takeone-youtube-clone/resources/views/components/language-select.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

243 lines
11 KiB
PHP

@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'placeholder' => 'Select language',
'required' => false,
'class' => '',
'style' => '',
])
@php
use App\Data\Languages;
$options = Languages::forLanguage();
$uid = 'lsd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$selFlag = null;
$selLabel = $placeholder;
if ($value) {
foreach ($options as $opt) {
if ($opt['value'] === $value) {
$selFlag = $opt['flag'];
$selLabel = $opt['label'];
break;
}
}
}
$isPlaceholder = !$value;
@endphp
{{-- Shared dropdown styles (same .csd-* rules used by country/timezone/phone selects) --}}
@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
{{-- Language-specific: flag icon sizing --}}
@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
{{-- Shared dropdown JS (same window.CSD class used by country/timezone/phone selects) --}}
@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();
if (this.root.dataset.geoMap && !this.hidden.value) {
this._autoGeo();
}
}
_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(); }
});
}
_autoGeo() {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const map = JSON.parse(this.root.dataset.geoMap);
const iso2 = map[tz];
if (iso2) {
const opt = this.list.querySelector('[data-v="' + iso2 + '"]');
if (opt) this._pick(opt);
}
} catch (_) {}
}
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="csd-wrap {{ $class }}" id="{{ $uid }}"
@if($style) style="{{ $style }}" @endif>
@if($label)
<label class="csd-lbl" for="{{ $inputId }}">
{{ $label }}@if($required)<span class="req">*</span>@endif
</label>
@endif
<button type="button"
class="csd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-label="{{ $label ?? 'Select language' }}">
<span class="csd-ico">
<span class="fi fi-{{ $isPlaceholder ? 'xx' : $selFlag }} lsd-flag"></span>
</span>
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</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($options as $opt)
<li class="csd-opt"
role="option"
tabindex="-1"
data-v="{{ $opt['value'] }}"
data-s="{{ $opt['search'] }}"
aria-selected="{{ $value === $opt['value'] ? 'true' : '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="{{ $name }}"
id="{{ $inputId }}"
value="{{ $value }}"
@if($required) required @endif>
</div>
<script>
(function () {
function boot() { new CSD('{{ $uid }}'); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}());
</script>