diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f0db8c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY proxy/nginx-site.conf /etc/nginx/conf.d/default.conf +COPY . /usr/share/nginx/html +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -qO- http://localhost/ > /dev/null || exit 1 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fd53847 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +# Makefile +APP=relibre-app + +.PHONY: deps run build clean +deps: + # Add dependency and generate go.sum + go get github.com/webview/webview_go@v0.1.1 + go mod tidy + +run: + go run . + +build: + go build -trimpath -ldflags "-s -w" -o $(APP) + +clean: + rm -f $(APP) + diff --git a/com.circlewithadot.Relibre.yml b/com.circlewithadot.Relibre.yml new file mode 100644 index 0000000..a1968f3 --- /dev/null +++ b/com.circlewithadot.Relibre.yml @@ -0,0 +1,37 @@ +# Flatpak manifest for the native webview build of Relibre +# Build with: +# flatpak-builder --user --install --force-clean build-dir com.circlewithadot.Relibre.yml +# flatpak run com.circlewithadot.Relibre +app-id: com.circlewithadot.Relibre +runtime: org.gnome.Platform +runtime-version: '46' +sdk: org.gnome.Sdk +command: relibre +finish-args: + # WebKitGTK needs network; your app also runs a localhost HTTP server + - --share=network + # UI backends + - --socket=wayland + - --socket=fallback-x11 + # Allow saving to Downloads (your /save endpoint writes there) + - --filesystem=xdg-download + # Optional: allow opening files via portal + - --talk-name=org.freedesktop.portal.Desktop + +modules: + # Build your Go app inside the sandbox + - name: relibre + buildsystem: simple + build-commands: + # Ensure modules cached and vendor if you like (optional) + - go env -w CGO_ENABLED=1 + - go mod download + # Build the binary into /app/bin + - go build -trimpath -ldflags "-s -w" -o /app/bin/relibre + # Install desktop file and icon + - install -Dm644 dist/com.circlewithadot.Relibre.desktop /app/share/applications/com.circlewithadot.Relibre.desktop + - install -Dm644 dist/com.circlewithadot.Relibre.png /app/share/icons/hicolor/512x512/apps/com.circlewithadot.Relibre.png + sources: + # Use the current directory as source (assuming manifest sits at repo root) + - type: dir + path: . diff --git a/dist/PKGBUILD b/dist/PKGBUILD new file mode 100644 index 0000000..a107d17 --- /dev/null +++ b/dist/PKGBUILD @@ -0,0 +1,46 @@ +# Maintainer: James "Bruce" Osborne +pkgname=relibre +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Native webview app to generate single-file landing pages for music releases" +arch=('x86_64') +url="https://git.circlewithadot.net/incentive/relibre" +license=('MIT') +depends=('gtk3' 'webkit2gtk' 'libsoup3') +makedepends=('go' 'git') +provides=('relibre') +conflicts=('relibre-bin') + +# Build from a specific tag; update tag and checksum for new releases +source=("${pkgname}::git+${url}.git#tag=v${pkgver}") +sha256sums=('SKIP') + +# If you need a submodule, add: options=('!strip') and git submodule init/update lines in build() + +prepare() { + cd "${srcdir}/${pkgname}" + # Ensure Go modules are present + export GOPATH="${srcdir}/gopath" + export GOMODCACHE="${GOPATH}/pkg/mod" + go mod download +} + +build() { + cd "${srcdir}/${pkgname}" + export CGO_ENABLED=1 + go build -trimpath -ldflags "-s -w" -o relibre +} + +package() { + cd "${srcdir}/${pkgname}" + install -Dm755 relibre "${pkgdir}/usr/bin/relibre" + + # Desktop entry and icon (optional but nice) + install -Dm644 dist/com.circlewithadot.Relibre.desktop \ + "${pkgdir}/usr/share/applications/com.circlewithadot.Relibre.desktop" + install -Dm644 dist/com.circlewithadot.Relibre.png \ + "${pkgdir}/usr/share/icons/hicolor/512x512/apps/com.circlewithadot.Relibre.png" + + # License + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} diff --git a/dist/com.circlewithadot.Relibre.desktop b/dist/com.circlewithadot.Relibre.desktop new file mode 100644 index 0000000..24f8134 --- /dev/null +++ b/dist/com.circlewithadot.Relibre.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Relibre +Comment=Generate single-file landing pages for music releases +Exec=relibre +Icon=com.circlewithadot.Relibre +Categories=AudioVideo;Utility; +Terminal=false + diff --git a/dist/com.circlewithadot.Relibre.png b/dist/com.circlewithadot.Relibre.png new file mode 100644 index 0000000..cb3f29c Binary files /dev/null and b/dist/com.circlewithadot.Relibre.png differ diff --git a/docker-compose.yml.apache b/docker-compose.yml.apache new file mode 100644 index 0000000..ee83052 --- /dev/null +++ b/docker-compose.yml.apache @@ -0,0 +1,17 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile.web + expose: ["80"] + restart: unless-stopped + + proxy: + image: httpd:2.4-alpine + volumes: + - ./proxy/httpd.conf:/usr/local/apache2/conf/httpd.conf:ro + ports: + - "8080:80" + depends_on: + - web + restart: unless-stopped diff --git a/docker-compose.yml.caddy b/docker-compose.yml.caddy new file mode 100644 index 0000000..3f067f9 --- /dev/null +++ b/docker-compose.yml.caddy @@ -0,0 +1,17 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile.web + expose: ["80"] + restart: unless-stopped + + proxy: + image: caddy:alpine + volumes: + - ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro + ports: + - "8080:80" + depends_on: + - web + restart: unless-stopped diff --git a/docker-compose.yml.haproxy b/docker-compose.yml.haproxy new file mode 100644 index 0000000..dc4f0c7 --- /dev/null +++ b/docker-compose.yml.haproxy @@ -0,0 +1,17 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile.web + expose: ["80"] + restart: unless-stopped + + proxy: + image: haproxy:2.9 + volumes: + - ./proxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + ports: + - "8080:80" + depends_on: + - web + restart: unless-stopped diff --git a/docker-compose.yml.nginx b/docker-compose.yml.nginx new file mode 100644 index 0000000..448e47a --- /dev/null +++ b/docker-compose.yml.nginx @@ -0,0 +1,17 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile.web + expose: ["80"] + restart: unless-stopped + + proxy: + image: nginx:alpine + volumes: + - ./proxy/nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "8080:80" + depends_on: + - web + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d420bd --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module relibre + +go 1.22 + +require github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2421e19 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0= +github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5c2a237 --- /dev/null +++ b/main.go @@ -0,0 +1,163 @@ +// main.go +package main + +import ( + "bufio" + "embed" + "encoding/json" + "fmt" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + webview "github.com/webview/webview_go" +) + +//go:embed index.html ui/* images/* +var content embed.FS + +type saveReq struct { + Filename string `json:"filename"` + Content string `json:"content"` +} + +type saveResp struct { + Path string `json:"path"` +} + +func main() { + // Static files + subFS, err := fs.Sub(content, ".") + if err != nil { + log.Fatalf("fs.Sub error: %v", err) + } + mux := http.NewServeMux() + mux.Handle("/", http.FileServer(http.FS(subFS))) + + // /save endpoint writes to Downloads + mux.HandleFunc("/save", func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + defer func() { log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) }() + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req saveReq + if err := json.NewDecoder(io.LimitReader(r.Body, 10<<20)).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if req.Filename == "" { + req.Filename = fmt.Sprintf("relibre-%d.html", time.Now().Unix()) + } + req.Filename = sanitizeFilename(req.Filename) + + dir := downloadsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + http.Error(w, "mkdir failed", http.StatusInternalServerError) + return + } + path := filepath.Join(dir, req.Filename) + if err := os.WriteFile(path, []byte(req.Content), 0o644); err != nil { + http.Error(w, "write failed", http.StatusInternalServerError) + return + } + log.Printf("Saved HTML -> %s", path) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(saveResp{Path: path}) + }) + + // Simple request logger + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + mux.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) + }) + + // Start server on ephemeral port + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + log.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} + + go func() { + log.Printf("HTTP listening on http://%s", addr) + if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { + log.Fatalf("http serve: %v", err) + } + }() + + // Native webview window (debug true for dev tools/log signals) + debug := true + w := webview.New(debug) + defer w.Destroy() + w.SetTitle("Relibre") + w.SetSize(1100, 750, webview.HintNone) + w.Navigate(fmt.Sprintf("http://%s", addr)) + w.Run() + + _ = srv.Close() + if runtime.GOOS == "linux" { + time.Sleep(100 * time.Millisecond) + } +} + +func sanitizeFilename(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "relibre.html" + } + if !strings.HasSuffix(strings.ToLower(name), ".html") { + name += ".html" + } + re := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`) + return re.ReplaceAllString(name, "_") +} + +func downloadsDir() string { + if p := readXDGDownloads(); p != "" { + return p + } + home, _ := os.UserHomeDir() + if home == "" { + return "./" + } + return filepath.Join(home, "Downloads") +} + +func readXDGDownloads() string { + home, _ := os.UserHomeDir() + if home == "" { + return "" + } + path := filepath.Join(home, ".config", "user-dirs.dirs") + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "XDG_DOWNLOAD_DIR=") { + raw := strings.TrimPrefix(line, "XDG_DOWNLOAD_DIR=") + raw = strings.Trim(raw, `"`) + raw = strings.ReplaceAll(raw, "$HOME", home) + return raw + } + } + return "" +} + diff --git a/relibre-app b/relibre-app new file mode 100755 index 0000000..4920081 Binary files /dev/null and b/relibre-app differ diff --git a/ui/generator.js b/ui/generator.js index e0bc79f..3924d63 100644 --- a/ui/generator.js +++ b/ui/generator.js @@ -468,3 +468,5 @@ const TEMPLATE_HTML = ` }); }); })(); + + diff --git a/ui/generator.js.backup b/ui/generator.js.backup new file mode 100644 index 0000000..e0bc79f --- /dev/null +++ b/ui/generator.js.backup @@ -0,0 +1,470 @@ +/* @licstart The following is the entire license notice for the JavaScript code in this file. +Relibre + Copyleft (🄯) 2025 James Osborne +SPDX-License-Identifier: AGPL-3.0-or-later + +This file is free software: you can redistribute it and/or modify it under the terms of the +GNU Affero General Public License as published by the Free Software Foundation, either version 3 +of the License, or (at your option) any later version. + +This file is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +See the GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License along with this program. +If not, see . +@licend The above is the entire license notice for the JavaScript code in this file. */ + +'use strict'; + +// Single source of truth for icons. Anything provided by icons.js (window.RELIBRE_ICON) +// will override these defaults. Leave defaults empty so missing icons simply render +// without an . +const ICONS = Object.assign({ + website: '', bluesky: '', mastodon: '', pixelfed: '', peertube: '', + lemmy: '', owncast: '', applemusic: '', spotify: '', bandcamp: '', + youtube: '', reddit: '', facebook: '', instagram: '', tiktok: '', + threads: '', friendica: '', 'diaspora*': '', x: '', blog: '', email: '', + soundcloud: '', tidal: '' +}, (window.RELIBRE_ICON || {})); + +(function () { + +const STYLE_CSS = ` +:root{color-scheme:dark;--bg:#0b0b0c;--panel:#121214;--fg:#eaeaea;--muted:#9aa0a6;--acc:#e53935;--acc2:#ff7aa5;--br:18px;--input:#1a1b1e;--input-br:#2a2c30} +*{box-sizing:border-box} +html,body{margin:0;padding:0;background:var(--bg);color:var(--fg);font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif} +a{color:#9bd1ff} + +.hf-container{max-width:860px;margin:40px auto;padding:0 18px} +.hf-card{background:var(--panel);border:1px solid #1d1f21;border-radius:var(--br);padding:22px;margin:16px 0;box-shadow:0 12px 38px rgba(0,0,0,.45)} + +.hf-hero{display:block} +.hf-cover{display:block;width:100%;height:auto;border-radius:14px;margin:4px 0 16px} +.hf-title{font-size:clamp(28px,4.6vw,42px);line-height:1.1;margin:0 0 4px;font-weight:800} +.hf-subtitle{color:var(--muted);margin:0 0 14px} +.hf-cta-row{display:flex;gap:12px;flex-wrap:wrap;margin:12px 0} + +.btn{display:inline-flex;gap:8px;align-items:center;padding:12px 16px;border-radius:999px;text-decoration:none;font-weight:700} +.btn-ghost{background:#1b1c1f;border:1px solid #2a2c30;color:var(--fg)} +.btn-red{background:var(--acc);color:#fff;border:0} + +.hf-embed{margin-top:16px} +.hf-desc{margin-top:10px;white-space:pre-wrap} + +.hf-section-title{font-size:14px;letter-spacing:.08em;text-transform:uppercase;color:#ddd;margin:0 0 10px;font-weight:800} +.hf-link-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:12px} +.hf-link{display:flex;gap:10px;align-items:center;padding:14px 16px;border-radius:12px;background:#191a1d;border:1px solid #2a2c30;text-decoration:none;color:var(--fg)} +.hf-link img{display:block;width:24px;height:24px} +.hf-video, video{width:100%;border-radius:12px} + +.hf-title, +.hf-subtitle { text-align: center; } +.hf-cta-row { justify-content: center; } + +.hf-footer{margin:12px 0 28px;text-align:center;color:var(--muted);font-size:13px} +.hf-footer a{text-decoration:none} +.hf-footer a:hover{filter:brightness(1.1)} + +/* ---- Embeds ---- */ + +/* Clip corners on embeds and give consistent radius */ +.hf-embed{overflow:hidden;border-radius:12px} + +/* Bandcamp: center and match inner fixed width to avoid right-gutter */ +.bc-wrap{display:flex;justify-content:center} +.bc-iframe{display:block;border:0;width:700px;max-width:100%} + +/* YouTube: responsive 16:9 */ +.hf-video{aspect-ratio:16/9} +.hf-video iframe{width:100%;height:100%;display:block;border:0} + +/* Consistent tile hover for all services (no brand tints) */ +.hf-link{ + transition: background .2s ease, border-color .2s ease, color .2s ease, + transform .06s ease-out, box-shadow .2s ease; +} +.hf-link:hover{ + background:#24262a; + border-color:#3a3c40; + color:#fff; + transform:translateY(-1px); + box-shadow:0 0 0 3px rgba(255,255,255,.02), inset 0 0 0 1px rgba(255,255,255,.04); +} +.hf-link:hover span{ color:inherit } +.hf-link:focus-visible{ outline:2px solid var(--acc2); outline-offset:3px } +.hf-link:active{ transform:translateY(0) } + +@media (max-width:720px){ + .hf-link-grid{grid-template-columns:1fr} +} +`; + +const SCRIPT_JS = ''; + +const CREDIT_FOOTER = ``; + +const TEMPLATE_HTML = ` + + + + {{TITLE}} — {{ARTIST}} + + + + + + + + +
+
+ {{TITLE}} cover art +

{{TITLE}}

+

{{ARTIST}}

+
+ {{BUY_BUTTON_HTML}} + {{STREAM_MAIN_BUTTON_HTML}} +
+ {{AUDIO_EMBED_HTML}} + {{DESCRIPTION_HTML}} +
+ {{WATCH_HTML}} + {{SUPPORT_SECTION_HTML}} + {{STREAM_SECTION_HTML}} + {{FOLLOW_SECTION_HTML}} + {{CREDIT_FOOTER}} +
+ + +`; + + const $ = (sel, root=document) => root.querySelector(sel); + const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel)); + + const FOLLOW_SERVICES = [ + 'blog','website','mastodon','pixelfed','bluesky','peertube','owncast','lemmy','friendica','diaspora*','email' + ]; + + function setEnvStatus(){ + const proto = location.protocol.replace(':',''); + const el = $('#envStatus'); + if (el) el.textContent = `Protocol: ${proto}. Relibre generator works directly from file:// — no server required.`; + } + + function createServiceRow(serviceKey, container){ + const row = document.createElement('div'); + row.className = 'grid2'; + row.innerHTML = ` + +
`; + container.appendChild(row); + } + + // Add a custom row with different presets per section and a free-text label for [Custom] + function addCustomRow(container, type){ + const opts = (type === 'stream') + ? ['[Custom]', 'SoundCloud', 'YouTube', 'Tidal'] + : ['[Custom]', 'X', 'Instagram', 'Facebook', 'TikTok', 'Twitch', 'Threads', 'YouTube', 'Reddit']; + + const optionsHtml = opts.map(v => ``).join(''); + + const row = document.createElement('div'); + row.className = 'grid2'; + row.innerHTML = ` + + `; + + const sel = row.querySelector('[data-svc="custom-label"]'); + const txt = row.querySelector('[data-svc="custom-label-text"]'); + const update = () => txt.classList.toggle('hidden', sel.value !== '[Custom]'); + sel.addEventListener('change', update); + update(); + + container.appendChild(row); + } + + function toggleRadios(name,map){ + const inputs = $$(`input[name="${name}"]`); + const update = () => { + const val = inputs.find(i=>i.checked)?.value; + Object.entries(map).forEach(([key,el]) => el.classList.toggle('hidden', key !== val)); + }; + inputs.forEach(r=>r.addEventListener('change', update)); + update(); + } + + function mdToHtml(md){ + if(!md) return ''; + let html = md.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])) + .replace(/\*\*([^*]+)\*\*/g,'$1') + .replace(/\*([^*]+)\*/g,'$1') + .replace(/`([^`]+)`/g,'$1') + .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,'$1'); + const lines = html.split(/\r?\n/); + let out = '', inOl=false, inUl=false; + for(const line of lines){ + if(/^\s*\d+\.\s+/.test(line)){ if(!inOl){out+='
    '; inOl=true;} out+='
  1. '+line.replace(/^\s*\d+\.\s+/,'')+'
  2. '; continue; } + if(/^\s*[-*]\s+/.test(line)){ if(!inUl){out+='
      '; inUl=true;} out+='
    • '+line.replace(/^\s*[-*]\s+/,'')+'
    • '; continue; } + if(inOl){out+='
'; inOl=false;} if(inUl){out+=''; inUl=false;} + if(line.trim()) out+='

'+line+'

'; + } + if(inOl) out+=''; if(inUl) out+=''; + return out; + } + + function fileToDataURL(file){ + return new Promise((resolve,reject)=>{ + const fr = new FileReader(); + fr.onload = () => resolve(fr.result); + fr.onerror = reject; + fr.readAsDataURL(file); + }); + } + + const LABELS = { + applemusic: 'Apple Music', + amazonmusic: 'Amazon Music', + soundcloud: 'SoundCloud', + tidal: 'Tidal', + tiktok: 'TikTok', + 'diaspora*': 'diaspora*', + x: 'X' + }; + + function pretty(k){ + return LABELS[k] || k.charAt(0).toUpperCase() + k.slice(1); + } + + function escapeHtml(s){ return (s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } + + // Map normalized key -> icon key + function getServiceIcon(key){ + const map = { + spotify:'spotify', applemusic:'apple', bandcamp:'bandcamp', + soundcloud:'soundcloud', tidal:'tidal', + mastodon:'mastodon', pixelfed:'pixelfed', peertube:'peertube', lemmy:'lemmy', owncast:'owncast', + bluesky:'bluesky', facebook:'facebook', instagram:'instagram', twitter:'x', threads:'threads', tiktok:'tiktok', + email:'email', youtube:'youtube', reddit:'reddit', website:'website', blog:'blog', + friendica:'friendica', 'diaspora*':'diaspora', x:'x' + }; + const iconKey = map[key] || 'website'; + return ICONS[iconKey] || ''; + } + + // Normalize a display label to a lookup key (e.g., "Apple Music" -> "applemusic") + function labelToKey(label){ + return String(label || '').toLowerCase().replace(/[^a-z0-9]+/g,''); + } + + function renderButtonsGrid(title, buttons, id){ + if(!buttons.length) return ''; + const items = buttons.map(b => { + const key = labelToKey(b.label); + const icon = getServiceIcon(key); + const img = icon ? `` : ''; + return `${img}${escapeHtml(b.label)}`; + }).join(''); + const idAttr = id ? ` id="${id}"` : ''; + return `

${escapeHtml(title)}

`; + } + + // ---- Embeds normalizers ---- + + // Bandcamp: force dark, clamp to inner fixed width, and center + function normalizeBandcampEmbed(rawHtml){ + if(!rawHtml) return ''; + const wrap = document.createElement('div'); + wrap.innerHTML = rawHtml.trim(); + const iframe = wrap.querySelector('iframe'); + if(!iframe) return rawHtml; + + let src = iframe.getAttribute('src') || ''; + const setSeg = (name, val) => { + const re = new RegExp(`${name}=[^/]+`); + if (re.test(src)) src = src.replace(re, `${name}=${val}`); + else src += (src.endsWith('/') ? '' : '/') + `${name}=${val}/`; + }; + setSeg('bgcol', '121214'); // matches --panel + setSeg('linkcol', 'eaeaea'); // matches --fg + setSeg('transparent', 'true'); + iframe.setAttribute('src', src); + + const size = (src.match(/size=([^/]+)/)?.[1] || 'large').toLowerCase(); + const innerW = size === 'small' ? 350 : 700; + + iframe.classList.add('bc-iframe'); + iframe.removeAttribute('width'); + iframe.removeAttribute('height'); + iframe.style.width = innerW + 'px'; + + return `
${iframe.outerHTML}
`; + } + + // YouTube: strip fixed w/h so CSS controls aspect-ratio + function normalizeYouTubeEmbed(rawHtml){ + if(!rawHtml) return ''; + const wrap = document.createElement('div'); + wrap.innerHTML = rawHtml.trim(); + const iframe = wrap.querySelector('iframe'); + if(!iframe) return rawHtml; + iframe.removeAttribute('width'); + iframe.removeAttribute('height'); + return iframe.outerHTML; + } + + // ---- App ---- + window.addEventListener('DOMContentLoaded', () => { + setEnvStatus(); + + const streamList = $('#streamList'); + const followList = $('#followList'); + + ['spotify','applemusic','bandcamp'].forEach(s => createServiceRow(s, streamList)); + $('#addCustomStream').addEventListener('click', () => addCustomRow(streamList, 'stream')); + + FOLLOW_SERVICES.forEach(s => createServiceRow(s, followList)); + $('#addCustomFollow').addEventListener('click', () => addCustomRow(followList, 'follow')); + + toggleRadios('audioType', { bandcamp: $('#bandcampBox'), self: $('#selfAudioBox') }); + toggleRadios('watchType', { youtube: $('#watchIframeBox'), self: $('#watchSelfBox') }); + + $('#generate').addEventListener('click', async () => { + try{ + $('#status').textContent=''; + const artwork = $('#artwork').files[0]; + const title = $('#title').value.trim(); + const artist = $('#artist').value.trim(); + const buyLabel = $('#buyLabel').value.trim() || 'Buy on Bandcamp'; + const buyUrl = $('#buyUrl').value.trim(); + const audioType = $('input[name="audioType"]:checked').value; + const bandcampIframe = $('#bandcampIframe').value.trim(); + const audioMp3Url = $('#audioMp3Url')?.value.trim() || ''; + const audioOggUrl = $('#audioOggUrl')?.value.trim() || ''; + const descHtml = mdToHtml($('#desc').value); + const watchType = $('input[name="watchType"]:checked').value; + const watchIframe = $('#watchIframe').value.trim(); + const videoMp4Url = $('#videoMp4Url')?.value.trim() || ''; + const videoWebmUrl = $('#videoWebmUrl')?.value.trim() || ''; + const videoPosterUrl = $('#videoPosterUrl')?.value.trim() || ''; + const supportBandcamp = $('#supportBandcamp').value.trim(); + + // Streams + const streamButtons = []; + streamList.querySelectorAll('input[type="url"]:not([data-svc="custom-url"])') + .forEach(inp => { + if (inp.value.trim()) streamButtons.push({ label: pretty(inp.dataset.svc), url: inp.value.trim() }); + }); + streamList.querySelectorAll('input[data-svc="custom-url"]').forEach(inp => { + const row = inp.closest('.grid2'); + const sel = row.querySelector('[data-svc="custom-label"]'); + let lbl = sel.value.trim(); + if (lbl === '[Custom]') lbl = row.querySelector('[data-svc="custom-label-text"]').value.trim(); + if (inp.value.trim() && lbl) streamButtons.push({ label: lbl, url: inp.value.trim() }); + }); + + // Follows + const followButtons = []; + followList.querySelectorAll('input[type="url"]:not([data-svc="custom-url"])') + .forEach(inp => { + if (inp.value.trim()) followButtons.push({ label: pretty(inp.dataset.svc), url: inp.value.trim() }); + }); + followList.querySelectorAll('input[data-svc="custom-url"]').forEach(inp => { + const row = inp.closest('.grid2'); + const sel = row.querySelector('[data-svc="custom-label"]'); + let lbl = sel.value.trim(); + if (lbl === '[Custom]') lbl = row.querySelector('[data-svc="custom-label-text"]').value.trim(); + if (inp.value.trim() && lbl) followButtons.push({ label: lbl, url: inp.value.trim() }); + }); + + if(!artwork || !title || !artist){ + alert('Artwork, title, and artist are required.'); + return; + } + if(!buyUrl && streamButtons.length===0){ + if(!confirm('No Buy URL and no Stream buttons provided. Continue anyway?')) return; + } + + const coverDataURL = await fileToDataURL(artwork); + const BUY_BUTTON_HTML = buyUrl ? `${escapeHtml(buyLabel)}` : ''; + const STREAM_MAIN_BUTTON_HTML = streamButtons.length ? `Stream` : ''; + + let AUDIO_EMBED_HTML = ''; + if (audioType === 'bandcamp' && bandcampIframe) { + const fixed = normalizeBandcampEmbed(bandcampIframe); + AUDIO_EMBED_HTML = `
${fixed}
`; + } else if (audioType === 'self' && (audioMp3Url || audioOggUrl)) { + const sources = [ + audioMp3Url ? `` : '', + audioOggUrl ? `` : '' + ].join(''); + AUDIO_EMBED_HTML = `
`; + } + + const DESCRIPTION_HTML = descHtml ? `
${descHtml}
` : ''; + + let WATCH_HTML = ''; + if (watchType === 'youtube' && watchIframe){ + const yt = normalizeYouTubeEmbed(watchIframe); + WATCH_HTML = `
${yt}
`; + } else if (watchType === 'self' && (videoMp4Url || videoWebmUrl)){ + const posterAttr = videoPosterUrl ? ` poster="${videoPosterUrl}"` : ''; + const sources = [ + videoMp4Url ? `` : '', + videoWebmUrl ? `` : '' + ].join(''); + WATCH_HTML = `
`; + } + + const STREAM_SECTION_HTML = streamButtons.length + ? renderButtonsGrid(`Stream ${title}`, streamButtons, 'stream-section') + : ''; + + const SUPPORT_SECTION_HTML = supportBandcamp ? renderButtonsGrid('Buy / Support', [{label:'Bandcamp', url:supportBandcamp}]) : ''; + const FOLLOW_SECTION_HTML = renderButtonsGrid('Follow', followButtons); + + let html = TEMPLATE_HTML + .replaceAll('{{INLINE_STYLE}}', STYLE_CSS) + .replaceAll('{{INLINE_SCRIPT}}', SCRIPT_JS) + .replaceAll('{{TITLE}}', escapeHtml(title)) + .replaceAll('{{ARTIST}}', escapeHtml(artist)) + .replaceAll('{{COVER_PATH}}', coverDataURL) + .replaceAll('{{BUY_BUTTON_HTML}}', BUY_BUTTON_HTML) + .replaceAll('{{STREAM_MAIN_BUTTON_HTML}}', STREAM_MAIN_BUTTON_HTML) + .replaceAll('{{AUDIO_EMBED_HTML}}', AUDIO_EMBED_HTML) + .replaceAll('{{DESCRIPTION_HTML}}', DESCRIPTION_HTML) + .replaceAll('{{WATCH_HTML}}', WATCH_HTML) + .replaceAll('{{SUPPORT_SECTION_HTML}}', SUPPORT_SECTION_HTML) + .replaceAll('{{STREAM_SECTION_HTML}}', STREAM_SECTION_HTML) + .replaceAll('{{FOLLOW_SECTION_HTML}}', FOLLOW_SECTION_HTML) + .replaceAll('{{CREDIT_FOOTER}}', CREDIT_FOOTER); + + const blob = new Blob([html], {type:'text/html;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const slug = (title || 'release').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,''); + a.href = url; a.download = `${slug}.html`; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + $('#status').textContent = `Downloaded → ${slug}.html`; + }catch(err){ + console.error(err); + $('#status').textContent = 'Generation failed (see console).'; + alert('Generation failed. See console for details.'); + } + }); + }); +})();