/* @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}}
{{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+='