takeone-youtube-clone/resources/views/components/phone-code-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

309 lines
12 KiB
PHP

@props([
'name' => '',
'id' => null,
'value' => null,
'label' => null,
'placeholder' => 'Code',
'required' => false,
'class' => '',
'style' => '',
])
@php
use App\Data\Countries;
$options = Countries::forPhoneCode();
$uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8);
$inputId = $id ?? $name;
$selFlag = '';
$selLabel = $placeholder;
$selSub = '';
if ($value) {
foreach ($options as $opt) {
if ($opt['value'] === $value || str_starts_with($opt['value'], $value . '|')) {
$selFlag = $opt['flag'];
$selLabel = $opt['label'];
$selSub = $opt['secondary'];
break;
}
}
}
$isPlaceholder = !$value;
@endphp
{{-- ── Shared CSS (output once across all three CSD components) ──────── --}}
@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; display: flex; align-items: center; }
.csd-ico .fi, .csd-opt-ico .fi { width: 22px; height: 16px; border-radius: 2px; display: inline-block; flex-shrink: 0; }
.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
{{-- ── Shared JS (output once across all three CSD components) ───────── --}}
@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
{{-- ── Component HTML ─────────────────────────────────────────────────── --}}
<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 ?? 'Phone code' }}">
<span class="csd-ico"><span class="fi fi-{{ $selFlag ?: 'xx' }}"></span></span>
<span class="csd-val{{ $isPlaceholder ? ' ph' : '' }}">{{ $selLabel }}</span>
@if(!$isPlaceholder && $selSub)
<span class="csd-sub">{{ $selSub }}</span>
@endif
<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 country or code…" 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 && str_starts_with($opt['value'], $value.'|') ? 'true' : 'false' }}">
<span class="csd-opt-ico"><span class="fi fi-{{ $opt['flag'] }}"></span></span>
<span class="csd-opt-main">{{ $opt['label'] }}</span>
<span class="csd-opt-sub">{{ $opt['secondary'] }}</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>