diff --git a/.claude/component-usage.md b/.claude/component-usage.md index dcab428..8ae6a14 100644 --- a/.claude/component-usage.md +++ b/.claude/component-usage.md @@ -1,13 +1,13 @@ # Reusable Select Component Usage -This file tracks every page/partial that uses ``, ``, or ``. +This file tracks every page/partial that uses ``, ``, ``, or ``. **Update this file whenever you add or remove a component from a view.** -When modifying any component or its data source (`app/Data/Countries.php`), check all pages in the relevant section below and verify the change works correctly in each context. +When modifying any component or its data source, check all pages in the relevant section below and verify the change works correctly in each context. --- -## Data source +## Data sources **`app/Data/Countries.php`** — `App\Data\Countries` @@ -20,18 +20,30 @@ When modifying any component or its data source (`app/Data/Countries.php`), chec Adding or renaming a field in `Countries::all()` requires updating the corresponding `for*()` method too. +**`app/Data/Languages.php`** — `App\Data\Languages` + +| Method | Used by component | +|---|---| +| `Languages::forLanguage()` | `` | +| `Languages::all()` | Via `forLanguage()` | + +Arabic and English are pinned to the top of the list; all others are sorted alphabetically by English name. Stored value is the ISO 639-1 code (e.g. `"ar"`, `"en"`). + --- ## Shared CSS / JS -The `.csd-*` CSS rules and the `window.CSD` class are duplicated across all three component files inside `@once` guards. If you change the look or behaviour of the dropdown, **update all three component files**: +The `.csd-*` CSS rules and the `window.CSD` class are duplicated across all four component files inside `@once` guards. If you change the look or behaviour of the dropdown, **update all four component files**: - `resources/views/components/phone-code-select.blade.php` - `resources/views/components/country-select.blade.php` - `resources/views/components/timezone-select.blade.php` +- `resources/views/components/language-select.blade.php` The `@once` Blade directive ensures the browser only receives one copy of the CSS/JS even when multiple components are on the same page. +`language-select` also emits an extra `@once('lsd-badge-styles')` block for the `.lsd-code` ISO badge that appears in place of a flag emoji. + --- ## `` @@ -88,7 +100,7 @@ The `@once` Blade directive ensures the browser only receives one copy of the CS | `resources/views/user/channel.blade.php` | `avatar` — circle 300×300 | Owner only; `update-url = profile.updateAvatar`; callback `onAvatarSaved` | | `resources/views/user/channel.blade.php` | `banner` — square 500×160 | Owner only; `update-url = profile.updateBanner`; callback `onBannerSaved` | | `resources/views/layouts/partials/upload-modal.blade.php` | `thumb_upload` — square 448×252 | Form mode; `target-input=thumbnail-modal`; output 1280px | -| `resources/views/layouts/partials/edit-video-modal.blade.php` | `thumb_edit` — square 448×252 | Form mode; `target-input=edit-thumbnail-input`; output 1280px | +| `resources/views/layouts/partials/edit-video-modal.blade.php` | `thumb_edit` — square 448×252 | Form mode; `target-input=edit-t1-thumbnail-input`; output 1280px | | `resources/views/videos/create.blade.php` | `thumb_create_mobile` — square 448×252 | Mobile; `target-input=thumbnail`; output 1280px | | `resources/views/videos/edit.blade.php` | `thumb_edit_mobile` — square 448×252 | Mobile; `target-input=edit-thumbnail`; output 1280px | | `resources/views/playlists/index.blade.php` | `thumb_pl_create` — square 448×252 | Form mode; `target-input=playlist-thumbnail-input`; output 1280px | @@ -140,6 +152,38 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`). --- +## `` + +**File:** `resources/views/components/track-editor-form.blade.php` +**Props:** `prefix` (default `'t1'`), `isPrimary` (bool, default `false`), `languageName`, `languageId`, `titleName`, `titleId`, `descName`, `descId`, `videoFileInputId`. +**Behaviour:** Renders the full track editor form panel shown inside the Track Editor popup. Contains: optional Primary Track banner (when `:is-primary="true"`), language dropdown (``), title input, description textarea, video+thumbnail zone (hidden, shown for video/match type via `_editApplyMode`), and audio+slides zone (hidden, shown for music type). All element IDs are prefixed with `edit-{prefix}-*`. JS functions `editHandleThumbnail(input, prefix)`, `editRemoveThumbnail(event, prefix)`, `editSlidesZoneClick(event, tid)`, `editHandleSlides(files, tid)`, `editClearSlides(event, tid)` all accept the prefix/tid param. + +| View file | Prefix used | Notes | +|---|---|---| +| `resources/views/layouts/partials/edit-video-modal.blade.php` | `t1` | Primary track only; secondary tracks are built via JS (`_editAddExistingTrack`) | + +--- + +## `` + +**File:** `resources/views/components/language-select.blade.php` +**Data source:** `app/Data/Languages.php` — `Languages::forLanguage()` +**Stored value:** ISO 639-1 code (e.g. `"ar"`, `"en"`, `"fr"`). +**Props:** `name`, `id`, `value`, `label`, `placeholder`, `required`, `class`, `style`. +**Icon:** 2-letter uppercase ISO code rendered as a monospace badge (`.lsd-code`) — no flag emoji. +**Arabic and English are always pinned to the top** of the list; all other languages are alphabetical by English name. + +**Rule:** This component must be used for every language picker in the application. Never build a custom ``, inline list, or custom picker: | Need | Component | Stored value | |---|---|---| @@ -134,8 +134,9 @@ Structure: ` @@ -207,7 +208,7 @@ data-v="{{ $opt['value'] }}" data-s="{{ $opt['search'] }}" aria-selected="{{ $value === $opt['value'] ? 'true' : 'false' }}"> - {{ $opt['flag'] }} + {{ $opt['label'] }} @endforeach diff --git a/resources/views/components/language-select.blade.php b/resources/views/components/language-select.blade.php new file mode 100644 index 0000000..4addbee --- /dev/null +++ b/resources/views/components/language-select.blade.php @@ -0,0 +1,242 @@ +@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') + +@endonce + +{{-- Language-specific: flag icon sizing --}} +@once('lsd-flag-styles') + +@endonce + +{{-- Shared dropdown JS (same window.CSD class used by country/timezone/phone selects) --}} +@once('csd-script') + +@endonce + +
+ + @if($label) + + @endif + + + + + + +
+ + diff --git a/resources/views/components/phone-code-select.blade.php b/resources/views/components/phone-code-select.blade.php index 229f328..697351f 100644 --- a/resources/views/components/phone-code-select.blade.php +++ b/resources/views/components/phone-code-select.blade.php @@ -15,7 +15,7 @@ $uid = 'csd_' . ($id ?? $name) . '_' . substr(md5(uniqid()), 0, 8); $inputId = $id ?? $name; - $selFlag = '🌐'; + $selFlag = ''; $selLabel = $placeholder; $selSub = ''; if ($value) { @@ -62,7 +62,8 @@ .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-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; } @@ -71,7 +72,7 @@ .csd-panel { position: fixed; - z-index: 1060; + z-index: 1080; min-width: 220px; background: var(--bg-secondary, #1e1e1e); border: 1px solid var(--border-color, #303030); @@ -225,7 +226,7 @@ _pick(opt) { this.hidden.value = opt.dataset.v; - this.icoEl.textContent = opt.querySelector('.csd-opt-ico').textContent; + 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) { @@ -258,7 +259,7 @@ aria-haspopup="listbox" aria-expanded="false" aria-label="{{ $label ?? 'Phone code' }}"> - {{ $selFlag }} + {{ $selLabel }} @if(!$isPlaceholder && $selSub) {{ $selSub }} @@ -279,7 +280,7 @@ data-v="{{ $opt['value'] }}" data-s="{{ $opt['search'] }}" aria-selected="{{ $value && str_starts_with($opt['value'], $value.'|') ? 'true' : 'false' }}"> - {{ $opt['flag'] }} + {{ $opt['label'] }} {{ $opt['secondary'] }} diff --git a/resources/views/components/playlist-card.blade.php b/resources/views/components/playlist-card.blade.php index 015eb92..a46b463 100644 --- a/resources/views/components/playlist-card.blade.php +++ b/resources/views/components/playlist-card.blade.php @@ -1,5 +1,13 @@ @props(['playlist']) +@php + $pl = $playlist; + $firstVid = $pl->videos->first(); + $plUrl = $firstVid + ? route('videos.show', $firstVid) . '?playlist=' . $pl->share_token + : route('playlists.show', $pl->id); +@endphp + @once @endonce -@php $pl = $playlist; @endphp -
- +
{{ $pl->name }}
@@ -94,7 +100,7 @@

- {{ $pl->name }} + {{ $pl->name }}

@if($pl->user)
  • Download MP3 @@ -292,7 +292,7 @@ Download Video @endif - Download MP3 diff --git a/resources/views/components/video-card.blade.php b/resources/views/components/video-card.blade.php index 4271d0c..4d05b96 100644 --- a/resources/views/components/video-card.blade.php +++ b/resources/views/components/video-card.blade.php @@ -1,6 +1,8 @@ @props(['video' => null, 'size' => 'medium']) @php +use App\Data\Languages; + $videoUrl = $video ? asset('storage/videos/' . $video->filename) : null; $thumbnailUrl = $video && $video->thumbnail ? route('media.thumbnail', $video->thumbnail) @@ -18,6 +20,9 @@ $isShorts = $video && $video->isShorts(); // Check if current user is the owner of the video $isOwner = $video && auth()->check() && auth()->id() == $video->user_id; +// Language flag code (null when no language set) +$langFlag = $video ? Languages::flag($video->language) : null; + // Size classes $sizeClasses = match($size) { 'small' => 'yt-video-card-sm', @@ -70,6 +75,9 @@ $sizeClasses = match($size) {

    + @if($langFlag) + + @endif {{ $video->title ?? 'Untitled Video' }}

    @@ -481,6 +489,19 @@ $sizeClasses = match($size) { .yt-video-card .yt-video-title a { color: inherit; text-decoration: none; + display: flex; + align-items: baseline; + gap: 5px; +} +.vc-lang-flag { + display: inline-block; + width: 16px; + height: 12px; + border-radius: 2px; + flex-shrink: 0; + vertical-align: baseline; + position: relative; + top: 1px; } .yt-video-card .yt-channel-icon { diff --git a/resources/views/components/video-insights.blade.php b/resources/views/components/video-insights.blade.php index 793cb58..67eb5b8 100644 --- a/resources/views/components/video-insights.blade.php +++ b/resources/views/components/video-insights.blade.php @@ -10,8 +10,9 @@ @if($isVideoOwner) {{-- Panel: placed inside .vdb-wrap right after the About panel --}} -
    +
    @@ -185,11 +187,12 @@
    - - - diff --git a/resources/views/layouts/partials/edit-video-modal.blade.php b/resources/views/layouts/partials/edit-video-modal.blade.php index 0dd5416..48ac078 100644 --- a/resources/views/layouts/partials/edit-video-modal.blade.php +++ b/resources/views/layouts/partials/edit-video-modal.blade.php @@ -1,1513 +1,1023 @@ - -
  • + + ${o.label} + ${o.native} +
  • ` + ).join(''); + } + + function ltToggleCreate(trackId) { + const item = document.getElementById('ltac-' + trackId); + const arr = document.getElementById('ltac-' + trackId + '-arr'); + if (!item) return; + const isOpen = item.classList.contains('open'); + if (isOpen) { + item.classList.remove('open'); + if (arr) { arr.style.transform = ''; arr.style.color = ''; } + } else { + item.classList.add('open'); + if (arr) { arr.style.transform = 'rotate(180deg)'; arr.style.color = 'var(--brand-red)'; } + } + } + + function updateLtacHeader(trackId, langCode) { + const lang = LANG_OPTIONS_CREATE.find(o => o.value === langCode); + if (!lang) return; + const flagEl = document.getElementById('ltac-' + trackId + '-flag'); + const nameEl = document.getElementById('ltac-' + trackId + '-name'); + if (flagEl) flagEl.innerHTML = ``; + if (nameEl) { + const prefix = trackId === 't1' ? 'Primary' : 'Track ' + (parseInt(trackId.replace('e', '')) + 1); + nameEl.textContent = prefix + ' — ' + lang.label; + } + } + + function ltRemoveCreate(e, trackId) { + e.preventDefault(); + e.stopPropagation(); + const item = document.getElementById('ltac-' + trackId); + if (item) item.remove(); + delete _cSlidesData['c' + trackId]; + } + + function addLangTrackCreate() { + const n = ++_ltCreateExtraCounter; + const trackNum = n + 1; + const uid = 'ltce_' + n; + + const item = document.createElement('div'); + item.className = 'ltac-item open'; + item.id = 'ltac-e' + n; + + item.innerHTML = ` +
    +
    + ${trackNum} + + + + Track ${trackNum} — Select language +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    + + Click or drag to add cover images +
    + +
    +
    +
    + +
    + + + +
    +
    +
    `; + + document.getElementById('ltac-extra').appendChild(item); + _cSlidesData['ce' + n] = []; + + if (window.CSD) new CSD(uid); + + const hiddenInput = document.getElementById('csd_v_' + uid); + if (hiddenInput) { + hiddenInput.dataset.trackId = 'e' + n; + hiddenInput.addEventListener('change', function() { + updateLtacHeader('e' + n, this.value); + }); + } + } + + // Wire primary language select → update track 1 header + ;(function() { + const plInput = document.getElementById('primary_language_create'); + if (plInput) { + plInput.dataset.trackId = 't1'; + plInput.addEventListener('change', function() { + updateLtacHeader('t1', this.value); + }); + } + + // Delegated fallback: catches any language hidden-input change within the tracks section + const section = document.getElementById('lang-tracks-section-create'); + if (section) { + section.addEventListener('change', function(e) { + const tid = e.target.dataset && e.target.dataset.trackId; + if (tid && e.target.value) updateLtacHeader(tid, e.target.value); + }); + } + }());
    + + {{-- Primary Language (audio only) --}} +
    + +
    + + {{-- Language Tracks Manager (audio only) --}} +
    + + + {{-- Existing tracks --}} +
    + @forelse($video->audioTracks as $track) +
    + + + + +
    + @empty +

    No extra language tracks yet.

    + @endforelse +
    + + {{-- New tracks to add --}} +
    + + +
    @endif {{-- Video Type --}} @@ -372,6 +429,8 @@ if (e.target.closest('.btn-remove')) return; if (typeof window.openCropperModal_thumb_edit_mobile === 'function') { window.openCropperModal_thumb_edit_mobile(); + const internal = document.getElementById('tcInput_thumb_edit_mobile'); + if (internal) internal.click(); } else { editThumbInput.click(); } @@ -536,6 +595,9 @@ @if($video->isAudioOnly()) // Append newly added slide files epSlidesData.filter(s => s.file).forEach(s => formData.append('slides_add[]', s.file, s.file.name)); + + // Append new extra track files (already have name="extra_track_files[]" in DOM) + // delete_track_ids are already wired via enabled hidden inputs @endif fetch('{{ route("videos.update", $video) }}', { @@ -561,6 +623,80 @@ .catch(() => showEditError('Something went wrong. Please try again.')); }); + @if($video->isAudioOnly()) + // ── Language track management ───────────────────────────────────────────── + const EP_LANG_OPTIONS = @json(\App\Data\Languages::forLanguage()); + let _epTrackCounter = 0; + + function _epMarkDelTrack(trackId, btn) { + const hiddenInput = document.getElementById('ep-del-' + trackId); + if (hiddenInput) { + hiddenInput.value = trackId; + hiddenInput.disabled = false; + } + btn.closest('[data-track-id]').remove(); + const existing = document.getElementById('ep-tracks-existing'); + if (existing && existing.querySelectorAll('[data-track-id]').length === 0) { + let msg = document.getElementById('ep-tracks-empty-msg'); + if (!msg) { + msg = document.createElement('p'); + msg.id = 'ep-tracks-empty-msg'; + msg.style.cssText = 'font-size:12px;color:#888;margin:0 0 4px;'; + msg.textContent = 'No extra language tracks yet.'; + existing.appendChild(msg); + } + } + } + + function buildLangSelectHtmlEp(uid) { + const opts = EP_LANG_OPTIONS.map(o => + `
  • + + ${o.label} + ${o.native} +
  • ` + ).join(''); + return `
    + + + +
    `; + } + + function addEpTrackRow(e) { + if (e) e.preventDefault(); + const uid = 'epet_' + (++_epTrackCounter); + const row = document.createElement('div'); + row.style.cssText = 'display:flex;flex-direction:column;gap:8px;background:#151515;border:1px solid #333;border-radius:8px;padding:10px 12px;'; + row.innerHTML = ` +
    +
    ${buildLangSelectHtmlEp(uid)}
    + +
    + + `; + document.getElementById('ep-tracks-new-list').appendChild(row); + if (window.CSD) new CSD('csd_' + uid); + } + @endif + function showEditError(message) { const status = document.getElementById('edit-status'); status.innerHTML = ' ' + message; diff --git a/resources/views/videos/partials/audio-player.blade.php b/resources/views/videos/partials/audio-player.blade.php index 0832216..f520d2d 100644 --- a/resources/views/videos/partials/audio-player.blade.php +++ b/resources/views/videos/partials/audio-player.blade.php @@ -1,11 +1,35 @@ @php - $audioUrl = route('videos.stream', $video); + $audioUrl = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp; $coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png'); $nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null; $prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null; $slideUrls = $video->slides->count() > 1 ? $video->slides->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all() : []; + // Build all-tracks list: primary first, then extra language tracks (skip extras that duplicate primary language) + $primaryLang = $video->language ?? 'default'; + $allLangData = \App\Data\Languages::all(); + $primaryFlag = $allLangData[$primaryLang]['flag'] ?? null; + $allAudioTracks = collect([[ + 'id' => 0, + 'language' => $primaryLang, + 'label' => $video->language ? strtoupper($video->language) : 'Default', + 'flag' => $primaryFlag, + 'stream_url' => $audioUrl, + 'title' => $video->title, + 'description' => $video->description ?? '', + 'dl_url' => route('videos.downloadMp3', $video), + ]])->concat($video->audioTracks->map(fn($t) => [ + 'id' => $t->id, + 'language' => $t->language, + 'label' => $t->label, + 'flag' => $allLangData[$t->language]['flag'] ?? null, + 'stream_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?v=' . $t->updated_at->timestamp, + 'title' => $t->title ?? '', + 'description' => $t->description ?? '', + 'dl_url' => route('videos.audio-track', ['video' => $video, 'track' => $t->id]) . '?download=1&v=' . $t->updated_at->timestamp, + ])); + $hasMultipleTracks = $allAudioTracks->count() > 1; @endphp
    @@ -83,6 +107,34 @@
    + {{-- Language flag — always in DOM; hidden only when no language/flag is set --}} + +
    @endforeach
    - {{-- Loop toggle --}} -
    - - Loop - Off -
    -
    + {{-- Loop — standalone button, outside gear --}} + + {{-- Bars visualiser toggle --}}