takeone-youtube-clone/resources/views/components/track-editor-form.blade.php
ghassan 66fd78c10f Add multi-language audio tracks and self-hosted flag-icons
Introduce per-video language support and multiple audio tracks
(VideoAudioTrack model + migrations for language, description, title),
a reusable language-select component, and a track-editor form. Bundle
the self-hosted flag-icons v7.2.3 library and a NAS auto-sync command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:32:52 +03:00

377 lines
21 KiB
PHP

@props([
'prefix' => 't1',
'isPrimary' => false,
'languageName' => 'primary_language',
'languageId' => 'edit_primary_language',
'titleName' => 'title',
'titleId' => 'edit-track1-title',
'descName' => 'description',
'descId' => 'edit-track1-desc',
'videoFileInputId' => 'edit-video-file',
'embedAudioInput' => false,
'audioInputName' => '',
])
@php
$p = $prefix;
$langOptions = \App\Data\Languages::forLanguage();
$musicDisplay = $isPrimary ? 'none' : 'flex';
@endphp
{{-- ── CSD shared CSS ────────────────────────────────────────────────────────── --}}
@once('csd-styles')
<style>
.csd-wrap { position: relative; }
.csd-lbl { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--text-primary, #f1f1f1); }
.csd-lbl .req { color: var(--brand-red, #e61e1e); margin-left: 2px; }
.csd-btn {
display: flex; align-items: center; gap: 8px; width: 100%;
background: var(--bg-dark, #0f0f0f); border: 1px solid var(--border-color, #303030);
border-radius: 8px; padding: 10px 14px; color: var(--text-primary, #f1f1f1);
font-size: 14px; font-family: inherit; line-height: 1.4; cursor: pointer;
text-align: left; transition: border-color .2s; outline: none; min-height: 46px; box-sizing: border-box;
}
.csd-btn:hover, .csd-btn:focus-visible { border-color: var(--brand-red, #e61e1e); }
.csd-btn[aria-expanded="true"] { border-color: var(--brand-red, #e61e1e); }
.csd-ico { font-size: 18px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-val { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-val.ph { color: var(--text-secondary, #aaa); }
.csd-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; }
.csd-arr { flex-shrink: 0; font-size: 11px; color: var(--text-secondary, #aaa); margin-left: 4px; transition: transform .2s; }
.csd-btn[aria-expanded="true"] .csd-arr { transform: rotate(180deg); }
.csd-panel {
position: fixed; z-index: 1080; min-width: 220px;
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); overflow: hidden;
}
.csd-srch { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #303030); }
.csd-srch i { font-size: 13px; color: var(--text-secondary, #aaa); flex-shrink: 0; }
.csd-sinput { flex: 1; background: none; border: none; outline: none; color: var(--text-primary, #f1f1f1); font-size: 13px; font-family: inherit; }
.csd-sinput::placeholder { color: var(--text-secondary, #aaa); }
.csd-list { list-style: none; margin: 0; padding: 4px 0; max-height: 240px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-color, #303030) transparent; }
.csd-list::-webkit-scrollbar { width: 4px; }
.csd-list::-webkit-scrollbar-thumb { background: var(--border-color, #303030); border-radius: 2px; }
.csd-opt { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; color: var(--text-primary, #f1f1f1); transition: background .1s; outline: none; }
.csd-opt:hover, .csd-opt:focus { background: rgba(255,255,255,.07); }
.csd-opt[aria-selected="true"] { background: rgba(255,255,255,.05); }
.csd-opt-ico { font-size: 17px; line-height: 1; flex-shrink: 0; user-select: none; }
.csd-opt-main { font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.csd-opt-sub { font-size: 12px; color: var(--text-secondary, #aaa); white-space: nowrap; flex-shrink: 0; max-width: 45%; overflow: hidden; text-overflow: ellipsis; }
.csd-empty { padding: 12px; font-size: 13px; color: var(--text-secondary, #aaa); text-align: center; }
</style>
@endonce
@once('lsd-flag-styles')
<style>
.lsd-flag { display: inline-block; width: 20px; height: 15px; border-radius: 2px; flex-shrink: 0; }
.csd-btn .lsd-flag { width: 22px; height: 16px; }
</style>
@endonce
@once('csd-script')
<script>
(function () {
if (window.CSD) return;
window.CSD = class {
constructor(id) {
this.root = document.getElementById(id);
this.btn = this.root.querySelector('.csd-btn');
this.panel = this.root.querySelector('.csd-panel');
this.sinput = this.root.querySelector('.csd-sinput');
this.list = this.root.querySelector('.csd-list');
this.hidden = this.root.querySelector('input[type=hidden]');
this.icoEl = this.btn.querySelector('.csd-ico');
this.valEl = this.btn.querySelector('.csd-val');
this.subEl = this.btn.querySelector('.csd-sub');
this.emptyEl= this.root.querySelector('.csd-empty');
this._bind();
}
_bind() {
this.btn.addEventListener('click', e => { e.stopPropagation(); this.toggle(); });
this.sinput.addEventListener('input', () => this._filter());
this.list.addEventListener('click', e => { const o = e.target.closest('.csd-opt'); if (o) this._pick(o); });
document.addEventListener('click', e => { if (!this.root.contains(e.target)) this.close(); }, true);
this.btn.addEventListener('keydown', e => { if (e.key==='Enter'||e.key===' ') { e.preventDefault(); this.toggle(); } });
this.sinput.addEventListener('keydown', e => {
if (e.key==='Escape') { this.close(); this.btn.focus(); }
if (e.key==='ArrowDown') { e.preventDefault(); this._move(0); }
if (e.key==='Enter') { e.preventDefault(); }
});
this.list.addEventListener('keydown', e => {
const all = [...this.list.querySelectorAll('.csd-opt:not([hidden])')];
const i = all.indexOf(document.activeElement.closest('.csd-opt'));
if (e.key==='ArrowDown') { e.preventDefault(); this._move(i+1); }
if (e.key==='ArrowUp') { e.preventDefault(); i>0 ? this._move(i-1) : this.sinput.focus(); }
if (e.key==='Enter') { e.preventDefault(); if (all[i]) this._pick(all[i]); }
if (e.key==='Escape') { this.close(); this.btn.focus(); }
});
}
toggle() { this.panel.hidden ? this.open() : this.close(); }
open() {
document.querySelectorAll('.csd-panel:not([hidden])').forEach(p => { p.hidden = true; });
document.querySelectorAll('.csd-btn[aria-expanded=true]').forEach(b => b.setAttribute('aria-expanded','false'));
this.panel.hidden = false;
this.btn.setAttribute('aria-expanded','true');
this.sinput.value = '';
this._filter();
requestAnimationFrame(() => this.sinput.focus());
const r = this.btn.getBoundingClientRect();
const goUp = window.innerHeight - r.bottom < 280 && r.top > 280;
this.panel.style.left = r.left + 'px';
this.panel.style.width = Math.max(r.width, 220) + 'px';
if (goUp) {
this.panel.style.top = '';
this.panel.style.bottom = (window.innerHeight - r.top + 4) + 'px';
} else {
this.panel.style.top = (r.bottom + 4) + 'px';
this.panel.style.bottom = '';
}
}
close() { this.panel.hidden=true; this.btn.setAttribute('aria-expanded','false'); }
_filter() {
const q = this.sinput.value.toLowerCase(); let n=0;
this.list.querySelectorAll('.csd-opt').forEach(o => {
const show = o.dataset.s.includes(q);
o.style.display = show ? '' : 'none';
if (show) n++;
});
if (this.emptyEl) this.emptyEl.style.display = n > 0 ? 'none' : '';
}
_move(i) { const all=[...this.list.querySelectorAll('.csd-opt:not([hidden])')]; if(all[i]){all[i].tabIndex=0;all[i].focus();} }
_pick(opt) {
this.hidden.value = opt.dataset.v;
this.icoEl.innerHTML = opt.querySelector('.csd-opt-ico').innerHTML;
this.valEl.textContent = opt.querySelector('.csd-opt-main').textContent;
this.valEl.classList.remove('ph');
if (this.subEl) { const s=opt.querySelector('.csd-opt-sub'); this.subEl.textContent=s?s.textContent:''; }
this.list.querySelectorAll('.csd-opt').forEach(o => o.setAttribute('aria-selected', o===opt?'true':'false'));
this.close(); this.btn.focus();
this.hidden.dispatchEvent(new Event('change',{bubbles:true}));
}
};
}());
</script>
@endonce
<div class="um-track-form" id="edit-tf-{{ $p }}" style="display:none;">
{{-- ── Primary track banner ──────────────────────────────────── --}}
@if($isPrimary)
<div class="um-tf-primary-note">
<i class="bi bi-star-fill"></i>
<span>Primary Track this language and title are the default display name</span>
</div>
@endif
{{-- ── Language + Title ──────────────────────────────────────── --}}
<div class="um-tf-row2">
<div id="edit-tf-{{ $p }}-lang-wrap">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language</label>
<div class="csd-wrap" id="csd_{{ $p }}">
<button type="button" class="csd-btn" aria-haspopup="listbox" aria-expanded="false" aria-label="Select language">
<span class="csd-ico"><span class="fi fi-xx lsd-flag"></span></span>
<span class="csd-val ph">Select language</span>
<i class="bi bi-chevron-down csd-arr"></i>
</button>
<div class="csd-panel" hidden role="listbox">
<div class="csd-srch">
<i class="bi bi-search"></i>
<input class="csd-sinput" type="text" placeholder="Search language…" autocomplete="off" spellcheck="false">
</div>
<ul class="csd-list">
@foreach($langOptions as $opt)
<li class="csd-opt" role="option" tabindex="-1"
data-v="{{ $opt['value'] }}" data-s="{{ $opt['search'] }}" aria-selected="false">
<span class="csd-opt-ico"><span class="fi fi-{{ $opt['flag'] }} lsd-flag"></span></span>
<span class="csd-opt-main">{{ $opt['label'] }}</span>
<span class="csd-opt-sub">{{ $opt['native'] }}</span>
</li>
@endforeach
</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden" name="{{ $languageName }}" id="{{ $languageId }}">
</div>
</div>
<div>
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Title @if($isPrimary)<span class="um-req">*</span>@else<span class="um-lbl-hint">optional</span>@endif
</label>
<input type="text"
name="{{ $titleName }}"
id="{{ $titleId }}"
class="um-input"
style="font-size:13px;padding:9px 12px;"
placeholder="Video title…"
autocomplete="off">
</div>
</div>
{{-- ── Description ────────────────────────────────────────────── --}}
<div class="um-field">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Description @if(!$isPrimary)<span class="um-lbl-hint">optional</span>@endif</label>
<textarea name="{{ $descName }}"
id="{{ $descId }}"
class="um-input um-textarea"
rows="3"
style="font-size:13px;padding:9px 12px;"
placeholder="Tell viewers about this content…"></textarea>
</div>
{{-- ── Video file + Thumbnail (video / match mode, primary only) ── --}}
<div style="display:flex;gap:12px;align-items:stretch;">
<div class="um-field um-field-col" id="edit-tf-{{ $p }}-video-zone"
style="display:none;flex:1;min-width:0;margin-bottom:0;">
<label class="um-lbl">
<i class="bi bi-film"></i> Replace Video
<span class="um-lbl-hint">optional</span>
</label>
<div id="edit-{{ $p }}-dropzone" class="um-dropzone um-zone-fill"
onclick="if(!event.target.closest('.um-x-btn')) {{ $videoFileInputId }}.click()"
ondragover="event.preventDefault();this.classList.add('dragover')"
ondragleave="this.classList.remove('dragover')"
ondrop="event.preventDefault();this.classList.remove('dragover');
if(event.dataTransfer.files.length){
editVideoFile.files=event.dataTransfer.files;
editHandleVideoSelect(editVideoFile);
}">
<div id="edit-{{ $p }}-dz-idle" class="um-dz-idle">
<div class="um-dz-ring"><i class="bi bi-cloud-arrow-up"></i></div>
<p class="um-dz-title">Drop to replace</p>
<p class="um-dz-sub">or click to browse</p>
<div class="um-dz-formats">
<span>MP4</span><span>MOV</span><span>MKV</span><span>AVI</span><span>WebM</span>
</div>
</div>
<div id="edit-{{ $p }}-file-info" class="um-file-card" style="display:none;">
<div class="um-file-icon-wrap"><i class="bi bi-film"></i></div>
<div class="um-file-meta">
<span class="um-file-name" id="edit-{{ $p }}-filename"></span>
<span class="um-file-size" id="edit-{{ $p }}-filesize"></span>
</div>
<button type="button" class="um-x-btn" onclick="editRemoveVideo(event)" title="Remove">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
</div>
<div class="um-field um-field-col" id="edit-tf-{{ $p }}-thumb-wrap"
style="display:none;flex:1;min-width:0;margin-bottom:0;">
<label class="um-lbl">
<i class="bi bi-card-image"></i>
<span>Thumbnail</span>
<span class="um-lbl-hint">16:9</span>
</label>
<div id="edit-{{ $p }}-thumbnail-dropzone" class="um-thumb-zone um-zone-fill">
<input type="file" name="thumbnail" id="edit-{{ $p }}-thumbnail-input"
accept="image/*" style="display:none;"
onchange="editHandleThumbnail(this, '{{ $p }}')">
<div id="edit-{{ $p }}-thumbnail-ph" class="um-thumb-ph">
<div class="um-dz-ring"><i class="bi bi-card-image"></i></div>
<p class="um-thumb-ph-title">Click to upload</p>
<p class="um-thumb-ph-sub">16:9 recommended</p>
</div>
<div id="edit-{{ $p }}-thumbnail-info" class="um-thumb-info" style="display:none;">
<div class="um-thumb-prev">
<img id="edit-{{ $p }}-thumbnail-preview" src="" alt="">
</div>
<div class="um-thumb-meta">
<span class="um-thumb-name" id="edit-{{ $p }}-thumbnail-fname"></span>
<span class="um-thumb-size" id="edit-{{ $p }}-thumbnail-fsize"></span>
</div>
<button type="button" class="btn-remove-file um-x-btn"
onclick="editRemoveThumbnail(event, '{{ $p }}')">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
</div>
</div>{{-- /video+thumb row --}}
{{-- ── Audio file + Cover Slides ──────────────────────────────── --}}
<div id="edit-tf-{{ $p }}-music-pair"
style="display:{{ $musicDisplay }};gap:12px;align-items:stretch;margin-bottom:14px;">
<div class="um-field-col" id="edit-tf-{{ $p }}-audio-zone"
style="flex:1;min-width:0;margin-bottom:0;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
<i class="bi bi-music-note-beamed"></i> Audio File
<span class="um-lbl-hint">optional replace</span>
</label>
<div id="edit-tf-{{ $p }}-audio-box" class="um-slides-zone"
onclick="@if($embedAudioInput)document.getElementById('edit-{{ $p }}-audio-input').click()@else{{ $videoFileInputId }}.click()@endif"
style="cursor:pointer;flex:1;">
<div class="um-slides-ph" id="edit-{{ $p }}-audio-ph">
<i class="bi bi-music-note-beamed"></i>
<span id="edit-{{ $p }}-fname">Keep existing / choose new</span>
</div>
@if($embedAudioInput)
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('edit-{{ $p }}-fname').textContent=this.files[0]?.name||'Keep existing / choose new…'">
@endif
</div>
</div>
<div class="um-field-col" id="edit-tf-{{ $p }}-slides-wrap"
style="flex:1;min-width:0;margin-bottom:0;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Cover Slides <span class="um-lbl-hint">drag to reorder</span>
</label>
{{-- name="slides_order" only on primary; secondary tracks are managed by JS and must not pollute this field --}}
<input type="hidden" name="{{ $isPrimary ? 'slides_order' : '' }}" id="edit-{{ $p }}-slides-order" value="[]">
<div id="edit-{{ $p }}-slides-zone" class="um-slides-zone"
onclick="editSlidesZoneClick(event,'{{ $p }}')"
ondragover="editSlidesZoneDragover(event,'{{ $p }}')"
ondragleave="editSlidesZoneDragleave('{{ $p }}')"
ondrop="editSlidesZoneDrop(event,'{{ $p }}')">
{{-- No name attr JS uses _editSlidesData state and appends files manually on submit --}}
<input type="file" accept="image/*" multiple
style="display:none" id="edit-{{ $p }}-slides-input"
onchange="editHandleSlides(this.files,'{{ $p }}')">
<div class="um-slides-ph" id="edit-{{ $p }}-slides-ph">
<i class="bi bi-images"></i>
<span>Click or drag to add images</span>
</div>
<div id="edit-{{ $p }}-slides-preview"
style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
<div id="edit-{{ $p }}-slides-strip"
style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span id="edit-{{ $p }}-slides-count"
style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button"
onclick="editClearSlides(event,'{{ $p }}')"
style="background:none;border:none;color:var(--text-secondary);
cursor:pointer;font-size:12px;padding:0;
display:flex;align-items:center;gap:4px;">
<i class="bi bi-x-lg"></i> Clear all
</button>
</div>
</div>
</div>
</div>
</div>{{-- /music pair --}}
</div>{{-- /um-track-form --}}
{{-- ── Log tracker: report field changes for this track editor instance ──────── --}}
@if(!str_contains($languageId, '__TPL__'))
<script>
(function () {
var label = '[EditTrack:' + ({{ $isPrimary ? 'true' : 'false' }} ? 'primary' : @json($prefix)) + ']';
var style = 'color:#a855f7;font-weight:700';
var langEl = document.getElementById(@json($languageId));
var titleEl = document.getElementById(@json($titleId));
var descEl = document.getElementById(@json($descId));
if (langEl) langEl.addEventListener('change', function () { console.log('%c' + label + ' language →', style, langEl.value); });
if (titleEl) titleEl.addEventListener('change', function () { console.log('%c' + label + ' title →', style, titleEl.value); });
if (descEl) descEl.addEventListener('change', function () { console.log('%c' + label + ' description →', style, (descEl.value || '').slice(0, 80)); });
})();
</script>
@endif