Storage structure
- All audio tracks (primary + per-language) now live in one folder per song with
unique lowercase names ({slug}-{lang}-{id}); no more tracks/ subfolder.
- Generated renders (download video + HLS) moved into the song's local-only cache/
subfolder, separated from source files (never synced to NAS, safe to wipe).
- tracks:reorganize artisan command (dry-run default) consolidates legacy files,
updates DB paths, and deletes orphans + empty folders.
- CLAUDE.md documents the canonical layout as a global rule (identical local + NAS).
Version-aware download & share
- Download MP3/Video and Share now act on the version being played; ?track={id}
is carried through share links and auto-selects audio + title + flag + about +
OG/meta on open.
GPU + visualizer
- Setting::gpuUsable() runs a cached health probe (nvidia-smi + nvenc smoke test,
256x144) before sending any encode to the GPU; falls back to CPU otherwise.
- Visualizer "Download Video" bakes in equal-width, cover-coloured, translucent
frequency bars; loop-filter rebuild makes generation ~25x faster.
Image cropper
- result-callback mode + per-song cover-slide cropper in upload/edit (modal + mobile).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
305 lines
16 KiB
PHP
305 lines
16 KiB
PHP
@props([
|
|
'name' => '',
|
|
'id' => null,
|
|
'value' => '',
|
|
'placeholder' => 'Tell viewers about this content…',
|
|
'class' => '',
|
|
'style' => '',
|
|
'minHeight' => '110px',
|
|
])
|
|
|
|
@php
|
|
$rid = $id ?: 'rte-' . \Illuminate\Support\Str::random(6);
|
|
@endphp
|
|
|
|
{{-- ── Shared CSS (once per page) ─────────────────────────────────────────── --}}
|
|
@once('rte-styles')
|
|
<style>
|
|
.rte-wrap { position: relative; }
|
|
.rte-source { display: none !important; }
|
|
.rte-toolbar {
|
|
display: flex; flex-wrap: wrap; gap: 2px; align-items: center;
|
|
background: var(--bg-dark, #0f0f0f);
|
|
border: 1px solid var(--border-color, #303030); border-bottom: none;
|
|
border-radius: 8px 8px 0 0; padding: 5px 6px;
|
|
}
|
|
.rte-tbtn {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 30px; height: 30px; border: none; border-radius: 6px;
|
|
background: none; color: var(--text-secondary, #aaa);
|
|
font-size: 15px; cursor: pointer; transition: background .12s, color .12s; outline: none;
|
|
}
|
|
.rte-tbtn:hover { background: rgba(255,255,255,.08); color: var(--text-primary, #f1f1f1); }
|
|
.rte-tbtn.active { background: rgba(230,30,30,.18); color: var(--brand-red, #e61e1e); }
|
|
.rte-sep { width: 1px; height: 18px; background: var(--border-color, #303030); margin: 0 3px; flex-shrink: 0; }
|
|
.rte-editable {
|
|
background: var(--bg-dark, #0f0f0f);
|
|
border: 1px solid var(--border-color, #303030); border-radius: 0 0 8px 8px;
|
|
padding: 10px 12px; color: var(--text-primary, #f1f1f1);
|
|
font-size: 13px; line-height: 1.6; font-family: inherit;
|
|
outline: none; overflow-y: auto; word-break: break-word;
|
|
}
|
|
.rte-editable:focus { border-color: var(--brand-red, #e61e1e); }
|
|
.rte-editable:empty::before { content: attr(data-placeholder); color: var(--text-secondary, #777); pointer-events: none; }
|
|
.rte-editable p { margin: 0 0 8px; }
|
|
.rte-editable p:last-child { margin-bottom: 0; }
|
|
.rte-editable h2 { font-size: 18px; font-weight: 700; margin: 4px 0 8px; }
|
|
.rte-editable h3 { font-size: 15px; font-weight: 700; margin: 4px 0 6px; }
|
|
.rte-editable ul, .rte-editable ol { margin: 0 0 8px; padding-left: 22px; }
|
|
.rte-editable blockquote { margin: 0 0 8px; padding-left: 12px; border-left: 3px solid var(--border-color, #303030); color: var(--text-secondary, #aaa); }
|
|
.rte-editable a { color: #3ea6ff; }
|
|
.rte-editable a.action-btn, .rte-editable a.action-btn-primary, .rte-editable a.action-btn-danger {
|
|
color: inherit; text-decoration: none;
|
|
}
|
|
|
|
/* inline popover (link / button / emoji) */
|
|
.rte-pop {
|
|
position: absolute; z-index: 1090; min-width: 240px;
|
|
background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030);
|
|
border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.55); padding: 10px;
|
|
}
|
|
.rte-pop[hidden] { display: none; }
|
|
.rte-pop label { display: block; font-size: 11px; color: var(--text-secondary, #aaa); margin: 0 0 4px; }
|
|
.rte-pop input[type=text] {
|
|
width: 100%; box-sizing: border-box; background: var(--bg-dark, #0f0f0f);
|
|
border: 1px solid var(--border-color, #303030); border-radius: 6px;
|
|
padding: 7px 9px; color: var(--text-primary, #f1f1f1); font-size: 13px; outline: none; margin-bottom: 8px;
|
|
}
|
|
.rte-pop input[type=text]:focus { border-color: var(--brand-red, #e61e1e); }
|
|
.rte-pop-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
|
.rte-pop-btn { border: none; border-radius: 6px; padding: 6px 12px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
|
.rte-pop-btn.cancel { background: rgba(255,255,255,.08); color: var(--text-secondary, #aaa); }
|
|
.rte-pop-btn.ok { background: var(--brand-red, #e61e1e); color: #fff; }
|
|
.rte-emoji-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px; max-height: 180px; overflow-y: auto; }
|
|
.rte-emoji-grid button { border: none; background: none; font-size: 18px; line-height: 1; padding: 5px; border-radius: 6px; cursor: pointer; }
|
|
.rte-emoji-grid button:hover { background: rgba(255,255,255,.1); }
|
|
</style>
|
|
@endonce
|
|
|
|
{{-- ── Engine (once per page) ─────────────────────────────────────────────── --}}
|
|
@once('rte-script')
|
|
<script>
|
|
(function () {
|
|
if (window.RTE) return;
|
|
|
|
var EMOJI = ('😀 😃 😄 😁 😆 😅 😂 🤣 😊 😇 🙂 😉 😍 🥰 😘 😎 🤩 🥳 🤔 🤨 😐 😴 😭 😡 🥺 😱 '
|
|
+ '👍 👎 👏 🙌 🙏 💪 🔥 ✨ ⭐ 🌟 💯 ✅ ❌ ❓ ❗ ❤️ 🧡 💛 💚 💙 💜 🖤 💔 💕 💖 '
|
|
+ '🎉 🎊 🎁 🏆 🥇 ⚽ 🏀 🏈 🎵 🎶 🎬 📺 📱 💻 📷 🎤 🎮 🚀 🌍 ☀️ 🌙 ⚡ 💧 🍀').split(' ');
|
|
|
|
function saveSel(ed) {
|
|
var s = window.getSelection();
|
|
if (s && s.rangeCount && ed.contains(s.anchorNode)) ed._range = s.getRangeAt(0).cloneRange();
|
|
}
|
|
function restoreSel(ed) {
|
|
ed.focus();
|
|
if (ed._range) {
|
|
var s = window.getSelection();
|
|
s.removeAllRanges();
|
|
s.addRange(ed._range);
|
|
}
|
|
}
|
|
function exec(ed, cmd, val) { restoreSel(ed); document.execCommand(cmd, false, val || null); sync(ed); }
|
|
// Alignment must be emitted as CSS text-align (not the legacy align attribute,
|
|
// which the server sanitizer strips), so toggle styleWithCSS just for these.
|
|
function execAlign(ed, cmd) {
|
|
restoreSel(ed);
|
|
try { document.execCommand('styleWithCSS', false, true); } catch (e) {}
|
|
document.execCommand(cmd, false, null);
|
|
try { document.execCommand('styleWithCSS', false, false); } catch (e) {}
|
|
sync(ed);
|
|
}
|
|
function sync(ed) {
|
|
var src = ed._source;
|
|
var html = ed.innerHTML.trim();
|
|
if (html === '<br>' || html === '<div><br></div>' || html === '<p><br></p>') html = '';
|
|
src.value = html;
|
|
src.dispatchEvent(new Event('input', { bubbles: true }));
|
|
src.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
function tbtn(icon, title, fn) {
|
|
var b = document.createElement('button');
|
|
b.type = 'button'; b.className = 'rte-tbtn'; b.title = title;
|
|
b.innerHTML = '<i class="bi bi-' + icon + '"></i>';
|
|
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
|
b.addEventListener('click', function (e) { e.preventDefault(); fn(e, b); });
|
|
return b;
|
|
}
|
|
function sep() { var s = document.createElement('span'); s.className = 'rte-sep'; return s; }
|
|
|
|
function buildPopover(wrap, ed) {
|
|
var pop = document.createElement('div');
|
|
pop.className = 'rte-pop'; pop.hidden = true;
|
|
wrap.appendChild(pop);
|
|
document.addEventListener('click', function (e) {
|
|
if (!pop.hidden && !pop.contains(e.target) && !wrap.querySelector('.rte-toolbar').contains(e.target)) pop.hidden = true;
|
|
});
|
|
return pop;
|
|
}
|
|
function openPop(pop, anchorBtn, html) {
|
|
pop.innerHTML = html;
|
|
pop.hidden = false;
|
|
var tb = anchorBtn.closest('.rte-toolbar');
|
|
pop.style.top = (tb.offsetTop + tb.offsetHeight + 4) + 'px';
|
|
pop.style.left = Math.min(anchorBtn.offsetLeft, tb.offsetWidth - 250) + 'px';
|
|
var f = pop.querySelector('input[type=text]');
|
|
if (f) setTimeout(function () { f.focus(); }, 10);
|
|
}
|
|
|
|
function linkForm(pop, ed, anchorBtn, asButton) {
|
|
saveSel(ed);
|
|
var sel = window.getSelection();
|
|
var selText = (sel && ed.contains(sel.anchorNode)) ? sel.toString() : '';
|
|
openPop(pop, anchorBtn,
|
|
'<label>' + (asButton ? 'Button label' : 'Link text') + '</label>'
|
|
+ '<input type="text" class="rte-f-text" value="' + selText.replace(/"/g, '"') + '" placeholder="' + (asButton ? 'Click here' : 'Text to show') + '">'
|
|
+ '<label>URL</label>'
|
|
+ '<input type="text" class="rte-f-url" placeholder="https://…">'
|
|
+ '<div class="rte-pop-actions"><button type="button" class="rte-pop-btn cancel">Cancel</button>'
|
|
+ '<button type="button" class="rte-pop-btn ok">Insert</button></div>');
|
|
pop.querySelector('.cancel').onclick = function () { pop.hidden = true; };
|
|
pop.querySelector('.ok').onclick = function () {
|
|
var txt = pop.querySelector('.rte-f-text').value.trim();
|
|
var url = pop.querySelector('.rte-f-url').value.trim();
|
|
if (!url) { pop.querySelector('.rte-f-url').focus(); return; }
|
|
if (!/^([a-z][a-z0-9+.\-]*:|\/|#)/i.test(url)) url = 'https://' + url;
|
|
txt = txt || url;
|
|
var esc = function (s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); };
|
|
var cls = asButton ? ' class="action-btn action-btn-primary"' : '';
|
|
var a = '<a href="' + esc(url) + '" target="_blank"' + cls + '>' + esc(txt) + '</a>';
|
|
restoreSel(ed);
|
|
document.execCommand('insertHTML', false, a + (asButton ? '<span> </span>' : ''));
|
|
sync(ed);
|
|
pop.hidden = true;
|
|
};
|
|
}
|
|
|
|
function emojiForm(pop, ed, anchorBtn) {
|
|
saveSel(ed);
|
|
var grid = '<div class="rte-emoji-grid">';
|
|
for (var i = 0; i < EMOJI.length; i++) grid += '<button type="button" data-e="' + EMOJI[i] + '">' + EMOJI[i] + '</button>';
|
|
grid += '</div>';
|
|
openPop(pop, anchorBtn, grid);
|
|
pop.querySelectorAll('.rte-emoji-grid button').forEach(function (b) {
|
|
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
|
b.addEventListener('click', function () {
|
|
restoreSel(ed);
|
|
document.execCommand('insertText', false, b.dataset.e);
|
|
sync(ed);
|
|
pop.hidden = true;
|
|
});
|
|
});
|
|
}
|
|
|
|
window.RTE = {
|
|
init: function (wrap) {
|
|
if (!wrap || wrap.dataset.rteReady) return;
|
|
var src = wrap.querySelector('.rte-source');
|
|
if (!src) return;
|
|
// Skip un-instantiated templates whose placeholders aren't filled in yet
|
|
// (e.g. edit-__TPL__-desc / __DESCNAME__). Closed modals are aria-hidden
|
|
// but must still init, so we only key off the placeholder marker here.
|
|
var marker = (src.id || '') + '|' + (src.getAttribute('name') || '');
|
|
if (marker.indexOf('__') !== -1) return;
|
|
wrap.dataset.rteReady = '1';
|
|
|
|
var toolbar = document.createElement('div');
|
|
toolbar.className = 'rte-toolbar';
|
|
|
|
var ed = document.createElement('div');
|
|
ed.className = 'rte-editable';
|
|
ed.contentEditable = 'true';
|
|
ed.setAttribute('data-placeholder', src.getAttribute('placeholder') || '');
|
|
ed.style.minHeight = wrap.dataset.minHeight || '110px';
|
|
ed.innerHTML = src.value || '';
|
|
ed._source = src;
|
|
src._editable = ed;
|
|
|
|
var pop = null;
|
|
function P() { return pop || (pop = buildPopover(wrap, ed)); }
|
|
|
|
toolbar.appendChild(tbtn('type-bold', 'Bold', function () { exec(ed, 'bold'); }));
|
|
toolbar.appendChild(tbtn('type-italic', 'Italic', function () { exec(ed, 'italic'); }));
|
|
toolbar.appendChild(tbtn('type-underline', 'Underline', function () { exec(ed, 'underline'); }));
|
|
toolbar.appendChild(tbtn('type-strikethrough', 'Strikethrough', function () { exec(ed, 'strikeThrough'); }));
|
|
toolbar.appendChild(sep());
|
|
toolbar.appendChild(tbtn('type-h2', 'Heading', function () {
|
|
var inH = document.queryCommandValue('formatBlock').toLowerCase();
|
|
exec(ed, 'formatBlock', (inH === 'h2' || inH === '<h2>') ? 'p' : 'h2');
|
|
}));
|
|
toolbar.appendChild(tbtn('list-ul', 'Bulleted list', function () { exec(ed, 'insertUnorderedList'); }));
|
|
toolbar.appendChild(tbtn('list-ol', 'Numbered list', function () { exec(ed, 'insertOrderedList'); }));
|
|
toolbar.appendChild(tbtn('quote', 'Quote', function () { exec(ed, 'formatBlock', 'blockquote'); }));
|
|
toolbar.appendChild(sep());
|
|
toolbar.appendChild(tbtn('text-left', 'Align left', function () { execAlign(ed, 'justifyLeft'); }));
|
|
toolbar.appendChild(tbtn('text-center', 'Align center', function () { execAlign(ed, 'justifyCenter'); }));
|
|
toolbar.appendChild(tbtn('text-right', 'Align right', function () { execAlign(ed, 'justifyRight'); }));
|
|
toolbar.appendChild(sep());
|
|
toolbar.appendChild(tbtn('link-45deg', 'Insert link', function (e, b) { linkForm(P(), ed, b, false); }));
|
|
toolbar.appendChild(tbtn('hand-index-thumb', 'Insert button link', function (e, b) { linkForm(P(), ed, b, true); }));
|
|
toolbar.appendChild(tbtn('emoji-smile', 'Insert emoji', function (e, b) { emojiForm(P(), ed, b); }));
|
|
toolbar.appendChild(sep());
|
|
toolbar.appendChild(tbtn('eraser', 'Clear formatting', function () { exec(ed, 'removeFormat'); }));
|
|
|
|
src.parentNode.insertBefore(toolbar, src);
|
|
src.parentNode.insertBefore(ed, src);
|
|
|
|
ed.addEventListener('input', function () { sync(ed); });
|
|
ed.addEventListener('keyup', function () { saveSel(ed); });
|
|
ed.addEventListener('mouseup', function () { saveSel(ed); });
|
|
ed.addEventListener('blur', function () { saveSel(ed); sync(ed); });
|
|
// Paste as plain text to avoid junk markup.
|
|
ed.addEventListener('paste', function (e) {
|
|
e.preventDefault();
|
|
var t = (e.clipboardData || window.clipboardData).getData('text/plain');
|
|
document.execCommand('insertText', false, t);
|
|
sync(ed);
|
|
});
|
|
// Keep editor in sync if external code rewrites the textarea value.
|
|
src.addEventListener('rte:refresh', function () { ed.innerHTML = src.value || ''; });
|
|
|
|
var form = src.closest('form');
|
|
if (form && !form.dataset.rteSubmitBound) {
|
|
form.dataset.rteSubmitBound = '1';
|
|
form.addEventListener('submit', function () {
|
|
form.querySelectorAll('.rte-editable').forEach(function (e2) { if (e2._source) sync(e2); });
|
|
}, true);
|
|
}
|
|
},
|
|
initAll: function (root) {
|
|
(root || document).querySelectorAll('.rte-wrap:not([data-rte-ready])').forEach(function (w) { RTE.init(w); });
|
|
}
|
|
};
|
|
|
|
// Auto-init any editor markup added later (modals, JS-cloned track rows).
|
|
var mo = new MutationObserver(function (muts) {
|
|
for (var i = 0; i < muts.length; i++) {
|
|
for (var j = 0; j < muts[i].addedNodes.length; j++) {
|
|
var n = muts[i].addedNodes[j];
|
|
if (n.nodeType !== 1) continue;
|
|
if (n.classList && n.classList.contains('rte-wrap')) RTE.init(n);
|
|
if (n.querySelectorAll) n.querySelectorAll('.rte-wrap:not([data-rte-ready])').forEach(function (w) { RTE.init(w); });
|
|
}
|
|
}
|
|
});
|
|
function rteBoot() {
|
|
RTE.initAll();
|
|
if (document.body) mo.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', rteBoot);
|
|
} else {
|
|
rteBoot();
|
|
}
|
|
}());
|
|
</script>
|
|
@endonce
|
|
|
|
{{-- ── Instance ───────────────────────────────────────────────────────────── --}}
|
|
<div class="rte-wrap {{ $class }}" data-min-height="{{ $minHeight }}" @if($style) style="{{ $style }}" @endif>
|
|
<textarea class="rte-source"
|
|
name="{{ $name }}"
|
|
id="{{ $rid }}"
|
|
placeholder="{{ $placeholder }}">{{ $value }}</textarea>
|
|
</div>
|