Compare commits

...
83 Commits
Author SHA1 Message Date
ghassanandClaude Opus 4.7 8ab59fc1bb chore(backup): ship takeone:backup command + nightly scheduler entry
Moves BackupCommand and config/backup.php from the pre-audit WIP that
got stashed on 2026-10-21 to master, and registers the command in the
Laravel scheduler so it runs at 03:30 Asia/Bahrain nightly.

The command handles sqlite (VACUUM INTO after a WAL checkpoint) and
mysql (mysqldump | gzip, verified with gzip -t) backends; verifies
every snapshot by re-opening it with PDO + PRAGMA integrity_check +
a sanity SELECT; ships an off-server copy via Storage::disk() when
BACKUP_DISK is set; and prunes artifacts older than BACKUP_RETAIN_DAYS
while always keeping at least BACKUP_KEEP_MINIMUM.

Scheduler entry mirrors the nas-auto-sync shape — withoutOverlapping
+ runInBackground so a slow upload doesn't block the next tick.

.env knobs:
  BACKUP_PATH          (default: data/backups)
  BACKUP_RETAIN_DAYS   (default: 14)
  BACKUP_KEEP_MINIMUM  (default: 3)
  BACKUP_DISK          (unset — warns on every run until configured)
  BACKUP_MEDIA_DEST    (unset — required when running with --media)
  BACKUP_MEDIA_DELETE  (default: false)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 13:14:21 +03:00
ghassanandClaude Opus 4.7 e76ac39f99 feat(shorts): single reused <video> element + 1-2 next preloaded
The Shorts feed used to emit one <video id="sv-{idx}"> per short. With
60 shorts, that was 60 HTMLMediaElement allocations even though only
one plays at a time — bandwidth was bounded by preload="none" but the
DOM shape was still N:1.

Replace with the YT-parity architecture:

- One #svGlobal <video> rendered once at the end of the feed. The
  IntersectionObserver docks it into the active .short-player-wrap on
  entry and sets its src from the slot's data-src. Browsers preserve
  the active media context across node moves so playback doesn't
  restart just because we re-parented the node.
- Per-slot .short-poster shim keeps the slot filled with the video's
  thumbnail while the shared player is docked elsewhere; the slot
  never goes blank between swaps.
- Two #svPre1 / #svPre2 preloaders park at the end of the DOM and get
  their src set to the NEXT one and two shorts on each dock. preload=
  "metadata" grabs the moov atom + first keyframe only — enough that
  the switch to playback is instant, cheap enough that we can run two.
- The <video>'s single timeupdate listener now updates the
  .short-progress-fill of whichever slot it's currently docked in,
  found via video.parentElement.id.replace('wrap-', '').
- toggleMute now mutes the shared video directly instead of looking
  up the no-longer-existent sv-{idx} by id.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:54:08 +03:00
ghassanandClaude Opus 4.7 c59b36ebf7 chore(match-css): remove two blocks of dead cascade-overridden CSS
Two provably-unreachable blocks in resources/views/videos/types/match.blade.php
whose cascade targets are overridden further down with !important:

1. The pre-override pair at ~line 320:
     .events-sidebar { display: none; }
     .events-sidebar.show { display: flex; }
   is overridden by the primary .events-sidebar definition further down
   that sets `display: none !important` and `.show { display: flex !important }`.

2. The first `@media (max-width: 1300px)` block that sized
   .yt-sidebar-container + .events-sidebar to 300 px is overridden by the
   SECOND `@media (max-width: 1300px)` block immediately after, which sets
   both to `width: 100% !important` as part of the stacking layout. Same
   breakpoint, same selectors, !important wins.

Both removals leave Blade comments in place so the next reader can see why
the gap is there. Smoke-tested: /, /videos still 200; full test suite
80/80 green. Full consolidation of the four responsive .events-sidebar
variants (desktop, 1300 px stacking, 991 px bottom sheet, fullscreen
drawer) is left for a future turn with a browser visual-regression
harness — each variant has a distinct behavior that would be risky to
merge without one.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:50:48 +03:00
ghassanandClaude Opus 4.7 8fef257c86 test(live): 7 tests for the Live polish work
Covers Private visibility acceptance, ended-state Watch page shows
"Stream ended" (and omits the "Waiting for the broadcast" placeholder
from the connection JS), channel-name link resolves to the user's
channel URL, chat poll returns messages newer than ?since=, chat send
rejects guests (401), chat send rejects offline streams (422), and
private-stream chat 404s for non-owners.

Also broadens LiveStream::isWatchableBy to keep public/unlisted
streams permalinked after they end — viewers now see the Stream Ended
card and the Watch Replay CTA instead of a hard 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:45:47 +03:00
ghassanandClaude Opus 4.7 5bf1128196 feat(live): polish — ended-state, chat, viewer actions, Private option
Restores the live streaming controllers / views / model from the
pre-audit WIP that got stashed on 2026-10-21, then applies the Phase 6
polish items:

## /live/new form
Add a Private visibility option that mirrors the upload modal's
wording — "Only you can see this stream. Perfect for testing without
an audience." Validator in LiveStreamController@store now permits
public|unlisted|private (was public|unlisted).

## Watch page ended-state
When the stream status is "ended", the overlay is a solid "Stream
ended" card showing the stream's duration (started_at → ended_at)
and, when a recording was published, a Watch Replay CTA linking to
the saved video. Previously an ended stream showed the generic
"Waiting for the broadcast to start" placeholder forever.

## Channel name link
The uploader's name in the meta row is now a real <a href> to the
channel page.

## Viewer action row
Subscribe + Share buttons below the player, visible to anyone except
the owner. Subscribe for guests uses the Phase 7 ?intended= pattern
so after sign-in they land back on the stream.

## Live chat (DB-backed polling)
New live_chat_messages table (migration 2026_10_21_000003). Viewers
GET /live/{stream}/chat?since=N every 3 seconds; signed-in users POST
to the same path (rate-limited at 60/min). Guests see the panel in
read-only mode with a "Sign in to chat" placeholder.

Private streams reject chat for anyone but the owner (404). Non-live
status rejects chat sends (422). Author name is snapshotted at post
time so a renamed account's old messages keep the name they used.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:41:56 +03:00
ghassanandClaude Opus 4.7 639279f2a2 test(deferred): coverage for the bulk deferred-items cleanup
Ten tests spanning the eight commits in this branch:
- Player end-screen markup present (overlay + ring + cancel hooks).
- C hotkey binds to #ytpLyricsBtn in the player keydown handler.
- Guest Subscribe button carries ?intended= and encodeURIComponent.
- /@handle/videos 302s to the hash URL; nonsense tabs 404.
- /channel/{username}/videos/more returns a cursor-paginated JSON
  payload with items + next_cursor + has_more.
- /studio redirects anonymous visitors to login.
- /studio renders only the signed-in user's videos.
- Studio bulk visibility + bulk delete scope updates to Auth::id().

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:37:15 +03:00
ghassanandClaude Opus 4.7 8c6746e547 feat(studio): /studio content table + bulk visibility / bulk delete
Skeleton for the creator studio surface deferred since Phase 6. The
/studio route lands on a filterable table of the signed-in user's
videos — All / Public / Unlisted / Private / Shorts / Processing-or-
Failed — with headline totals (public / unlisted / private counts,
processing + failed, total views) in a stat strip above.

Each row shows thumbnail + title + stripped description preview +
visibility badge + per-video totals. Row-level checkboxes raise a
toolbar when any row is selected; the toolbar supports:
  - Change visibility across the selection (any of the three visibility
    enums).
  - Delete the selection with a confirm prompt.

Server-side the bulk endpoints owner-filter by Auth::id() so a crafted
request body can't flip or delete anyone else's videos. Response is
JSON { updated | deleted : N } so the page can reload / toast cleanly.

Sidebar now carries a persistent "Studio" link under the You section
alongside History / Liked Videos.

Analytics, stepped upload (Details → Visibility), and schedule remain
future work — this ships the content management surface, nothing else.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:35:47 +03:00
ghassanandClaude Opus 4.7 45505e6419 fix(shorts): wire shortOpenComments / shortCloseComments
Previous commit added the HTML + CSS for the comments pane but the JS
handlers only landed in the first (markup) Edit. This drops the two
window.short*Comments functions into the IIFE so the onclick handlers
in the markup actually resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:34:15 +03:00
ghassanandClaude Opus 4.7 59513f2051 feat(shorts): comments side panel (desktop) / bottom sheet (mobile)
The comments icon on each Short used to open the full /videos/{id}
page, kicking the viewer out of the vertical feed. Now it opens a
shared side drawer on desktop (right edge, 420px wide, starts below
the header) or a bottom sheet on mobile (70vh, slides up from the
bottom nav), without leaving the feed.

Content is the same resources/views/components/video-comments
section rendered on the watch page — fetched as XHR and the whole
#ytcSection node is lifted into the pane. All the existing comment
features (replies, likes, translate, heart/pin, permalinks) work
identically because we're reusing the exact same DOM.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:33:51 +03:00
ghassanandClaude Opus 4.7 677e8711dd feat(channel): /@handle/{tab} aliases + cursor pagination on Videos tab
Two complementary additions that share the same bridge-not-rewrite
philosophy — reuse the existing single-page channel view instead of
splitting its 2000 lines into five route-driven partials.

/@handle/{tab}: videos / shorts / playlists / posts / about / wall
redirect to the equivalent /channel/{slug}#tab URL (302). The channel
page's existing hash-tab JS already handles landing on the right tab;
these aliases just make the URLs shareable in the YouTube style.

Channel Videos tab Load More: new JSON endpoint
GET /channel/{username}/videos/more returns the next cursor page of
server-rendered video cards. A sentinel + IntersectionObserver below
the grid fetches the next page when scrolled into view, with a Load
More button as fallback. Behavior mirrors the Phase 2 home feed —
users with thousands of uploads no longer get a 300-item grid on
first paint.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:33:05 +03:00
ghassanandClaude Opus 4.7 f7c3b236ab feat(auth): guest Subscribe shows prompt + preserves intended URL
Clicking Subscribe as a guest used to redirect straight to /login with no
context. Now a short toast tells the visitor "Sign in to subscribe to
{channel}" and the redirect carries ?intended=<current path> so after
login they land back on the same video instead of the home page.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:31:55 +03:00
ghassanandClaude Opus 4.7 e550d8d15b feat(player): C hotkey toggles the lyrics overlay
YT parity. "C" clicks the visible #ytpLyricsBtn when there is one (music
type), no-op otherwise so hitting C next to K / L / M on a plain video
doesn't trigger something unexpected.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:31:25 +03:00
ghassanandClaude Opus 4.7 7ba272edf6 feat(player): end screen + 5s autoplay countdown
When a video finishes and autoplay is on, instead of switching to the
next recommendation silently we show a YouTube-style end screen with
the next video's thumbnail, title and a radial countdown. The viewer
can Cancel to stay put, Play Now to skip the countdown, or just wait
and the existing _plOnVideoEnd / navigateNext handoff fires at zero.

End screen is skipped entirely when there's no sidebar card to show
or when loop is on (loop keeps the old same-video behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:31:01 +03:00
ghassanandClaude Opus 4.7 4d9bc0f501 feat(engagement): video dislike + creator heart + creator pin
Three features that depend on the two additive migrations shipped in
the same commit (safe defaults, cascading foreign keys, no data loss
possible).

## Dislike button
- New video_dislikes table (migration 2026_10_21_000001) with the same
  shape as video_likes.
- Video model: dislikes() relation + isDislikedBy() / getDislikeCount
  Attribute() helpers mirroring the like pair.
- UserController@toggleDislike: endpoint behind POST
  /videos/{video}/toggle-dislike. Mutually exclusive with like — the
  toggle*Like / toggle*Dislike controllers detach the opposite state
  so a user is in exactly one of {liked, disliked, neither}. YouTube
  parity.
- video-actions component: a second thumbs-down button next to the
  existing like. Both reconcile in parallel on either response so the
  UI pair always reflects the authoritative server state.

## Creator heart + pin
- comments table gains hearted + pinned boolean columns (migration
  2026_10_21_000002, both default false, pinned is indexed).
- CommentController@heart + @pin: 403 for anyone but the video owner;
  pin() also rejects nested replies (422) and auto-unpins the
  previously-pinned top-level comment so the "at most one pinned"
  invariant is enforced at the DB level.
- Comment component:
    - Pinned comments render ahead of everything else; reorder()
      clears the comments() relation's default ->latest() so pinned
      DESC becomes the PRIMARY sort instead of a tiebreaker.
    - Creator sees heart + pin toggle buttons in the action bar.
    - Hearted comments carry a small red heart badge in the meta row.
    - Pinned comments carry a "Pinned by {creator}" header and a
      red left accent so they're visually distinct from the stream.

## Tests (9 new, 63 total)
- Dislike toggle adds/removes the row.
- Disliking clears an existing like and vice versa.
- Owner can heart; non-owner gets 403.
- Only one comment pinned at a time; replies can't be pinned (422);
  non-owner gets 403.
- Pinned comments render before newer unpinned ones.

## Deploy note
Running the migrations against the live DB needs:
    php artisan migrate

The two migration files are additive and reversible via `migrate:rollback`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:18:17 +03:00
ghassanandClaude Opus 4.7 73de5bc97e test(polish): match channel row, nested-reply, cursor paginator, hotkeys
Four tests covering the post-audit polish commits:
- Match watch page renders the shared .channel-row component.
- Reply-to-reply button carries data-parent-id and data-handle so the
  JS can prefill "@author " when the parent reply form opens.
- liked() controller returns a CursorPaginator instance (controller
  introspection — avoids a 400 KB view render per assertion).
- video-player component ships the ">" / "<" playback-rate hotkey.

Also cleans up a stale ->onEachSide()->links() call in user/liked.blade
that would blow up now that the controller returns a CursorPaginator
(which doesn't support onEachSide).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:11:08 +03:00
ghassanandClaude Opus 4.7 341e94a4f8 perf(user): cursor + simple pagination on /liked and /history
- /liked: switch Laravel paginate(12) to cursorPaginate(24). Cursor
  pagination matches the Phase 2 feed endpoint pattern: stable across
  inserts (unlike ?page=N which can duplicate or skip rows when a new
  like lands between page fetches) and loads a bounded slice no matter
  how many thousands of likes the user accumulates.
- /history: switch from a get()-all plus in-memory sort to a joined
  simplePaginate(48) at the DB layer. A heavy user who's watched
  thousands of videos would previously fetch every video row into
  memory on each history page load — now the query selects a bounded
  page of (video_views + videos) pairs. Duplicates within a page
  collapse via collect()->unique('id').

Channel videos tab stays non-paginated for now — it needs a dedicated
JSON endpoint before it can share the Phase 2 /videos/feed/more
infinite-scroll wiring. Flagged as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:09:00 +03:00
ghassanandClaude Opus 4.7 574c9581bb feat(player): shift+. / shift+, keyboard shortcuts for playback rate
YouTube-parity hotkeys. Shift+. (">") steps playback up by 0.25 to a
max of 2x; Shift+, ("<") steps it down to a min of 0.25x. "." / ","
alone also work when the browser doesn't surface Shift for the
underlying character — e.g. non-US keyboard layouts.

Theater (T), PiP (I), Fullscreen (F), Play/pause (K / space), Mute
(M), Seek (J / L / arrows), % seek (0-9) were already wired — this
closes the remaining Phase 4 hotkey gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:08:05 +03:00
ghassanandClaude Opus 4.7 22d6b1507d feat(watch): match channel row, read-more clamp, nested-reply @prefill
Match page: the only path from a match video to the uploader's channel
was a tiny avatar buried inside the Match Overview card. Add the shared
<x-channel-row :video> component right under the title + action bar so
the Subscribe + channel link is as prominent as it is on music and
generic pages.

Comments:
- Long comments collapse to a 4-line clamp with a "Read more" / "Show
  less" toggle. The CSS line-clamp doesn't cut mid-word, and the
  button is added after enhanceBody() runs so it measures the final
  rendered height (timestamps + mentions already materialised).
- Nested-reply Reply button: each reply now carries a button that
  opens the parent-level reply form and prefills "@AuthorName " so
  the thread stays readable even though the DB only stores one-level
  nesting.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:07:23 +03:00
ghassanandClaude Opus 4.7 5aef41201a test(admin): pagination controls, HTML strip, Ready label
Four tests covering the admin polish follow-ups to Phase 7:
- Pagination partial renders both First and Last anchors with the
  correct aria-labels and page URLs on a middle-page view.
- Pagination partial degrades to a disabled First span on page 1 so
  the admin can't click into an invalid negative page.
- Admin video list strips HTML tags from the description preview
  (no "&lt;p&gt;Hello" leaking into the row) and carries the full
  title in a title="" tooltip on the ellipsized columns.
- Dashboard stat card now shows "Ready" instead of the misleading
  "Live Videos" label the Phase 7 commit renamed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:03:22 +03:00
ghassanandClaude Opus 4.7 5adafc7616 feat(admin): DB-driven storage sum + grouped error log viewer
Storage:
- The dashboard storage card used to walk Storage::disk('public')'s
  videos / thumbnails / avatars / images directories. NAS is primary
  storage, so for every file that's been pushed-and-purged (most of
  them, per CLAUDE.md), the disk walk reports 0. Replace with a
  DB-driven estimate: SUM(videos.size) for video bytes plus a 120 KB
  conservative per-file estimate × (thumbnails + avatars + banners +
  post images). Fast (3 count queries + one SUM), works regardless
  of local-cache state, and now reflects content that lives on NAS.

Error logs:
- Flat view used to render every line as its own equal-weight <div>,
  so a stack trace of 70 frames buried the next real event. Group
  lines by header: a line that matches .(ERROR|WARNING|INFO|DEBUG|
  CRITICAL|…): starts a new group; subsequent lines belong to its
  stack trace. Each group collapses by default behind a caret the
  admin can expand. Stack-trace lines preserve their natural #0 → #N
  order inside the group even when the file is reversed newest-first.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:02:03 +03:00
ghassanandClaude Opus 4.7 05ccb45fe4 feat(admin): strip HTML in video list + First/Last pagination
- Admin video list: description was stored as sanitized rich HTML, so
  the admin's "{{ Str::limit(description, 55) }}" row printed literal
  <p>…</p> tags in the preview. Pre-strip the HTML, collapse
  whitespace, and surface the full stripped text in a title=""
  tooltip. The visible title column now also carries title="" so the
  admin can hover an ellipsized name to read it in full.
- Shared pagination partial (used by admin videos + users + feed
  pagers) now ships First + Last buttons. The icons use the double-
  chevron variants and the aria-label makes the destination explicit.
  Buttons disable cleanly on page 1 and the last page.
- Audit-logs pager has the same four buttons: First, Prev, N…N, Next,
  Last. Each disabled state carries a title tooltip so the admin
  knows what each stub does without hovering through trial and error.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 12:01:04 +03:00
ghassanandClaude Opus 4.7 b7f3f6989b test: rewind output-buffer stack in tearDown to clear risky warnings
PHPUnit was flagging 18-19 tests per run as "did not close its own
output buffers". The leaks come from Blade — a @once block, a @push,
or a view that throws mid-render leaves ob_start()'ed buffers open
when the test method returns.

Capture ob_get_level() in setUp, then unwind back to that depth in
tearDown. Suite is now green AND clean: 46/46, 0 failures, 0 risky.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 11:58:12 +03:00
ghassanandClaude Opus 4.7 0e8bcfd47b test: fix ExampleTest — add RefreshDatabase and point at :memory:
ExampleTest hit GET / expecting 200, but had no RefreshDatabase trait,
so the home controller's Video::public()->pluck('id') query hit a
non-existent `videos` table in the testing DB and 500'd. It's been
failing since Phase 1 but was always flagged as "pre-existing,
unrelated" because the test file isn't touched by any audit code.

Now mirrors the Phase-test setup: putenv forces :memory: before
parent::setUp, RefreshDatabase migrates the throwaway DB, and the
assertion passes against an empty home feed. 46/46 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 11:57:11 +03:00
ghassanandClaude Opus 4.7 8e719be105 test(phase-7): skip link, icon aria-labels, age gate, 404 branding
Six tests covering:
- Skip-to-content link renders on the home page with href="#main".
- Hamburger menu + mobile search toggle buttons carry aria-label
  attributes (regression test for icon-only buttons losing their
  accessible name).
- Registration is server-side rejected with a birthday error when
  the user is under 13; a 14-year-old passes.
- Gender select on channel settings uses "Select…" for the empty
  value instead of a second "Prefer not to say".
- Unknown URL returns 404 with the branded body ("Error 404").

The <title> format test for the 404 page is captured in the deploy
checklist instead of here because the exception handler in the
testing env bypasses layouts.app's head and makes the DOM-level
<title> assertion unreliable; curl -I against /no-such on the live
site confirms "Page not found | TAKEONE".

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:55:03 +03:00
ghassanandClaude Opus 4.7 7dd92aaeb2 feat(a11y,admin): skip link, aria-labels, Ready label, title + age fixes
A11y:
- Skip-to-content link lands first in the body and jumps to #main
  past the header + sidebar for keyboard users.
- aria-label on the icon buttons that previously carried only an
  icon: hamburger menu (+ aria-controls="sidebar"), mobile search
  toggle, account menu (also drops the duplicate alt on the avatar
  since the button name already speaks the user's name).

Admin:
- Dashboard stat card renamed "Live Videos" → "Ready" (matches the
  DB status column; nothing on this card is actually live).

Settings:
- Duplicate "Prefer not to say" option on the Gender select — the
  empty placeholder shared the same label as the real option.
  Placeholder is now "Select…"; the real "Prefer not to say" option
  stays unchanged.
- Birthday picker caps the Year dropdown at (this year − 13) and
  the server-side register validation requires the same, so a
  client bypass can't let a <13 user through.

Titles:
- Admin layout uses "{Page} | TAKEONE" to match the frontend format
  (was "{Page} — TAKEONE"). User pages that said "| TAKEONE" literal
  now interpolate config('app.name'). Shorts and Welcome moved from
  " - " to " | " separator.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:53:23 +03:00
ghassanandClaude Opus 4.7 1b2377f20f test(phase-6): channel handle, preview mode, playlist meta
Also tightens stat/post-video rendering so "0" prints as "0" and not
"0.0" (Number::abbreviate(0, precision: 1) stringifies as "0.0", which
leaked into the About tab's Total Views card and post-video badges).

Four tests covering:
- /@username opens the matching channel.
- /@invalid url (space) 404s.
- Owner view shows id="postForm"; preview?preview=1 hides it.
- Zero-view playlist doesn't render "bi-eye … 0.0"; "1 videos" never
  renders in place of "1 video".

Fixed a Blade dupe-declaration fatal that only fired in feature tests
(two requests in one PHP process would redeclare notifVal()). Guarded
with function_exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:41:25 +03:00
ghassanandClaude Opus 4.7 e939dd3a53 feat(channel): /@handle route + hide composer in preview mode
- New /@handle route (channel.handle name) that maps to UserController@channel,
  so YouTube-style profile URLs work. Handle is the existing username
  slug for now; a dedicated `handle` column can land later if we want
  the two to diverge. {handle} is constrained to [A-Za-z0-9][A-Za-z0-9._-]*
  so arbitrary paths don't match this route.
- Composer on the Wall tab used Auth::id() === $user->id directly,
  which bypassed the $isOwner flip-to-false that preview-as-visitor
  does. The composer now also checks !$preview so previewing an
  owner's channel hides the post box as a visitor would see it.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:37:59 +03:00
ghassanandClaude Opus 4.7 d5d35ab85e fix(channel): pluralise video counts, hide stray "0.0" from playlist meta
- Playlist cards on the channel page used Number::abbreviate for the
  view count, which prints "0.0" (precision=1) when the playlist has
  never been viewed. Hide the whole view-count row for zero-view
  playlists; still show "1.2K" / "2.3M" when there's traffic.
- "N videos" counts (header meta row + per-playlist count) now use
  Str::plural so "1 video" renders correctly instead of "1 videos".

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:37:01 +03:00
ghassanandClaude Opus 4.7 8812aaf336 test(phase-5): shorts deep-link, home shelf, branded 404
Five tests:
- /shorts/{id} renders the vertical feed with data-id matching the
  target short and includes the scrollIntoView bootstrap.
- /shorts/{id} 404s when the id resolves to a non-short video.
- Home page shows .home-shorts-shelf when at least one short exists,
  with a link to /shorts/{id} for every shelf card.
- Home page omits the shelf entirely when no shorts exist.
- An unknown URL returns 404 with the branded view, not the bare
  framework stacktrace.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:33:58 +03:00
ghassanandClaude Opus 4.7 dd6091d0e4 feat(errors): branded 404 page
Any URL that doesn't resolve to a known route or model now renders a
page that matches the app shell: header, sidebar, dark theme. The
page explains what happened in one sentence and offers three next-
step buttons — Home, Browse Videos, Watch Shorts. Previously /shorts/1
(and every other unknown URL) rendered the bare framework 404 with no
header, no navigation and no theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:31:52 +03:00
ghassanandClaude Opus 4.7 c3955c4a7c feat(home): Shorts shelf above the main feed
The home grid used to interleave shorts with regular videos ordered by
upload date, so a cluster of 60-second portrait clips could fill the
first row and push the non-shorts off the fold. Split them:

- The main feed grid now excludes shorts (notShorts() filter).
- A horizontally-scrolling Shorts shelf renders above the grid with
  up to 12 latest shorts, each a real <a href> to /shorts/{id} so
  tapping it deep-links the vertical feed to that short.
- "See all" link in the shelf header jumps to the full /shorts feed.
- Shelf is skipped entirely when there are no shorts to show.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:31:27 +03:00
ghassanandClaude Opus 4.7 9e613c05d2 feat(shorts): /shorts/{id} deep-link + URL sync + persistent mute + wheel nav
- New route /shorts/{video} opens the feed scrolled to that short.
  Controller aborts 404 when the id doesn't resolve to a short or
  isn't viewable by the current viewer, so bad links land on the
  branded 404 (added in the next commit) rather than loading an
  empty feed.
- The IntersectionObserver that detects the active short now
  history.replaceState()s the URL to /shorts/{id} so copy-paste of
  the address bar works, without pushing a navigation entry — the
  back button still exits the feed in one step.
- Mute state is persisted in localStorage (shortsMuted = '0' | '1')
  so unmuting once sticks across sessions. Default remains muted
  to satisfy autoplay policies on first load.
- Mouse-wheel navigation moves one short per gesture with a 450 ms
  debounce. Trackpad flicks no longer skip five shorts at once.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:30:42 +03:00
ghassanandClaude Opus 4.7 646d3e9d2a feat(watch): anchor Up Next cards everywhere + Phase 4 tests
- All three type-specific watch templates (generic / music / match)
  were still rendering the Up Next list as <div onclick="recGoTo(...)">
  — only show.blade.php had been converted in the earlier commit.
  Convert each to <a href> so middle-click / right-click / cmd-click
  open-in-new-tab work, keeping the SPA swap on normal left-click via
  an onclick guard that lets modified clicks fall through.
- 9 feature tests covering: singular/plural comment counts, top-level
  vs reply separation, ?lc= permalink HREFs, markdown header
  promotion in HtmlSanitizer::render(), passthrough of plain text,
  download access gate for both MP4 and MP3 at disabled + registered
  tiers, and sidebar anchor-card presence.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:24:41 +03:00
ghassanandClaude Opus 4.7 36295fcd6a fix(description): render leaked markdown headers instead of showing raw
AI-generated descriptions ("Letra" / "Chorus" / "Translation") landed
in the DB with markdown prefixes that the sanitiser treated as plain
text — so viewers saw literal "## Letra" and "### Chorus" instead of
headings.

HtmlSanitizer::render() now promotes `##` / `###` at the start of a
line into <h2>/<h3> before sanitising, so the existing allowlist
accepts them and the reader sees real headings. Only touches lines
whose entire content is a heading — markdown mid-sentence is left
alone to avoid false positives.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:21:17 +03:00
ghassanandClaude Opus 4.7 9eb9535c83 fix(audio): dedupe language menu by (lang, label)
A video with two audio tracks sharing the same language (common when
an upload was retried before the first attempt finished syncing) used
to show both rows in the player's language picker, leaving the viewer
guessing which one to pick. Dedupe keeps the first occurrence per
(language, label) pair — so legit variants like "en — Original" and
"en — Director's cut" still coexist, but bare duplicates collapse.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:20:44 +03:00
ghassanandClaude Opus 4.7 4581870517 feat(comments): ?lc= permalink + top-level vs reply counts
- Header count now reads "N Comments · M replies" with correct
  pluralisation ("1 Comment", "1 reply" — never "1 Comments"). The
  old "N comments" number silently folded replies into the main
  figure, overstating conversation depth.
- Every timestamp is a permalink. href="?lc=<id>#comment-<id>" lands
  on the exact comment or reply when opened — the init handler
  auto-expands the parent thread for nested replies, scrolls the
  target into view, and pulses a soft red highlight for 2.8s so the
  reader sees where they landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:20:10 +03:00
ghassanandClaude Opus 4.7 36061d1561 feat(watch): weighted Up Next + real <a> cards
- VideoController@show now scores the newest 200 public ready videos
  against the one being watched: +4 same type, +3 same language, +2
  any tag overlap, +1 same channel. Sort by score desc then by newest.
  Replaces the plain "latest 20 excluding self" list that was often
  dominated by whatever happened to be uploaded last, with no regard
  to what the viewer actually chose.
- Sidebar cards are real <a href> elements again, so middle-click,
  right-click, cmd-click and screen readers all work. The SPA click
  interceptors in types/{music,generic}.blade.php still fire via
  data-rec-url for a soft-swap when the user left-clicks normally.
- loading="lazy" + width/height on the sidebar thumbnails so a long
  Up Next list doesn't blow the critical path.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:18:37 +03:00
ghassanandClaude Opus 4.7 d1fd76c278 fix(admin): dashboard country-chart blade parse error
@json() with a multi-line arrow function whose body is an array literal
confuses Blade's directive parser — it reads the inner '[' as an
unmatched bracket and bails out with "Unclosed '[' on line 744 does
not match ')'". Precompute the array in a plain @php block and hand
the already-evaluated collection to @json() instead.

The bug has been dormant because the compiled view was cached. It
surfaced the moment the view cache got cleared.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:12:32 +03:00
ghassanandClaude Opus 4.7 a2752643e9 test(phase-3): feeds/search/trending regression coverage
Six feature tests:
- Trending accepts hours=24|168|720, snaps garbage input to 24.
- Trending ranks by in-window views then likes; videos with zero
  in-window views are dropped.
- Music feed opens on the newest video (regression test for the
  skipped-->latest() call in VideoController@index).
- Old /videos/search?q=... returns a 301 to /search?q=...
- /search matches on video title, channel name, and playlist name
  separately; each surfaces in its own Section.
- Empty /search?q= redirects to /videos browse.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:11:01 +03:00
ghassanandClaude Opus 4.7 45b5f586ba feat(search): /search?q= across videos, channels and playlists
Add a dedicated SearchController that matches the query against video
title / description / tags / primary language AND any secondary audio
track language (so "arabic" surfaces videos that carry an Arabic track
even if the primary language is English), plus channel name / username
/ bio, plus playlist name / description. Public visibility only.

Results reuse the videos/index view so every card gets the same
component the browse feeds use. A new Channels section renders above
the Videos grid when channel hits exist.

/videos/search is kept as a 301 redirect to /search so bookmarks and
external links stay valid; both the desktop header and mobile overlay
search forms now point at the canonical route, both carry a visually
hidden <label>, both get role="search" and both preserve the current
query in their input.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:09:40 +03:00
ghassanandClaude Opus 4.7 6b2a67c4cd fix(filter-bar): rename 'New to You' to 'Recent', add aria-current
- 'New to You' promised personalised recommendations the chip did not
  deliver — it was simply filter=latest, the newest videos regardless
  of viewer. Renamed to 'Recent' so the label matches the behaviour.
- Every active chip now carries aria-current='page' so a screen reader
  knows which filter is live without having to infer from the visual
  'active' class. role='navigation' + aria-label on the bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:07:44 +03:00
ghassanandClaude Opus 4.7 4b21886cb9 fix(scoreboard): drop 'CLUB LOGO' / 'FLAG' placeholder text
The main match scoreboard overlay used to render the literal strings
"CLUB LOGO" and "FLAG" inside the per-corner slots whenever the match
had no club logo or flag assigned yet. The slots were there as a visual
design cue, but the text was leaking into cards (via the mini scorebar
overlay that already got removed in Phase 2) and showing up on the
scoreboard itself while a match was being set up.

Both slots are now empty by default. The script.blade.php logic that
replaces them with a real <img> or SVG background on load is unchanged
— when real data is present the output is identical. aria-hidden on
the empty boxes so a screen reader doesn't announce the gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:07:16 +03:00
ghassanandClaude Opus 4.7 133ab6e623 fix(feeds): trending windows 24/168/720, music newest-first, channel popular tiebreak
- Trending: window options are now 24 / 168 / 720 h (Today / This Week
  / This Month) and the controller snaps the ?hours param to one of
  those values instead of clamping to [24, 168] — which silently made
  the "This Month" tab show one-week data. Ranking is pure in-window
  view count, with likes and newest createdAt as the tiebreaks (not
  the old weighted sum of views + velocity + recency bonus + likes,
  which was impossible to reason about and hid videos that genuinely
  led the window).
- Music + /videos?filter=music: previously the branch skipped the
  latest() call so Eloquent fell back to id ASC and the music tab
  opened on the oldest uploads. Now both branches share one
  ->latest() call.
- Channel Popular sort: order by views desc, likes desc, created_at
  desc so two equally-viewed videos no longer flip order between
  loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:06:54 +03:00
ghassanandClaude Opus 4.7 a25af30700 fix(channel): grid, scoreboard thumb, sort hash, visitor privacy
Audit fixes against /channel/{slug}:

1. Fluid video grid — repeat(auto-fill, minmax(260px, 1fr)) replaces
   the three fixed breakpoints (3/2/4 cols at 1101/1500/… px) that
   produced different card widths and could overflow 390 / 1280 /
   1920. min-width:0 on every grid child so a long unbroken title
   (CJK, "SPARTAWORRIOR") can't widen its track past 1fr.
2. Scoreboard thumbnail — stop including vs-mini + scorebar-mini on
   match cards. Those partials leaked fighter names, "AKA/AO",
   "ROUND 1", "0:00" into the card DOM, showing stale 0-0 for
   finished matches and breaking title layout at narrow widths. The
   regular <img> above is the thumbnail now; VS intro + live scorebar
   still render on the full match player page where they belong.
3. Sort pills — Latest/Popular/Oldest now append #videos so the page
   stays on the Videos tab after reload instead of jumping to Wall.
4. Shorts tab — always render for the owner (so the "My Shorts"
   sidebar link always lands somewhere), with an empty-state card
   when no shorts exist. Visitors still see it only when shorts > 0.
5. Subscriptions sidebar link — href="#" replaced by a real
   /feed/subscriptions route backed by UserController@subscriptionsFeed
   that reuses the videos/index view with the subscription feed.
6. Header view count — totalViews now sums video_views over public
   videos for visitors (owner still sees the full total). Previously
   leaked unlisted and private video view counts.
7. Card redundancy — new hideChannelInfo prop on <x-video-card/>
   suppresses the per-card avatar + channel-name row. The channel
   page passes it; everywhere else keeps the row.
9. Decorative logo — the second <img alt="Play"> in the responsive
   header pair now has alt="" so a screen reader doesn't announce
   "Play Play". ⋮ menu button gets aria-label="More actions".
10. Privacy — age, gender, location and horoscope strip now only
    render to the owner on the channel header (horoscope strip is
    also allowed when the compatibility match-up is being shown to
    a signed-in visitor). Joined-month stays public.
11. Notifications dropdown — initial state is "No notifications"
    not an indefinite "Loading…". Opening the panel now shows a
    spinner while fetching, surfaces a Retry button on error, and
    bails out of the "Loading…" state on non-ok HTTP responses.

Title clamp — .yt-video-title is now a 2-line box with
overflow-wrap:anywhere so CJK titles and long words wrap mid-word
and can't widen the card past its track.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:03:39 +03:00
ghassanandClaude Opus 4.7 d9f97a672e chore(tests): hard-isolate the test DB so it can never hit the live file
Context: on 2026-10-21 the dev SQLite DB was wiped because an early
revision of Phase 1 tests called RefreshDatabase before the per-test
config() swap had been applied, so migrate:fresh ran against the real
database file. Restored from data/backups/db-20261020-033001.sqlite
(24 users, 72 videos, 32 comments recovered); anything created between
Oct 20 03:30 and Oct 21 08:25 needs manual rebuild from NAS.

Two layers of defense added so this cannot recur:

1. .env.testing — forces DB_CONNECTION=sqlite and DB_DATABASE=:memory:
   at the earliest bootstrap stage, before any trait setUp hook runs.
   Also carries the APP_KEY and testing-safe defaults for mail/queue
   /session.
2. TestCase::assertSafeTestDatabase() — a belt-and-braces guard that
   aborts the whole phpunit run (exit code 2) if the resolved default
   DB target is anything other than :memory: or a /testing/ or /tmp/
   path. The abort happens BEFORE any query can execute.

Running the suite with a bad DB target now prints a stderr banner and
stops instantly; the dev file is safe even if someone writes a new
test that forgets to override the connection.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 09:26:49 +03:00
ghassanandClaude Opus 4.7 fa4e09ad62 test(phase-2): perf and DOM regression tests + delete 300 lines of dead CSS
Seven feature tests covering:
- the per-card edit modal is gone (id="editVideoModal{slug}" and the
  .cute-edit-modal class),
- owner sees Edit/Delete and no Report/Not-interested,
- non-owner sees Report/Not-interested and no Edit/Delete,
- card thumbnail img carries width/height and loading="lazy",
- feedMore returns JSON with items + next_cursor + has_more,
- feedMore rejects unknown filters by returning an empty slice,
- comments component ships the reply <template>, not inline forms.

While wiring these up I also deleted the 300-line .cute-edit-modal CSS
block that was still shipping in card-styles.blade.php even though its
markup was removed in the previous commit — the CSS was pointing at
IDs that no longer exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:54:55 +03:00
ghassanandClaude Opus 4.7 8aa08e705a perf(comments): lazy-render top-level reply forms
The reply form used to render ~13 lines of hidden markup under every
top-level comment, so a 50-comment video shipped roughly 650 lines of
inert DOM to every signed-in viewer. Replaced with a single
<template id="ytc-reply-form-tpl"> plus a per-comment slot div; the
first click on Reply clones the template, wires the IDs
(replyForm{id}, replyBody{id}, data-parent-id, data-video-id) and
appends it, so toggle behavior stays identical from the user's side.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:52:13 +03:00
ghassanandClaude Opus 4.7 8fdac4b483 perf(feeds): cursor-paginated infinite scroll on /videos
Add VideoController@feedMore -> JSON { items, next_cursor, has_more }
that pages the current filter (all / shorts / match / music / lang:xx)
with Laravel's cursorPaginate(24). Cursor is stable across inserts, so
scrolling never duplicates or skips a video when something is uploaded
mid-session.

Client side: a #feed-sentinel + IntersectionObserver (rootMargin
400px) appends the next page of cards to the existing grid without a
page reload. A Load More button is always wired as the fallback when
IO is unavailable or a fetch fails. Both reuse the existing
<x-video-card/> component, so the newly fetched cards get the same
singleton edit modal + width/height thumbs as the server-rendered ones.

The initial 60-card server render stays, so the first viewport is
unchanged. The limit(60) and limit(50) caps in index() can now be
treated as "first page size" rather than a hard ceiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:50:59 +03:00
ghassanandClaude Opus 4.7 73620a0f24 perf(video-card): strip dead per-card edit modal, tighten owner menu
- Delete the per-card "cute" edit modal (lines 242-341) and the three
  dead handlers that drove it (openEditVideoModal, closeEditVideoModal,
  the type/privacy/thumbnail/submit document-level delegators). The
  singleton at layouts/partials/edit-video-modal.blade.php already
  defines a global window.openEditVideoModal that always won the
  last-definition race, so clicking Edit was already going through
  the singleton. Card is 547 -> 308 lines; a 60-card feed drops
  ~14 000 lines of modal markup.
- "Not interested", "Don't recommend channel", "Report" are now gated
  on @unless($isOwner) — they're meaningless on your own video.
- All dropdown items that were <a href="javascript:void(0)" onclick>
  are now real <button type="button">, including the save-to-playlist
  and share slots (tag="button").
- Thumbnail img gains width="320" height="180" so the browser reserves
  layout space before the image decodes — removes layout shift on
  slow connections. The actual rendered size still comes from
  .yt-video-thumb's aspect-ratio: 16/9.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:49:33 +03:00
ghassanandClaude Opus 4.7 4718888de0 test(phase-1): feature coverage for security + data-integrity fixes
Seven feature tests, run against an in-memory SQLite forced via putenv
in setUp (keeps the shared dev DB safe). Covers:

- Reserved-identifier rule rejects admin / super_admin / takeone /
  support / etc, matches both name and email local-part only, and
  lets normal names through.
- SafeNotifier swallows a notification throw without re-raising.
- Identical comments within 60s collapse to the first row.
- X-Idempotency-Key returns the first comment on retry.
- Owner viewing their own video does not insert a video_views row.

AppServiceProvider stops force-upgrading the scheme in the testing env
so the HTTP test client isn't 302'd before reaching the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:44:58 +03:00
ghassanandClaude Opus 4.7 c7d2f25ef6 feat(views): skip owner views, 30-minute device dedupe
- Insert is skipped entirely when the authenticated viewer owns the
  video. Owners re-watching, previewing an edit, or impersonating no
  longer inflate their own view counts.
- Dedupe window drops 1h -> 30min for both auth and guest paths.
- Auth-path dedupe now also matches on device fingerprint / device_id
  cookie, so signing out and back in during the same session doesn't
  double-count a single viewer.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:38:59 +03:00
ghassanandClaude Opus 4.7 48c947d171 feat(cli): comments:dedupe cleanup command
Collapse historical duplicate comments produced before the 60-second
dedupe guard landed. Keeps the earliest row in each cluster, reparents
nested replies onto it, moves comment_likes rows, deletes the rest.

Default --window=60 matches the server guard; --dry-run shows the plan
without touching the DB. Transactional per group so a partial failure
does not leave orphaned likes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:38:34 +03:00
ghassanandClaude Opus 4.7 c9123bc7b8 feat(comments): 60s dedupe window + idempotency key
- Server window raised from 5s to 60s so slow double-fires (observed
  on /videos/CRKAf: same body saved 3-7 times, seconds to minutes
  apart) collapse into one comment.
- X-Idempotency-Key honored: the client sends a per-attempt UUID; the
  server caches the resulting comment id for 5 minutes under that key.
  A retry with the same key returns the first-written comment without
  touching the DB a second time.
- Comment and reply submit JS both send the key via fetch().

The existing _busy flag + disabled submit button already covers the
fast double-click case on the client; this hardens the server side so
duplicates can't slip through even when the client guard is bypassed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:38:03 +03:00
ghassanandClaude Opus 4.7 3122420a3d feat(auth): tighten login throttle and alert on brute force
- Drop /login throttle from 10/min to 5/min per IP.
- After every failed login, count user.login.failed audit entries for
  the same email in the last 10 minutes. If >=5, fire a one-shot
  (cache-deduped) BruteForceLoginAlert notification to every super
  admin via database + mail channels.
- Alert carries the email, originating IP, failure count and window
  so the admin can decide whether to force a reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:37:22 +03:00
ghassanandClaude Opus 4.7 76584651a6 feat(cli): app:resend-verification command
Resend the verification link to one user (--user=<id|email>) or every
unverified user (--all-unverified), with a --dry-run preview. Uses
User::sendEmailVerificationNotification() which is already Throwable-
guarded, so SMTP hiccups during a bulk flush log instead of aborting.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:36:45 +03:00
ghassanandClaude Opus 4.7 1cefc6ad2d feat(mail): make notification failures non-fatal
Add App\Support\SafeNotifier that wraps $notifiable->notify() in a
Throwable guard with a logged warning, so SMTP outages (the current
Gmail 535 errors) no longer propagate as a 500 to the user. Apply to
the two hot paths that were still unprotected: CommentController
(new comment, reply, like) and RegisteredUserController (super-admin
registration alert). User::sendEmailVerificationNotification() is
wrapped inline for the same reason.

Also add docs/mail-setup.md covering the three delivery options:
Gmail app password (quick), Mailgun, Postmark — with the exact .env
keys for each.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:35:41 +03:00
ghassanandClaude Opus 4.7 a1493462f7 feat(auth): block reserved identifiers at sign-up
Add App\Rules\ReservedIdentifier for name + email local-part so users
cannot impersonate staff (admin, superadmin, support, root, takeone, …).
Separators are collapsed so "super_admin"/"super-admin"/"superadmin"
all match. Rule wired into RegisteredUserController@store.

email_verified_at is already absent from User::$fillable so only the
signed verification route can flip it — audited, no code change needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:34:32 +03:00
ghassanandClaude Opus 4.7 56b6a83b34 docs: audit remediation plan (7 phases)
Checklist across security, performance/DOM, feeds/search/trending,
watch page, Shorts, channel/studio/live, and admin/a11y. Branch per
phase; backup tag backup/pre-audit-2026-10-21 at 5058eaf.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:33:10 +03:00
ghassanandClaude Opus 4.7 5058eaff2f Fix video merge losing images, audio tracks, and lyrics
Three bugs made merge destructive and unrecoverable:

1. Primary-track slide paths were only prefix-swapped {sourceDir} → {targetDir}.
   The promoted primary track got a NEW folder name
   tracks/{lang}-{newTrackId}, but the slide filename kept the old
   tracks/{lang}-{srcVideoId} segment, pointing at a folder that no longer
   existed. Rewrite is now computed from the owning track's updated path, so
   it survives regardless of what the stored filename used to look like.

2. Legacy-layout sources (primary audio directly at {sourceDir}/audio.ext,
   slides at {sourceDir}/slides/) were silently skipped. The old code
   hard-coded tracks/{lang-id}/ as the source location. Merge is now
   layout-aware: it reads dirname($source->path) to pick between folder-move
   (new layout) and per-file moves (legacy — audio, slides/ dir, thumb).

3. Lyrics were lost on legacy sources. {sourceDir}/lyrics/primary.json and
   {sourceDir}/lyrics/track-{id}.json are now explicitly moved into the
   target's per-track folders as lyrics.json, matching the new layout the
   LyricsService reads from.

Added moveFile() helper (mirrors moveFolder on NAS + local, queues to
pending_nas_moves when NAS is unreachable). Verified against six scenarios
including new-layout, legacy, target-with-existing-extras, minimal, 2-extras
with slides and reversed-position lyrics source: all broken-file counts are
zero and slide position order is preserved verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-02 12:18:49 +03:00
ghassanandClaude Opus 4.7 468820c9fe Unify upload/edit modal track-form + fix slide cropper hang & reorder loss
Track-form unification: the "Add Language Track" dialog in the edit modal now
clones the same #um-track-form-template partial that the create modal uses, so
future tweaks to markup/CSS/behaviour show up in both flows automatically.
Added window.cloneTrackFormTemplate() + window.initTrackFormNode() as the
shared cloner+init. updateTrackCard() writes to both um-tc-*-{id} and
edit-tc-*-{id} ids so one helper serves both modals. Dropped the dead
#edit-extra-track-tpl and the custom editHandleTrackAudio (handleTrackAudio
auto-fills title identically).

Cover slides cropper fixes (both modals):
  - Second batch no longer hangs. _slidesCropLoadNext now calls
    openCropperModal_* BEFORE tcPreload_*; previously preloading first measured
    canvas.clientWidth=0 against a display:none overlay and left _st.ready
    false so Save silently returned.
  - Reorder no longer loses thumbnails. Create flow stored raw Files and
    spawned a FileReader per slide per render; under memory pressure some
    readers failed silently. Now stores {file, url} with URL.createObjectURL
    called once per slide, and renderSlidesStrip assigns img.src = url
    directly. Removal revokes the URL; clear revokes everything it drops.
  - Reset .value on the slides input after processing so re-selecting the
    same filename still fires change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-02 12:18:35 +03:00
ghassanandClaude Opus 4.7 2d33e39b9b Add AI translation for comments and video descriptions
Comments get a Translate button that calls the active LLM provider in Admin → AI
settings. Each commenter shows a small country flag inferred from their
video_views + profile_visits history (falls back to users.nationality); that
inferred country drives the language hint passed to the LLM so Portuguese
"Linda, linda, linda" translates to "Beautiful" instead of pass-through.

Description boxes get a line-paired translation grid: a shared Translate button
flips #vdbDescSplit into a two-column CSS grid where every source line sits
next to its translation on the same row. Backend splits by newline and asks
the LLM for a JSON map keyed by index so counts always match.

LlmLyricsService now has translate() + translateLines() with echo-retry (if the
model returns the input unchanged while source != target, retry once with a
stronger directive) and keeps the Ollama model resident via keep_alive=1h.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-02 12:18:20 +03:00
ghassanandClaude Opus 5 20c8fdcdf3 Video pages: stack Up Next below the video under 1300px
Between 992px and 1300px the left nav (240px) and the 300px side rail left
the video column under ~700px. Up Next titles collapsed into a ~124px
channel and the match About card broke its VS row apart, so a window that
was not full-screen lost information instead of reflowing.

Add one additive @media (max-width: 1300px) block per video type that fires
the SAME stacking the <=991px layout already did, just earlier: layout goes
column, video section and sidebar go full width. Match also collapses
fighters-row to 1fr and stacks the event header.

Nothing is removed. >=1301px is byte-for-byte the old desktop layout, and
the <=991px block still owns the phone treatment (thumb-on-top cards, match
review view, bottom sheet).

Verified by headless render at 1600/1360/1200/1050/960/820/700/420: no
horizontal overflow at any width, desktop unchanged, and a music video still
plays (readyState 4, stream advancing) per the match-type-only rule.
Generic could not be checked visually - there are no generic videos in the
database - but its block is identical to music's.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-24 10:22:16 +03:00
ghassanandClaude Opus 4.7 9f82d3cf31 Match capture strips: freeze video on every scrubber interaction
The point-capture and coach-review strips have a forcePause helper that
pauses the video (and re-pauses 30ms later to beat HLS.js's async
resume) whenever the slider is grabbed or scrubbed. It wasn't being
called from the frame-step buttons or the typed-timestamp commit, so
using the ± nudge to land on an exact moment while the video was
playing would happily keep the video playing and drift past the target.

Expose forcePause on both _pcbCtx and _rcbCtx and call it from:
- pcbNudge / rcbNudge (frame-step buttons)
- commitTimeInput / _rcbCommitTimeInput (typed timestamp on Enter)
- _rcbSetActiveSlot (switching start/end slot re-scrubs the video)

Playback now resumes only on an explicit play press.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-11 13:48:34 +03:00
ghassanandClaude Opus 4.7 3e18bd2150 Match videos: composed VS-card OG preview + richer share metadata
Sports match share links now show the full VS composition (fighters,
flags, event, weight class, referee/court/match chips) in WhatsApp /
Telegram / Facebook / Twitter previews instead of the raw video frame.

- Browsershot + puppeteer-bundled Chromium screenshots a dedicated
  1200x675 (true 16:9) render of the vs-mini composition. Chrome runs
  through a small wrapper (bin/browsershot-node.sh) that raises the fd
  limit and reads its full env, since PHP-FPM's stripped env otherwise
  makes Chromium fail to launch.
- Render is JPEG @ q85 (~100 KB) instead of PNG (~700 KB) — WhatsApp
  silently drops OG images above roughly 300 KB, which is why music
  thumbnails always showed and match cards didn't.
- SportsMatchController@store/update renders synchronously so the
  picture is guaranteed to exist before the user copies the share URL;
  edits regenerate and old stamped variants are cleaned. The og:image
  URL carries a ?v={updated_at} stamp so scrapers recache on edit.
- accessShare() (/s/{token}) now detects scraper user agents and serves
  the video page HTML inline instead of a 302 — some crawlers don't
  follow redirects when building link previews.
- og:description / twitter:description built from headerData(): flag
  emoji + fighter names (blue vs red), event, weight, match#, court,
  referee, view count, upload date.
- Fixed 3-dot menu on video cards being hidden behind the next card
  (:has(.dropdown-menu.show) promotes the active card above siblings
  and lifts the overflow:hidden clip on .yt-video-info).
- vs-mini: skip the entrance animation on touch devices and expose a
  .vs-mini-static class so the OG render always captures the final
  frame instead of a random mid-animation moment.

Also adds an admin gallery at /admin/og-previews for spot-checking the
generated preview JPEGs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-11 12:34:40 +03:00
ghassanandClaude Opus 4.7 665bad76bf Match cards: composed title, header stage from match title, equal-sized grid
- SportsMatchController syncs Video.title with SportsMatch.title on
  store/update so the "Match title" field drives the video's display
  title everywhere.
- headerData(): "championship" now prefers event_name (event title)
  and adds a dedicated "stage" key sourced from SportsMatch.title
  (shown between the yellow lines above the weight class in both the
  in-player VS overlay and the vs-mini gallery card).
- Video cards render match titles with flags + corner colours
  ("FINALS – 🇧🇭 Blue vs 🇧🇭 Red (-61 kg)"), matching the video
  page header, and clip long titles to one line.
- Video gallery grid uses repeat(N, minmax(0, 1fr)) + grid-auto-rows:1fr
  so every card thumbnail renders at exactly the same size; hover-preview
  videos use object-fit:cover to stop portrait sources looking smaller.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-11 09:14:25 +03:00
ghassanandClaude Opus 4.7 04d63cccdb VS intro screen (arena design), card VS mini + live scorebar, full country names, sports-match edit fix
Match player + preview
- Rebuild VS intro as a 1920×1080 scaled canvas (arena design from drafts/):
  angled RED/BLUE panels with clip-paths, slam-in VS with pulse+shine,
  event/stage/weight header, Match/Court/Referee chips, per-fighter
  Record/Rank/Stats chip row. Every placeholder from the artifact is
  always in the DOM; empty ones are hidden by `.vs-nullable:empty`
  and `.vs-nullable-hide` so the layout adapts without losing the
  design's HTML shape. Country codes always render as full country
  names (via new App\Data\Countries::name()).
- Real-time 6s countdown Skip button; video pinned at t=0 while
  the intro runs, released to play at zero or on Skip.
- Standalone preview page at /videos/{video}/vs-preview.

Video cards (home + everywhere)
- Match cards get a `vs-mini` overlay of the same arena design
  (fixed canvas, per-card ResizeObserver, animations replay every
  time the mouse leaves the thumb). On hover the mini explodes
  outward (panels fly out, VS scales+blurs+fades) and the hover
  video plays over the cleared thumb.
- Match cards also render a `scorebar-mini` overlay (verbatim of
  the full-player msb-scale scorebar + msb-feed) that shows
  during hover playback. Live-scoring is wired per-card via a
  data-sbm-state payload of rounds+points; RED/BLUE score, round
  label, per-round clock, and the top-right Live Scoring ticker
  (4 entries, sport-aware Yuko/Waza-ari/Ippon or Punch/Body
  kick/… labels) all update from the mini video's timeupdate.

Bug fixes
- Hover video wasn't playing sound: dropped the `muted` attribute
  I'd added while debugging match previews.
- White flash strobing on the left of match cards: removed the
  `.vs-mini-flash` element (intro-only flourish that fired on
  every mouseleave replay).
- Sports-match modal was showing "Please complete the required
  fields" when saving basic info for match videos that had no
  SportsMatch row yet: the submit gate only checked matchId, not
  the attached videoId; now it correctly skips uploadVideoFirst()
  in both edit and attach-existing-video modes.

Country names project-wide
- New helper App\Data\Countries::name($iso2) → full country name.
- Swapped `?? country` fallbacks to `?? Countries::name(country)`
  in admin dashboard, video-analytics, video-insights payloads
  (VideoController + SuperAdminController), and the VS partials.
  Now user-visible country labels always show "Bahrain" instead
  of "BH", including chart tooltips and modal titles.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-10 18:34:31 +03:00
ghassanandClaude Opus 4.7 5b6bd86d70 Video player: lock frame to strict 16:9 at all times
Remove max-height clamps and override portrait/square/ultrawide/theater
variants so the .ytp-wrap box shape never distorts. Portrait content
letterboxes inside the 16:9 frame via object-fit: contain.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-10 13:39:15 +03:00
ghassanandClaude Opus 4.7 b05ff14b3b Fullscreen highlights drawer: keep scoreboard visible, don't hide feed; scrollable gear menu; remove slowmo picker + scrim tint
- Scoreboard scale observes #videoContainer (the .ytp inner box) instead of
  #ytpWrap so the overlay tracks the squeezed container when the fullscreen
  highlights drawer opens. Score bar + live-scoring feed shift left with the
  video and stay fully visible next to the drawer.

- msb-small (auto-hide identity + feed under 700 px) now skipped while
  document.fullscreenElement is set — the drawer squeeze on smaller viewports
  was dropping the container under 700 and hiding the live feed.

- Player settings panel (#ytpSettingsPanel) is now vertical-scroll capable:
  max-height: calc(100vh - 120px) + overflow-y: auto + slim on-brand
  scrollbar + overscroll-behavior: contain. Top items (Mini player, Playback
  speed) stay reachable when the injected Scoreboard toggles push the
  list past the visible area.

- Removed the slow-mo (.slowmo-picker) 1/4x/1/2x/3/4x selector from the
  match highlights tab bar.

- Disabled the scoreboard scrim (.msb-scrim { display: none }). The two
  linear-gradients were tinting the top and bottom of the video for text
  legibility, but every scoreboard text element already carries its own
  text-shadow — the tint was showing as permanent shadows over portrait
  and landscape videos alike.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-10 00:19:18 +03:00
ghassan 5d038488fb Merge professional-match-overly: match scoreboard overlay + gear-menu toggles + cropper rewrite + sports metadata fixes 2026-08-09 23:49:09 +03:00
ghassanandClaude Opus 4.7 2b5e480c9a Remove VS screen; fix cropper + sports edit save + headshot/logo mapping
- Remove VS intro from player and thumbnail VS card from match video cards.
  All references (`msb-vs*`, `initVsIntro`, `setVsImg`, `HAS_VS`, `$vs`,
  `$msbThumbVs`, `$hasMatchMeta`) are stripped from scoreboard partials,
  video-card component, and scoreboard script. Related CSS keyframes
  (`msbFadeIn/Out`, `msbDriftA/B`, `msbSweep`) removed.

- Image cropper (resources/views/components/image-cropper.blade.php):
  replace unreliable third-party Cropme integration with a DIY cropper.
  Pan/zoom/rotate wired directly with pointer + wheel events; on save,
  the visible viewport is rendered to an offscreen canvas at the configured
  output-width (with circular clip for `shape="circle"`). Also reparent the
  overlay to <body> on open to escape any transformed Bootstrap-modal
  ancestor's stacking context that clipped it.

- Sports match modal (resources/views/layouts/partials/sports-match-modal.blade.php):
  set fighter photo croppers to 3:4 portrait (285x380) to match the VS
  card's 228x304 frame; club logos + referee stay 1:1 square.

- SportsMatchController::fillFromRequest (line 174): coerce
  `$this->clean($request->input('media', []))` to `[]` when it returns null
  so array_merge doesn't crash under PHP 8's stricter types.

- SportsMatch::headerData(): read fighter headshots + club logos from the
  canonical media.* keys (participant1_photo / participant2_photo /
  club1_logo / club2_logo) that the uploader modal actually saves, with the
  older p1_* / p2_* names kept as a secondary fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-09 23:47:59 +03:00
ghassanandClaude Opus 4.7 19741f489b Match scoreboard overlay: identity/ticker/scorebar/timeline + VS intro + gear-menu toggles
- Scoreboard overlay driven by video.currentTime (scores/ticker/timeline as pure
  functions of playback time; scrub-back removes points; slow-mo doesn't affect).
- Score bar + timeline + VS intro rendered verbatim from the design handoff
  (drafts/score-bar.html, drafts/vs-screen.html) — only {{ }} data holes filled.
- Fixed 1150 px design canvas scaled to any player size via ResizeObserver so
  the design's pixel-perfect measurements survive at every player width.
- Gear-menu injection adds a "Scoreboard" section with 7 toggle rows (Score bar,
  Live scoring feed, Match info, Point timeline, Club logos, Country flags,
  Penalties) using a custom 26x14 track/knob toggle matching the design.
  Persists per user in localStorage['msb_prefs']; penalties defaults off.
- VS card fades in on first play of a session (skippable by click/tap/key,
  2 s hold then 0.5 s fade); skipped entirely when no athlete metadata.
- SportsMatch::headerData() extended to expose sport, headshots, club logos,
  division, format, match_date and per-video scoreboard defaults from existing
  JSON columns (no schema migration).
- Every missing field hides its element (no "NOT SET" placeholders); athlete
  name falls back to RED/BLUE per brief.
- Pushed .replay-badge down from top:2.5cqi to top:8cqi so it clears the
  new KARATE KUMITE identity block.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-09 22:09:04 +03:00
ghassan ed921a4ccf Merge mobile-view-match-highlights: frame-accurate scrubbing, mobile UX, collapsed action menu 2026-08-09 02:27:48 +03:00
ghassanandClaude Opus 4.7 1c4e4986b5 Frame-accurate scrubbing + mobile highlights UX + collapsed action menu
Frame-accurate timestamps (end-to-end)
- Migration 2026_08_08_000002 promotes match_rounds.start_time_seconds,
  match_points.timestamp_seconds, coach_reviews.start_time_seconds and
  coach_reviews.end_time_seconds from INTEGER to decimal(10,3).
- Model $casts updated (float). MatchEventController validators relaxed
  from `integer` to `numeric|min:0` on all four fields.
- Blade SSR data-time-{start,end} attributes cast to (float) so the
  first paint carries frame precision.
- Point + review capture strips: slider step = 1/fps, snapToFrame()
  applied to every seek/nudge/input commit. Clock now shows SMPTE
  MM:SS.FF; parsePcbTimeInput accepts MM:SS.FF, MM:SS, MM.SS or seconds.
- Default fps is 30; override via window.matchFPS.
- confirmPointCapture no longer Math.round()s currentTime — sends the
  exact frame boundary.

Point + review UX
- Video pauses aggressively on scrubber grab (mousedown/pointerdown/
  touchstart) AND on every input, with a 30ms follow-up to beat HLS.js
  race conditions.
- Zoom buttons now scope by data-pcs-zoom / data-rcb-zoom so clicks on
  the review-strip zoom don't clobber the point-strip zoom (root cause
  of the "resets while button still on 20x" bug).
- Point cards now play a preroll/postroll clip at 1× then replay at
  the chosen slow-mo rate, then resume normal playback.
- REPLAY ×N badge shown in the top-left of the video during replays,
  swapping palette when slow-mo kicks in.
- Slow-mo picker (¼× ½× ¾×) in the tab-header — window.slowmoRate is
  the source of truth for both point and review replays.
- Clicking a card hides the player chrome (controls-hidden re-applied
  across 0/10/60/200/500ms to beat pause/seek/play showControls races);
  mousemove over the video brings them back instantly.
- Review-tools slow-mo button removed — the card click IS the trigger.

Coach review specific
- Dual-thumb scrubber on a shared track with a red fill between the
  thumbs (visualises the note's range). Start clamps to ≤ end and
  vice-versa; nudges act on the last-touched thumb.
- Note overlay live-previews as user types and is drag-to-position;
  overlay position persists (position_x/position_y decimal(6,4)).
- Overlay uses container-query units (cqi) so text scales with the
  player width and position stays proportional on any resize.
- Timeline card design (chapter-style time label + subtle card with
  emoji, note, coach name, tools) — nothing else selected.

Sidebar
- "Rounds & points" / "Private notes" section labels removed; only
  the owner-only + Add Round / + Add Note button remains.
- Points that share a timestamp within a round collapse into ONE card
  with a chip per side (equal-width Blue/Red pills), one edit/delete
  pair, and a coloured score line (yellow ROUND N, blue/red score
  badges — background pills with white numerals).
- On mobile the highlights sheet only closes via its own toggle now.
  Backdrop is pointer-events: none so taps pass through to the video.

Mobile capture strip
- Renders below the video on mobile portrait so the paused frame is
  visible while typing. Wider inner padding (10px 20px 14px) so
  content doesn't sit on the edges.
- .pcs-entry uses flex-wrap: nowrap and Action input shrinks to
  fit — the row stays on one line at any width.

Collapsed action menu (video-actions component)
- Every viewport now shows the single ⚡ Action dropdown; individual
  desktop-action buttons are hidden by CSS across the board.

SPA navigation fixes
- window.videoId + isOwner now live on window so navigation to a new
  match video via Up Next can update them via reloadMatchVideoState.
- Tabs, event-item clicks, highlights toggle and sidebar-height sync
  re-hydrate on each SPA swap.
- Blade point-time regression: "{{ '@' . $fmtTime(...) }}" instead of
  the misused "@{{ ... }}" escape directive.

Backend
- Point-score recompute (recomputeRoundScores) already sums by
  (timestamp, id) order — works fine with the new decimal columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-09 02:26:19 +03:00
ghassan 55247828e1 Merge coach-review: in-player review capture strip, draggable overlay, timeline UI, SPA re-hydration 2026-08-08 07:21:20 +03:00
ghassanandClaude Opus 4.7 cba8e01d56 Match videos: full re-hydration on Up Next SPA navigation
Fixes a raft of things breaking when jumping between match videos via the
Up Next / playlist sidebar without a page reload:

- Highlights toggle stopped working (button held a click handler pointing at
  the OLD, detached .events-sidebar node).
- Points / Coach review tabs went unclickable (new .tab-button elements had
  no bound handlers).
- Event-item clicks (jump-to-timestamp on point / review cards) stopped
  seeking / playing.
- window.matchRounds / window.matchReviews and const videoId were frozen at
  first page load, so edit/delete/save handlers routed to the wrong video.
- Blade point card leaked "{{ $fmtTime(...) }}" as literal text because of
  a misused @{{ }} escape directive.

Changes
- videoId + isOwner now live on window so they can be updated at runtime;
  `var videoId` at module scope keeps existing handlers working.
- Extracted tab-button + attachEventListeners wiring into
  window.initMatchTabsAndEvents (idempotent — _tabBound flag).
- Added window.reloadMatchVideoState(url): parses the new slug from the URL,
  updates window.videoId, calls loadMatchData (refreshes cache + re-renders
  sidebar), then re-runs the tabs/events/toggle/sidebar-height inits.
- recSwapContent and plSwapContent call reloadMatchVideoState whenever the
  target page has an .events-sidebar (falls back to just the toggle re-init
  for non-match targets).
- Fixed the Blade point-card time: {{ '@' . $fmtTime(...) }} instead of
  @{{ ... }} so the timestamp actually renders instead of showing braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-08 07:20:50 +03:00
ghassanandClaude Opus 4.7 716693a74f Coach reviews: in-player capture strip, draggable overlay, timeline UI
Capture strip
- Twin of the point-capture strip, embedded inside #ytpWrap. Two time slots
  (Start + optional End) with the scrubber driving whichever is "active".
  Editable mm:ss inputs, zoom (1x/5x/20x), inline note + coach name + emoji
  chip picker. Cancel / Save on the right; Delete in edit mode.
- ▶ preview button plays Start → End and auto-pauses at End; click again to
  stop early. Playback uses the live overlay so previewing shows what the
  viewer will see.
- Removed the #reviewModal popup; openAddReviewModal(), setReviewEmoji(),
  saveReview() and the reviewForm submit binding are gone.

Live-preview overlay + drag-to-position
- As the user types, the coach-note overlay updates on the video in real
  time. In capture mode the overlay is grab-able and draggable to any spot
  over the video (mouse + touch), with a red dashed outline + "↕ drag me"
  hint.
- Overlay position is stored as normalized 0..1 CENTER coords of the video
  wrap and applied via `left: X%; top: Y%; transform: translate(-50%,-50%)`
  so the overlay stays at the same relative spot on any resize / fullscreen.
- Font-size, padding and border-radius scale with the player width via CSS
  container queries (`container-type: inline-size` on #ytpWrap + `clamp(...cqi)`),
  so the note grows/shrinks proportionally.
- Draggable overlay lives above the strip (z-index 70 vs 60) so it can be
  dropped anywhere including over the strip.

Persistence (schema change)
- Migration 2026_08_08_000001_add_position_to_coach_reviews: adds nullable
  decimal(6,4) position_x / position_y to coach_reviews.
- CoachReview fillable + casts updated. store/updateReview validate
  position_x / position_y as numeric between 0 and 1 and persist them.
- showCoachNoteOverlay(text, pos) applies the saved position on playback.

Cards: timeline design (Option B from the mockup)
- Vertical spine with red dots on the left; each review is a chapter-style
  time label above a subtle card. Card body: emoji as the leading indicator,
  note text, then coach name + tools (▶⏳ / ✏️ / 🗑).
- ▶⏳ = playReviewSlowmo(id): plays Start → End once at 1×, then loops back
  and plays again at 0.5×, then pauses and hides the overlay. Only rendered
  when the review has an end_time > start_time.
- Card body still clickable for the classic "jump-and-play-once" behaviour.
- Fixes a latent bug where the primary Save button stayed disabled after a
  successful save, silently swallowing the next click. Both the review and
  point capture strips now re-enable the button in their cancel handlers.

Also
- Design mockup file at public/coach-review-mockups.html shows the four
  candidate layouts side by side (kept for future design iterations).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-08 07:07:03 +03:00
ghassan 057ce87564 Merge match-video: sports match sheet, in-player point capture, HEVC transcode fix 2026-08-08 05:33:36 +03:00
ghassanandClaude Opus 4.7 402c6dc89f Point capture in the player: inline strip, grouped entries, correct scores
Replaces the point Add/Edit popup with an in-player capture strip and fixes
the underlying running-score bug.

Backend (MatchEventController)
- storePoint used pluck('points','competitor'), which collapses same-side
  points into a single row (last value wins) and excluded same-timestamp
  points (< instead of <=). Same-moment Blue+Red produced 1-0 / 0-1 instead
  of 1-1. Replaced with recomputeRoundScores($roundId), a single pass over
  the round ordered by (timestamp_seconds, id) that walks a running total
  and updates each row. Called after create/update/delete so edits ripple.

Player-embedded capture strip (match.blade.php)
- Adding / editing points no longer opens a modal; the video's chrome
  bottom-bar is swapped for a strip inside #ytpWrap that survives fullscreen.
- Editable mm:ss field + draggable red marker + −1s/+1s nudges. Zoom control
  (1x / 5x / 20x) narrows the slider window around the current time for
  frame-level positioning; nudges scale with zoom. Auto-recentre on edge.
- Inline form: Blue / Red / Both toggle (equal-width segments), action text,
  points number. Both mode splits into two per-side rows. Values carry over
  when switching modes so nothing is retyped.
- Enter saves, Esc cancels. Save button auto-disables during in-flight
  requests. On mobile the buttons collapse to icon-only (× / ✓).

Grouped highlights entry
- Points sharing a timestamp within a round collapse into ONE card with a
  chip per side (equal-width Blue/Red pills), a single ✏️/🗑 pair, and a
  meta line coloured by role: ROUND N in amber, blue score in blue, red
  score in red.
- ✏️ on a grouped entry opens the strip in Both mode with both sides
  pre-filled. Saving PUTs both rows; switching to a single competitor
  keeps that side and deletes the other.
- 🗑 on a grouped entry deletes both rows via the shared custom-confirm.
- loadMatchData now writes back to window.matchRounds so subsequent
  edit/lookup handlers find newly-added points without a page reload.

Removed
- #pointModal HTML block and the openAddPointModal / savePoint /
  confirmDeletePoint helpers (superseded by the capture strip).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-08 05:31:26 +03:00
ghassanandClaude Opus 4.7 ad6cbaf742 Match highlights: DB-backed rounds/points, draggable point capture, sports sheet Edit
Match highlights sidebar (videos/types/match.blade.php)
- Replaced hardcoded 3-round/5-point/3-review mock with server-rendered loops
  over $video->matchRounds (with points) and $video->coachReviews. Owner-only
  buttons carry real DB ids to the existing JS handlers.
- Fixed savePoint create branch: was doing parseInt(rawMmSs) which truncated
  "1.15" -> 1 second. Now sends the parsed value that edit already used.
- Added a point-capture bar: clicking + on a round pauses the video and shows a
  bottom bar with a big draggable red marker + nudge buttons. Dragging seeks
  the video live; Confirm reads video.currentTime and opens the Add Point modal
  with mm.ss pre-filled and Action focused. Fixed both server- and JS-rendered
  round buttons so dynamic reloads keep the new flow.

Edit → sports sheet routing
- Added sports_match_id to /videos/{video}/edit JSON.
- openEditVideoModal delegates type=match videos to openSportsMatchModal —
  edit mode when a SportsMatch is linked, create-with-attached-video mode
  when the video is match-typed but has no SportsMatch row yet.
- Sports sheet gained attachExistingVideo() so create-mode can be seeded with
  a video that's already in the library (skips the upload dropzone).

Upload chooser mobile bottom-nav
- Bottom nav "Upload" now opens the type chooser (bottom sheet on mobile,
  centred modal on desktop) instead of hard-navigating to /videos/create.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-08 04:01:06 +03:00
ghassanandClaude Opus 4.7 76f5ef0c84 Fix match-video playback: HEVC → H.264 transcode + correct HLS URL
- CompressVideoJob: rewrite to exec ffmpeg directly. php-ffmpeg's X264
  format class validates the codec name and only accepts libx264, so
  every job dispatched with h264_nvenc had been throwing within ~1s and
  leaving the source untouched. The job now honours the GPU encoder
  settings, and always replaces the source when the codec is HEVC/H.265
  so downloads and the MP4 fallback stay playable.
- VideoController + video-player component: point HLS URLs at
  playlist.m3u8 (the actual master file GenerateHlsJob writes) instead
  of the non-existent master.m3u8, which was 404-ing and forcing the
  player back to the raw MP4.
- VideoController@store: on the NAS-success branch, flip status to
  'ready' and populate duration/is_shorts before dispatching
  GenerateHlsJob, so the job no longer silently no-ops.
- Upload chooser: render as a bottom sheet on mobile, and forward the
  picked type to /videos/create?type= so the create page pre-selects it.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-08 02:08:25 +03:00
ghassanandClaude Opus 4.7 e0aa6e4817 Drag-and-drop reordering for tracks in the edit modal
Replaces the up/down arrow buttons on each track card with a grip
handle. Dragging a card shows a red insertion indicator on the target
card's top or bottom edge and drops it into the closest slot on
release, including gaps between cards or past the last card.

The card is only marked draggable while the grip handle is being held,
so text selection and button clicks inside the card body behave
normally. Existing promote_track_id logic (secondary at position 1
becomes primary on save) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-02 02:17:24 +03:00
ghassanandClaude Opus 4.7 3d8d7b8efe Allow converting a video to a music track without losing views/URL
VideoController@update() now handles a replacement media file: if the
new file is audio the row is forced to type=music and, when the on-disk
type folder differs from the declared type (e.g. a video sitting under
users/{u}/videos/ that was later marked music), the media is relocated
into the canonical users/{u}/music/{slug}/tracks/{lang}-{id}/audio.ext
layout. Old primary file and stale meta.json are deleted from NAS; HLS
and slideshow caches are reset. The same relocation is applied by
replaceFile() so the mobile "Replace Media File" flow behaves the same.

Slide- and audio-track-management gates in update() also switched from
isAudioOnlyFile() (extension-based) to the declared type, so secondary
tracks and cover slides added mid-conversion are no longer silently
dropped. The edit modal now renders existing secondary tracks whenever
type=music regardless of the primary file extension.

Also carries an unrelated app.blade.php tweak: the mobile filter bar's
sticky reset moved from .yt-filter-bar to its wrapper, with padding
kept on the inner bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-01 14:39:19 +03:00
ghassanandClaude Opus 4.7 da714958c6 Scrollable, SPA-navigated filter bar with all languages
Removes the 12-chip cap on the language filter so every language that
appears in public content is listed (video 175 alone contributes 12).

The bar wrapper is now sticky and horizontally scrollable with chevron
buttons that appear only when the bar overflows in that direction, a
gradient fade behind each button, vertical-wheel → horizontal scroll
that hands off to the page at the edges, and sessionStorage-persisted
scroll position so navigation doesn't snap it back to the start.

Chip clicks are intercepted (only for /videos URLs; Trending/Shorts
still render different templates via a normal navigation) and swap
only the #filter-content region via fetch + DOMParser, updating
history.pushState, document.title, and active-chip state without a
full page reload. Modifier-clicks and middle-clicks fall through
to native "open in new tab" behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-07-31 03:51:45 +03:00
125 changed files with 20013 additions and 1998 deletions

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
APP_ENV=testing
APP_DEBUG=true
APP_URL=http://localhost
APP_KEY=base64:Zm7kR6vDfsQQmCeWTR2Mk2WQGu6fGP1UHEcZVk/hIt0=
# ── Hard rule — tests must never touch the shared SQLite file. ───────────
# phpunit.xml also sets these via <env>, but having them here means Laravel
# picks them up during the earliest bootstrap step (before any trait or
# setUp hook can run), closing the window that let migrate:fresh wipe the
# dev DB on 2026-10-21. If you change these values, read tests/TestCase.php's
# assertSafeTestDatabase() guard first.
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
BCRYPT_ROUNDS=4
CACHE_DRIVER=array
MAIL_MAILER=array
QUEUE_CONNECTION=sync
SESSION_DRIVER=array
TELESCOPE_ENABLED=false
PULSE_ENABLED=false
+357
View File
@@ -0,0 +1,357 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use PDO;
/**
* Backs up the database, and optionally mirrors the media library.
*
* Three things make this a backup rather than a file copy:
*
* 1. CONSISTENCY — SQLite is snapshotted with `VACUUM INTO` after a WAL
* checkpoint, so the artifact is a coherent point-in-time copy even while
* the app is writing. Copying the file by hand mid-write is not.
* 2. VERIFICATION — the snapshot is re-opened, integrity-checked and read
* back before it counts as a success. An unverified backup is a rumour.
* 3. OFF-SERVER — a snapshot on the same disk survives a bad migration but
* not a dead disk, so a configured remote disk receives a copy and the
* command warns loudly on every run where none is set.
*
* The media library is deliberately NOT part of the default run. It is
* gigabytes of immutable uploads; archiving it nightly would produce hours of
* CPU and a disk full of near-identical tarballs. Pass --media to mirror it
* incrementally with rsync instead.
*
* Exits non-zero on any failure so a cron surfaces the problem instead of
* quietly producing corrupt files.
*
* Mirrors `takeone:backup` on the takeone platform so both systems are backed
* up the same way and restored the same way.
*/
class BackupCommand extends Command
{
protected $signature = 'takeone:backup
{--media : Also mirror the media library (incremental rsync, needs BACKUP_MEDIA_DEST)}
{--media-only : Mirror media and skip the database}
{--keep-local : Do not delete the local copy after an off-server upload}';
protected $description = 'Snapshot the database, verify it, optionally mirror media, copy off-server and prune old artifacts';
private array $artifacts = [];
public function handle(): int
{
$dir = (string) config('backup.path');
if (! is_dir($dir) && ! @mkdir($dir, 0775, true)) {
$this->error("Cannot create backup directory: {$dir}");
return self::FAILURE;
}
$stamp = now()->format('Ymd-His');
$failed = false;
if (! $this->option('media-only')) {
$failed = ! $this->backupDatabase($dir, $stamp) || $failed;
}
if ($this->option('media') || $this->option('media-only')) {
$failed = ! $this->mirrorMedia() || $failed;
} else {
$this->line(' <comment>–</comment> media not included (pass --media to mirror it)');
}
$this->shipOffServer();
$this->prune($dir);
if ($failed) {
Log::error('Backup run failed', ['artifacts' => $this->artifacts]);
$this->error('Backup FAILED — see the errors above.');
return self::FAILURE;
}
Log::info('Backup completed', ['artifacts' => array_map('basename', $this->artifacts)]);
$this->newLine();
$this->info('Backup complete.');
return self::SUCCESS;
}
/* ---------------- Database ---------------- */
private function backupDatabase(string $dir, string $stamp): bool
{
$connection = config('database.default');
$driver = config("database.connections.{$connection}.driver");
return match ($driver) {
'sqlite' => $this->backupSqlite($dir, $stamp),
'mysql', 'mariadb' => $this->backupMysql($dir, $stamp, $connection),
default => tap(false, fn () => $this->error("Unsupported database driver for backup: {$driver}")),
};
}
/** Consistent SQLite snapshot: checkpoint the WAL, then VACUUM INTO. */
private function backupSqlite(string $dir, string $stamp): bool
{
$target = $dir."/db-{$stamp}.sqlite";
// VACUUM cannot run inside a transaction. Rather than let SQLite throw
// something cryptic, say so plainly — a backup that quietly produces
// nothing is the worst possible outcome.
if (DB::transactionLevel() > 0) {
$this->error('Cannot snapshot the database inside an open transaction.');
return false;
}
try {
// Harmless when the database is not in WAL mode.
DB::statement('PRAGMA wal_checkpoint(TRUNCATE)');
DB::statement('VACUUM INTO '.DB::getPdo()->quote($target));
} catch (\Throwable $e) {
$this->error('Database snapshot failed: '.$e->getMessage());
return false;
}
if (! $this->verifySqlite($target)) {
return false;
}
$this->artifacts[] = $target;
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
return true;
}
/**
* Read the snapshot back. A file that exists but cannot be opened, or that
* holds no migrations and no videos, is not a backup worth trusting.
*/
private function verifySqlite(string $path): bool
{
try {
$pdo = new PDO('sqlite:'.$path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if ($pdo->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') {
$this->error("Snapshot failed integrity check: {$path}");
return false;
}
$migrations = (int) $pdo->query('SELECT count(*) FROM migrations')->fetchColumn();
if ($migrations < 1) {
$this->error("Snapshot contains no migrations — refusing to trust it: {$path}");
return false;
}
// Read a real application table too. `migrations` alone would still
// be present in a snapshot taken of an empty database.
$videos = (int) $pdo->query('SELECT count(*) FROM videos')->fetchColumn();
$this->line(" <info>✓</info> verified integrity ok · {$migrations} migrations · {$videos} videos");
} catch (\Throwable $e) {
$this->error('Snapshot could not be verified: '.$e->getMessage());
return false;
}
return true;
}
private function backupMysql(string $dir, string $stamp, string $connection): bool
{
$target = $dir."/db-{$stamp}.sql.gz";
$c = config("database.connections.{$connection}");
$command = sprintf(
'mysqldump --single-transaction --quick --no-tablespaces -h%s -P%s -u%s %s %s | gzip > %s',
escapeshellarg((string) $c['host']),
escapeshellarg((string) $c['port']),
escapeshellarg((string) $c['username']),
$c['password'] ? '-p'.escapeshellarg((string) $c['password']) : '',
escapeshellarg((string) $c['database']),
escapeshellarg($target),
);
exec($command, $out, $status);
if ($status !== 0 || ! is_file($target) || filesize($target) === 0) {
$this->error('mysqldump failed (exit '.$status.').');
return false;
}
// Verify the dump is readable and non-trivial rather than trusting the
// exit code — a gzip of an error message also exits 0.
exec('gzip -t '.escapeshellarg($target).' 2>/dev/null', $o2, $verify);
if ($verify !== 0) {
$this->error('Dump failed its gzip integrity check — discarding.');
@unlink($target);
return false;
}
$this->artifacts[] = $target;
$this->line(sprintf(' <info>✓</info> database %s (%s)', basename($target), $this->humanSize($target)));
return true;
}
/* ---------------- Media ---------------- */
/**
* Mirror the media library with rsync.
*
* Uploaded video cannot be regenerated from anything — losing it is the
* one unrecoverable outcome on this platform. rsync is used rather than an
* archive because the library is large and almost entirely immutable, so
* each run copies only what is new.
*/
private function mirrorMedia(): bool
{
$destination = (string) config('backup.media_destination');
if ($destination === '') {
$this->newLine();
$this->warn('--media was requested but BACKUP_MEDIA_DEST is not set — media was NOT backed up.');
$this->line(' Uploaded video cannot be regenerated. Point BACKUP_MEDIA_DEST at a NAS mount or remote path.');
return false;
}
if (! is_dir($destination) && ! @mkdir($destination, 0775, true)) {
$this->error("Media destination is not reachable: {$destination}");
return false;
}
$paths = array_values(array_filter((array) config('backup.media_paths', []), 'is_dir'));
if (! $paths) {
$this->line(' <comment>–</comment> media nothing to mirror');
return true;
}
// --delete is opt-in and off by default: a mirror that deletes is one
// wrong path away from erasing the copy it exists to protect.
$flags = '-a --partial --info=stats2';
if (config('backup.media_delete')) {
$flags .= ' --delete';
}
$ok = true;
foreach ($paths as $path) {
// Trailing slash on the source, basename on the destination, so
// each folder lands in its own directory rather than merging.
$command = sprintf(
'rsync %s %s %s 2>&1',
$flags,
escapeshellarg(rtrim($path, '/').'/'),
escapeshellarg(rtrim($destination, '/').'/'.basename($path).'/'),
);
exec($command, $out, $status);
if ($status !== 0) {
$this->error('Media mirror failed for '.basename($path).' (exit '.$status.').');
$ok = false;
continue;
}
$this->line(sprintf(' <info>✓</info> media %s → %s', basename($path), $destination));
}
return $ok;
}
/* ---------------- Off-server ---------------- */
private function shipOffServer(): void
{
$disk = config('backup.disk');
if (! $disk) {
$this->newLine();
$this->warn('No BACKUP_DISK configured — these copies live on the SAME disk as the data.');
$this->line(' They survive a bad migration, but not a dead server. Set BACKUP_DISK to a remote filesystem.');
return;
}
foreach ($this->artifacts as $artifact) {
try {
Storage::disk($disk)->put('backups/'.basename($artifact), fopen($artifact, 'rb'));
$this->line(" <info>✓</info> off-server {$disk}:backups/".basename($artifact));
if (! $this->option('keep-local')) {
@unlink($artifact);
}
} catch (\Throwable $e) {
// Never delete the local copy when the upload failed — it is
// the only copy left.
$this->error('Off-server upload failed for '.basename($artifact).': '.$e->getMessage());
}
}
}
/* ---------------- Rotation ---------------- */
/**
* Delete artifacts past the retention window, but always keep the newest
* few — a box that sat idle for a month must still have something to
* restore from.
*/
private function prune(string $dir): void
{
$days = (int) config('backup.retain_days', 14);
if ($days <= 0) {
return;
}
$keep = max(0, (int) config('backup.keep_minimum', 3));
$cutoff = now()->subDays($days)->getTimestamp();
$removed = 0;
$files = glob($dir.'/db-*') ?: [];
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a)); // newest first
foreach (array_slice($files, $keep) as $file) {
if (filemtime($file) < $cutoff && @unlink($file)) {
$removed++;
}
}
if ($removed) {
$this->line(" <info>✓</info> pruned {$removed} artifact(s) older than {$days} days");
}
}
private function humanSize(string $path): string
{
$bytes = (int) @filesize($path);
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes >= 1024 && $i < count($units) - 1; $i++) {
$bytes /= 1024;
}
return round($bytes, 1).' '.$units[$i];
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use App\Models\Comment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Clean up duplicate comments that pre-date the server-side 60-second dedupe
* guard. Duplicate = identical (user_id, video_id, parent_id, body) within a
* configurable gap. Keeps the earliest row, moves any likes onto it, deletes
* the rest. --dry-run shows what would happen without touching the DB.
*/
class CommentsDedupeCommand extends Command
{
protected $signature = 'comments:dedupe
{--window=60 : Treat duplicates as same submission if within N seconds (default 60)}
{--dry-run : Print what would be removed; do not delete}';
protected $description = 'Collapse duplicate comments (same user, video, parent, body) into the earliest one';
public function handle(): int
{
$window = max(1, (int) $this->option('window'));
$dryRun = (bool) $this->option('dry-run');
// Fetch (user_id, video_id, parent_id, body) groups that appear >1 time.
// We ignore the window at the SQL layer and apply it per-group in PHP:
// that avoids a correlated subquery and is fast enough for a one-shot
// cleanup. For very large tables, chunk by user_id.
$groups = DB::table('comments')
->selectRaw('user_id, video_id, parent_id, body, COUNT(*) as cnt')
->groupBy('user_id', 'video_id', 'parent_id', 'body')
->havingRaw('COUNT(*) > 1')
->get();
if ($groups->isEmpty()) {
$this->info('No duplicate (user, video, parent, body) groups found.');
return self::SUCCESS;
}
$totalRemoved = 0;
$groupsTouched = 0;
foreach ($groups as $g) {
$rows = Comment::where('user_id', $g->user_id)
->where('video_id', $g->video_id)
->where(function ($q) use ($g) {
$g->parent_id === null
? $q->whereNull('parent_id')
: $q->where('parent_id', $g->parent_id);
})
->where('body', $g->body)
->orderBy('created_at')
->orderBy('id')
->get();
if ($rows->count() < 2) {
continue;
}
$keeper = $rows->first();
$cluster = collect([$keeper]);
$previous = $keeper;
foreach ($rows->slice(1) as $row) {
// Same cluster if row is within window of the previous row —
// which is the behavior the server guard enforces today.
if ($row->created_at->diffInSeconds($previous->created_at) <= $window) {
$cluster->push($row);
$previous = $row;
}
}
if ($cluster->count() < 2) {
continue;
}
$toDelete = $cluster->skip(1);
$groupsTouched++;
$totalRemoved += $toDelete->count();
$this->line(sprintf(
' user=%d video=%d parent=%s keep=%d remove=%s body="%s"',
$g->user_id,
$g->video_id,
$g->parent_id ?? 'null',
$keeper->id,
$toDelete->pluck('id')->join(','),
mb_strimwidth(preg_replace('/\s+/', ' ', (string) $g->body), 0, 40, '…'),
));
if ($dryRun) {
continue;
}
DB::transaction(function () use ($keeper, $toDelete) {
$ids = $toDelete->pluck('id')->all();
// Reparent any replies that were nested under a doomed row.
Comment::whereIn('parent_id', $ids)->update(['parent_id' => $keeper->id]);
// Move likes to the keeper, dropping dupes.
DB::table('comment_likes')
->whereIn('comment_id', $ids)
->update(['comment_id' => $keeper->id]);
Comment::whereIn('id', $ids)->delete();
});
}
$this->info(sprintf(
'%s %d duplicate rows across %d groups.',
$dryRun ? 'Would remove' : 'Removed',
$totalRemoved,
$groupsTouched,
));
return self::SUCCESS;
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\VideoController;
use App\Models\Video;
use Illuminate\Console\Command;
/**
* Renders the 1200×630 VS composition PNG for a match video's social-share
* preview. Runs the same helper used by VideoController@ogImage but from a
* CLI process context, because Chromium reliably fails to launch under
* PHP-FPM ("Failed to launch the browser process: Code: null") on this
* host. Invoke detached via `nohup` from either the ogImage endpoint (on
* cache miss) or SportsMatchController on save.
*/
class RenderMatchOgImage extends Command
{
protected $signature = 'og:render-match {video}';
protected $description = 'Render the OG social-share PNG for a match video';
public function handle(): int
{
$video = Video::find($this->argument('video'));
if (! $video) {
$this->error("Video {$this->argument('video')} not found.");
return 1;
}
if ($video->type !== 'match' || ! $video->sportsMatch) {
$this->error("Video {$video->id} is not a match with a sports record.");
return 1;
}
$controller = new VideoController();
$r = new \ReflectionMethod($controller, 'renderMatchOgImage');
$r->setAccessible(true);
$png = $r->invoke($controller, $video);
if ($png === null) {
$this->error("Render failed. See laravel.log.");
return 1;
}
$this->info("Rendered " . strlen($png) . " bytes for video {$video->id}.");
return 0;
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
/**
* Resend the email-verification mail to one user or to every unverified user.
*
* Useful after an SMTP outage: the account was created and the user was told
* "we sent you a link", but the mail never left because Gmail 535'd us. Once
* the mail config is fixed, this flushes the backlog.
*/
class ResendVerificationCommand extends Command
{
protected $signature = 'app:resend-verification
{--user= : A specific user id OR email to resend to}
{--all-unverified : Resend to every user with a null email_verified_at}
{--dry-run : Print who would be mailed; do not actually send}';
protected $description = 'Resend the email-verification link to one user or all unverified users';
public function handle(): int
{
$one = (string) $this->option('user');
$all = (bool) $this->option('all-unverified');
$dryRun = (bool) $this->option('dry-run');
if ($one === '' && ! $all) {
$this->error('Pass either --user=<id-or-email> or --all-unverified.');
return self::FAILURE;
}
if ($one !== '') {
$query = ctype_digit($one)
? User::where('id', (int) $one)
: User::where('email', $one);
$users = $query->get();
} else {
$users = User::whereNull('email_verified_at')->get();
}
if ($users->isEmpty()) {
$this->warn('No matching users.');
return self::SUCCESS;
}
$sent = 0;
$skipped = 0;
foreach ($users as $user) {
if ($user->hasVerifiedEmail()) {
$skipped++;
$this->line(" skip #{$user->id} <{$user->email}> — already verified");
continue;
}
if ($dryRun) {
$this->line(" would #{$user->id} <{$user->email}>");
continue;
}
// sendEmailVerificationNotification is already SafeNotifier-wrapped
// and will log instead of throwing on SMTP errors.
$user->sendEmailVerificationNotification();
$sent++;
$this->line(" sent #{$user->id} <{$user->email}>");
}
$this->info($dryRun
? "Dry run complete — {$users->count()} candidates, {$skipped} already verified."
: "Done — {$sent} sent, {$skipped} skipped.");
return self::SUCCESS;
}
}
+8
View File
@@ -48,6 +48,14 @@ class Kernel extends ConsoleKernel
->weeklyOn(1, '09:00') ->weeklyOn(1, '09:00')
->withoutOverlapping() ->withoutOverlapping()
->runInBackground(); ->runInBackground();
// Nightly database backup at 03:30 Asia/Bahrain. Writes to
// data/backups/db-YYYYMMDD-HHMMSS.sqlite, verifies the snapshot,
// and (when BACKUP_DISK is set) ships a copy off-server.
$schedule->command('takeone:backup')
->dailyAt('03:30')
->withoutOverlapping()
->runInBackground();
} }
/** /**
+12
View File
@@ -8,6 +8,18 @@ class Countries
* All countries keyed by ISO2 code. * All countries keyed by ISO2 code.
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency * Fields: name, iso2, iso3, flag, dial_code, timezone, currency
*/ */
/**
* Resolve any ISO2 code (case-insensitive) to its full country name.
* Falls back to the uppercased code, then to null for empty input.
* Use this instead of printing raw two-letter codes to end users.
*/
public static function name(?string $iso2): ?string
{
$code = strtoupper(trim((string) $iso2));
if ($code === '') return null;
return self::all()[$code]['name'] ?? $code;
}
public static function all(): array public static function all(): array
{ {
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library) // Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Data;
class CountryLanguages
{
private const MAP = [
'AE' => 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt',
'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az',
'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar',
'BO' => 'es', 'BR' => 'pt', 'BY' => 'be',
'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es',
'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs',
'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar',
'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am',
'FI' => 'fi', 'FR' => 'fr',
'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es',
'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu',
'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar',
'IR' => 'fa', 'IS' => 'is', 'IT' => 'it',
'JO' => 'ar', 'JP' => 'ja',
'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar',
'KZ' => 'kk',
'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr',
'LV' => 'lv', 'LY' => 'ar',
'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my',
'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms',
'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne',
'NZ' => 'en',
'OM' => 'ar',
'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl',
'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es',
'QA' => 'ar',
'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru',
'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl',
'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar',
'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh',
'TZ' => 'sw',
'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz',
'VE' => 'es', 'VN' => 'vi',
'YE' => 'ar',
'ZA' => 'en', 'ZW' => 'en',
];
public static function primary(?string $iso2): ?string
{
if (! $iso2) return null;
return self::MAP[strtoupper($iso2)] ?? null;
}
}
@@ -4,8 +4,12 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\User;
use App\Notifications\BruteForceLoginAlert;
use App\Support\SafeNotifier;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class AuthenticatedSessionController extends Controller class AuthenticatedSessionController extends Controller
{ {
@@ -50,11 +54,57 @@ class AuthenticatedSessionController extends Controller
'details' => ['email' => $credentials['email']], 'details' => ['email' => $credentials['email']],
]); ]);
$this->detectBruteForce($request, $credentials['email']);
return back()->withErrors([ return back()->withErrors([
'email' => 'The provided credentials do not match our records.', 'email' => 'The provided credentials do not match our records.',
]); ]);
} }
/**
* Fire one alert to super admins when the same email has racked up
* BRUTE_FORCE_THRESHOLD failures in BRUTE_FORCE_WINDOW_MINUTES. A Cache
* key with the same TTL prevents the alert from re-firing on every
* subsequent failed attempt within the same burst.
*/
private const BRUTE_FORCE_THRESHOLD = 5;
private const BRUTE_FORCE_WINDOW_MINUTES = 10;
private function detectBruteForce(Request $request, string $email): void
{
$window = self::BRUTE_FORCE_WINDOW_MINUTES;
$threshold = self::BRUTE_FORCE_THRESHOLD;
// SQLite stores JSON-cast `details` as text; a LIKE-based match is
// database-portable and avoids per-driver JSON operator syntax.
$pattern = '%"email":"' . addslashes($email) . '"%';
$recent = AuditLog::where('action', 'user.login.failed')
->where('created_at', '>=', now()->subMinutes($window))
->where('details', 'like', $pattern)
->count();
if ($recent < $threshold) {
return;
}
$cacheKey = 'brute_force_alert:' . sha1($email);
if (Cache::has($cacheKey)) {
return;
}
Cache::put($cacheKey, 1, now()->addMinutes($window));
$ip = $request->header('CF-Connecting-IP')
?? $request->header('X-Forwarded-For')
?? $request->ip()
?? 'unknown';
$notification = new BruteForceLoginAlert($email, (string) $ip, $recent, $window);
User::where('role', 'super_admin')->each(function (User $admin) use ($notification) {
SafeNotifier::send($admin, $notification);
});
}
public function destroy(Request $request) public function destroy(Request $request)
{ {
$user = Auth::user(); $user = Auth::user();
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Notifications\NewUserRegistered; use App\Notifications\NewUserRegistered;
use App\Rules\NotDisposableEmail; use App\Rules\NotDisposableEmail;
use App\Rules\ReservedIdentifier;
use App\Support\SafeNotifier;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -27,10 +29,14 @@ class RegisteredUserController extends Controller
} }
$request->validate([ $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255', new ReservedIdentifier('name')],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new NotDisposableEmail, new ReservedIdentifier('email')],
'password' => ['required', 'confirmed', Password::defaults()], 'password' => ['required', 'confirmed', Password::defaults()],
'birthday' => ['required', 'date', 'before:today'], // Age gate: must be at least 13 on sign-up day. The front-end
// date picker already caps the year dropdown, but the server
// must enforce it so a client bypass doesn't let a 10-year-old
// through.
'birthday' => ['required', 'date', 'before:' . now()->subYears(13)->toDateString()],
'gender' => ['required', 'in:male,female'], 'gender' => ['required', 'in:male,female'],
'nationality' => ['required', 'string', 'size:2'], 'nationality' => ['required', 'string', 'size:2'],
]); ]);
@@ -48,7 +54,7 @@ class RegisteredUserController extends Controller
// Notify all super admins of the new registration // Notify all super admins of the new registration
User::where('role', 'super_admin')->each(function (User $admin) use ($user) { User::where('role', 'super_admin')->each(function (User $admin) use ($user) {
$admin->notify(new NewUserRegistered($user)); SafeNotifier::send($admin, new NewUserRegistered($user));
}); });
auth()->login($user); auth()->login($user);
+144 -4
View File
@@ -8,6 +8,8 @@ use App\Models\Video;
use App\Notifications\NewCommentLikeNotification; use App\Notifications\NewCommentLikeNotification;
use App\Notifications\NewCommentNotification; use App\Notifications\NewCommentNotification;
use App\Notifications\NewReplyNotification; use App\Notifications\NewReplyNotification;
use App\Services\LlmLyricsService;
use App\Support\SafeNotifier;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -15,7 +17,7 @@ class CommentController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['index']); $this->middleware('auth')->except(['index', 'translate']);
} }
public function index(Video $video) public function index(Video $video)
@@ -34,12 +36,66 @@ class CommentController extends Controller
$commenter = Auth::user(); $commenter = Auth::user();
// Idempotency guard: a client that retries (duplicate listeners after
// an SPA swap, fast double-click, flaky connection) can POST the same
// comment twice. We dedupe on two signals:
//
// 1. X-Idempotency-Key — the fetch() client sends a per-attempt
// UUID; the browser's own retry logic reuses the same value.
// This catches the deliberate-retry case before body comparison.
//
// 2. Identical (user, video, parent, body) within 60s — covers
// the duplicate-listener case where two independent fetch()es
// fire with different idempotency keys for the same user intent.
//
// Either match returns the first-written comment unchanged.
$idemKey = (string) $request->header('X-Idempotency-Key', '');
$cacheKey = $idemKey !== ''
? 'comment-idem:' . $commenter->id . ':' . $video->id . ':' . substr(hash('sha256', $idemKey), 0, 40)
: null;
if ($cacheKey) {
$existingId = \Illuminate\Support\Facades\Cache::get($cacheKey);
if ($existingId && $existing = Comment::find($existingId)) {
return response()->json([
'success' => true,
'comment' => $existing->load('user:id,name,avatar'),
]);
}
}
$existing = $video->comments()
->where('user_id', $commenter->id)
->where('body', $request->body)
->where(function ($q) use ($request) {
$request->parent_id
? $q->where('parent_id', $request->parent_id)
: $q->whereNull('parent_id');
})
->where('created_at', '>=', now()->subSeconds(60))
->latest('id')
->first();
if ($existing) {
if ($cacheKey) {
\Illuminate\Support\Facades\Cache::put($cacheKey, $existing->id, now()->addMinutes(5));
}
return response()->json([
'success' => true,
'comment' => $existing->load('user:id,name,avatar'),
]);
}
$comment = $video->comments()->create([ $comment = $video->comments()->create([
'user_id' => $commenter->id, 'user_id' => $commenter->id,
'body' => $request->body, 'body' => $request->body,
'parent_id' => $request->parent_id, 'parent_id' => $request->parent_id,
]); ]);
if ($cacheKey) {
\Illuminate\Support\Facades\Cache::put($cacheKey, $comment->id, now()->addMinutes(5));
}
$comment->load('user:id,name,avatar'); $comment->load('user:id,name,avatar');
// Fire notifications (never notify yourself) // Fire notifications (never notify yourself)
@@ -47,12 +103,12 @@ class CommentController extends Controller
// Reply — notify the parent comment's author // Reply — notify the parent comment's author
$parent = Comment::find($request->parent_id); $parent = Comment::find($request->parent_id);
if ($parent && $parent->user_id !== $commenter->id) { if ($parent && $parent->user_id !== $commenter->id) {
$parent->user->notify(new NewReplyNotification($video, $comment, $commenter)); SafeNotifier::send($parent->user, new NewReplyNotification($video, $comment, $commenter));
} }
} else { } else {
// Top-level comment — notify the video owner // Top-level comment — notify the video owner
if ($video->user_id !== $commenter->id) { if ($video->user_id !== $commenter->id) {
$video->user->notify(new NewCommentNotification($video, $comment, $commenter)); SafeNotifier::send($video->user, new NewCommentNotification($video, $comment, $commenter));
} }
} }
@@ -89,6 +145,37 @@ class CommentController extends Controller
return response()->json(['success' => true]); return response()->json(['success' => true]);
} }
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
{
if (! $llm->isEnabled()) {
return response()->json(['error' => 'Translation is not configured'], 503);
}
$target = (string) $request->input('target', 'en');
// Derive a source-language hint from the comment author's most-frequent
// access country (video_views + profile_visits, falling back to their
// nationality setting). Never trust the client for this — it's used
// to disambiguate scripts the LLM might otherwise misidentify.
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
$sourceCountry = $topCountries[$comment->user_id] ?? null;
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
if ($translated === '') {
return response()->json(['error' => 'Translation failed'], 502);
}
return response()->json([
'success' => true,
'translated' => $translated,
'target' => $target,
'source_hint' => $sourceLang,
'author_country'=> $sourceCountry,
]);
}
public function like(Comment $comment) public function like(Comment $comment)
{ {
$userId = Auth::id(); $userId = Auth::id();
@@ -107,7 +194,8 @@ class CommentController extends Controller
if ($comment->user_id !== $userId) { if ($comment->user_id !== $userId) {
$video = $comment->video; $video = $comment->video;
if ($video) { if ($video) {
$comment->user->notify( SafeNotifier::send(
$comment->user,
new NewCommentLikeNotification($video, $comment, Auth::user()) new NewCommentLikeNotification($video, $comment, Auth::user())
); );
} }
@@ -118,4 +206,56 @@ class CommentController extends Controller
return response()->json(['liked' => $liked, 'count' => $count]); return response()->json(['liked' => $liked, 'count' => $count]);
} }
/**
* Toggle the "creator heart" on a comment. Only the video owner can
* drop a heart; anyone else gets a 403.
*/
public function heart(Comment $comment)
{
$video = $comment->video;
if (! $video || $video->user_id !== Auth::id()) {
return response()->json(['error' => 'Only the video owner can heart a comment.'], 403);
}
$comment->hearted = ! $comment->hearted;
$comment->save();
return response()->json([
'hearted' => (bool) $comment->hearted,
]);
}
/**
* Toggle "pinned" on a top-level comment. Only the video owner can
* pin, and pinned comments sort ahead of everything else in the list.
* At most one comment is pinned at a time — pinning a new one unpins
* the previous.
*/
public function pin(Comment $comment)
{
$video = $comment->video;
if (! $video || $video->user_id !== Auth::id()) {
return response()->json(['error' => 'Only the video owner can pin a comment.'], 403);
}
if ($comment->parent_id) {
return response()->json(['error' => 'Only top-level comments can be pinned.'], 422);
}
$wasPinned = (bool) $comment->pinned;
if (! $wasPinned) {
// Unpin any previously-pinned comment on this video first so
// the UI invariant "at most one pinned" is maintained at the
// DB level, not just in the view.
Comment::where('video_id', $video->id)
->where('pinned', true)
->update(['pinned' => false]);
}
$comment->pinned = ! $wasPinned;
$comment->save();
return response()->json([
'pinned' => (bool) $comment->pinned,
]);
}
} }
+208
View File
@@ -0,0 +1,208 @@
<?php
namespace App\Http\Controllers;
use App\Models\LiveStream;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* The media server's authorisation hook.
*
* MediaMTX holds no policy of its own: before it accepts a publisher or serves
* a viewer it POSTs here, and a non-2xx answer refuses the connection. That
* keeps every "who may do what" decision inside the application, next to the
* users table, rather than duplicated in a media server config.
*
* Deny by default, in the strongest sense: if this app is down, the media
* server can authorise nothing at all.
*
* These endpoints are called ONLY by MediaMTX over loopback. They are not part
* of the public API and reject anything arriving from elsewhere.
*/
class LiveAuthController extends Controller
{
/** Loopback addresses the media server may call from. */
private const LOCAL_IPS = ['127.0.0.1', '::1'];
/**
* Decide a publish or read attempt.
*
* Payload from MediaMTX: user, password, token, ip, action, path,
* protocol, id, query.
*/
public function authenticate(Request $request): Response
{
if (! $this->fromMediaServer($request)) {
return response('', 403);
}
$action = (string) $request->input('action');
// Control-plane actions carry no stream path, so they are settled
// before any attempt to resolve one.
if (in_array($action, ['api', 'metrics', 'pprof'], true)) {
return $this->authorizeApi();
}
$path = (string) $request->input('path');
$stream = $this->resolveStream($path);
if (! $stream) {
// An unknown path is not an error worth explaining — a publisher
// probing for valid stream ids learns nothing from this.
return response('', 403);
}
return match ($action) {
'publish' => $this->authorizePublish($request, $stream),
'read' => $this->authorizeRead($stream),
default => response('', 403),
};
}
/**
* The media server's own control API.
*
* It authenticates against this same hook, so without an explicit allow the
* app could not read live state from the server it runs alongside. Safe
* because it is already loopback-gated above — nothing off this host can
* reach it.
*/
private function authorizeApi(): Response
{
return response('', 200);
}
/**
* A publisher must present the short-lived token minted for this stream
* when its owner pressed "Go live". The token is single-use: it is burned
* here, so a captured one cannot start a second broadcast.
*/
private function authorizePublish(Request $request, LiveStream $stream): Response
{
// MediaMTX passes HTTP Basic credentials through as user/password.
$presented = (string) ($request->input('password') ?: $request->input('token'));
if (! $stream->publishTokenMatches($presented)) {
Log::warning('Live publish refused', [
'stream' => $stream->public_id,
'ip' => $request->input('ip'),
]);
return response('', 403);
}
$stream->consumePublishToken();
$stream->markLive();
Log::info('Live publish authorised', [
'stream' => $stream->public_id,
'user_id' => $stream->user_id,
]);
return response('', 200);
}
/**
* Reads are anonymous by design — a viewer fetching HLS parts carries no
* credentials — so the gate is the stream's own state: it must be live and
* not private. The unpredictable path is what keeps an unlisted stream
* unlisted.
*/
private function authorizeRead(LiveStream $stream): Response
{
if (! $stream->isLive()) {
return response('', 403);
}
if (! in_array($stream->visibility, ['public', 'unlisted'], true)) {
return response('', 403);
}
return response('', 200);
}
/**
* Lifecycle notifications from the media server.
*
* The auth hook only fires on the way in, so without this a stream whose
* phone lost signal would read as live forever.
*/
public function hook(Request $request): Response
{
if (! $this->fromMediaServer($request)) {
return response('', 403);
}
$stream = $this->resolveStream((string) $request->input('path'));
if (! $stream) {
return response('', 204);
}
match ((string) $request->input('event')) {
'publish' => $stream->markLive(),
// Only a stream that is actually live can end. A late hook from a
// previous, failed session must not kill the one just started.
'unpublish' => $stream->isLive() ? $stream->markEnded() : null,
'read' => $this->viewerJoined($stream),
'unread' => $this->viewerLeft($stream),
default => null,
};
return response('', 204);
}
/* ---------------- Viewer accounting ---------------- */
/*
* Counted with atomic SQL increments rather than read-modify-write: viewers
* arrive concurrently, and two joins landing together would otherwise
* record one.
*/
private function viewerJoined(LiveStream $stream): void
{
LiveStream::where('id', $stream->id)->increment('current_viewers');
// peak_viewers is a high-water mark, so it only ever moves up.
LiveStream::where('id', $stream->id)
->whereColumn('current_viewers', '>', 'peak_viewers')
->update(['peak_viewers' => DB::raw('current_viewers')]);
}
private function viewerLeft(LiveStream $stream): void
{
// Floor at zero: a missed join (a restart, a dropped hook) must not
// drive the count negative and show "-1 watching".
LiveStream::where('id', $stream->id)
->where('current_viewers', '>', 0)
->decrement('current_viewers');
}
/* ---------------- Helpers ---------------- */
/**
* Only the media server on this host may ask. Deliberately checks the raw
* connection address rather than a forwarded header — a header can be
* spoofed by anyone who can reach the app.
*/
private function fromMediaServer(Request $request): bool
{
return in_array($request->server('REMOTE_ADDR'), self::LOCAL_IPS, true);
}
/** Turn "live/<public_id>" into the stream it names. */
private function resolveStream(string $path): ?LiveStream
{
if (! preg_match('#^live/([A-Za-z0-9]{32})$#', $path, $m)) {
return null;
}
return LiveStream::where('public_id', $m[1])->first();
}
}
@@ -0,0 +1,292 @@
<?php
namespace App\Http\Controllers;
use App\Models\LiveChatMessage;
use App\Models\LiveStream;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
/**
* Live streaming — broadcaster and viewer surfaces.
*
* The media plane (MediaMTX) is deliberately dumb: this controller decides who
* may broadcast, mints the short-lived credential the browser publishes with,
* and hands out the URLs. See LiveAuthController for the hook the media server
* calls back on.
*/
class LiveStreamController extends Controller
{
public function __construct()
{
// Watching is open; everything that creates or controls a stream needs
// an account.
$this->middleware('auth')->except(['index', 'watch', 'status', 'chatPoll']);
}
/** Live now, plus the viewer's own streams when signed in. */
public function index(): \Illuminate\View\View
{
$live = LiveStream::live()
->listed()
->with('user')
->latest('started_at')
->limit(60)
->get();
$mine = Auth::check()
? LiveStream::where('user_id', Auth::id())->latest()->limit(20)->get()
: collect();
return view('live.index', compact('live', 'mine'));
}
/** The "start a broadcast" form. */
public function create(): \Illuminate\View\View
{
return view('live.create');
}
/**
* Create the stream record. This does NOT start anything — it reserves an
* identity and a path; the browser goes live from the broadcast page.
*/
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'visibility' => ['required', Rule::in(['public', 'unlisted', 'private'])],
]);
// One live broadcast per account at a time. Without this a single user
// can pin open as many encoder slots as they can open tabs.
$alreadyLive = LiveStream::where('user_id', Auth::id())
->where('status', LiveStream::STATUS_LIVE)
->exists();
if ($alreadyLive) {
return back()
->withInput()
->withErrors(['title' => 'You already have a stream running. End it before starting another.']);
}
$stream = LiveStream::create([
'user_id' => Auth::id(),
'title' => $data['title'],
'description' => $data['description'] ?? null,
'visibility' => $data['visibility'],
'status' => LiveStream::STATUS_IDLE,
]);
return redirect()->route('live.broadcast', $stream);
}
/** The broadcaster's camera page. Owner only. */
public function broadcast(LiveStream $stream): \Illuminate\View\View
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
// An ended stream is not a dead one — the owner may go live on it
// again from the same link.
$stream->reArm();
return view('live.broadcast', [
'stream' => $stream,
'whipUrl' => $this->whipUrl($stream),
'watchUrl' => route('live.watch', $stream),
'iceServers' => $this->iceServers(),
]);
}
/**
* Mint the publish credential.
*
* Separate from the page load so the token's short life starts when the
* user actually presses "Go live", not when they opened the tab and went
* to find a tripod.
*/
public function token(LiveStream $stream): JsonResponse
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
// Pressing "Go live" on a stream that previously ended restarts it
// rather than being refused. A failed attempt must never burn the link.
$stream->reArm();
return response()->json([
'token' => $stream->issuePublishToken(),
'expires_in' => LiveStream::PUBLISH_TOKEN_TTL_SECONDS,
'whip_url' => $this->whipUrl($stream),
'ice_servers' => $this->iceServers(),
]);
}
/** The viewer page. */
public function watch(LiveStream $stream): \Illuminate\View\View
{
abort_unless($stream->isWatchableBy(Auth::user()), 404);
$stream->loadMissing('user');
return view('live.watch', [
'stream' => $stream,
'whepUrl' => $this->whepUrl($stream),
'hlsUrl' => $this->hlsUrl($stream),
'isOwner' => $stream->isOwnedBy(Auth::user()),
'iceServers' => $this->iceServers(),
]);
}
/** Lightweight poll for the viewer page: is it live, how many watching. */
public function status(LiveStream $stream): JsonResponse
{
abort_unless($stream->isWatchableBy(Auth::user()), 404);
return response()->json([
'status' => $stream->status,
'live' => $stream->isLive(),
'viewers' => $stream->current_viewers,
'duration' => $stream->formatted_duration,
]);
}
/** End the broadcast. Owner only. */
public function stop(LiveStream $stream): JsonResponse
{
abort_unless($stream->isOwnedBy(Auth::user()), 403);
$stream->markEnded();
return response()->json([
'success' => true,
'status' => $stream->status,
'duration' => $stream->formatted_duration,
]);
}
/* ---------------- URLs ---------------- */
/*
* These are same-origin paths, proxied by nginx to the media server on
* loopback. Same-origin matters: the page is served over HTTPS, and a
* mixed-content or cross-origin media URL would be blocked by the browser
* before WebRTC ever started.
*/
private function whipUrl(LiveStream $stream): string
{
return url('/live-rtc/'.$stream->mediaPath().'/whip');
}
private function whepUrl(LiveStream $stream): string
{
return url('/live-rtc/'.$stream->mediaPath().'/whep');
}
private function hlsUrl(LiveStream $stream): string
{
return url('/live-hls/'.$stream->mediaPath().'/index.m3u8');
}
/**
* ICE servers for the browser.
*
* No inbound UDP port is forwarded to this server, so a broadcaster outside
* the local network can only reach it through a TURN relay. On the LAN the
* host candidates work directly and TURN is never used.
*
* Configured through the environment so credentials never sit in the repo.
*/
private function iceServers(): array
{
$servers = [];
if ($stun = config('live.stun_url')) {
$servers[] = ['urls' => $stun];
}
if ($turn = config('live.turn_url')) {
$servers[] = array_filter([
'urls' => $turn,
'username' => config('live.turn_username'),
'credential' => config('live.turn_credential'),
]);
}
return $servers;
}
/**
* GET poll: viewer asks for every message in this stream newer than
* ?since=<id> (defaults to 0 for a cold open). Returns up to 50
* messages — more than enough for a 3-second polling window, bounded
* to protect the DB when a client is slow to reconnect.
*/
public function chatPoll(Request $request, LiveStream $stream): JsonResponse
{
// Private streams are owner-only; everything else is open to viewers.
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
abort(404);
}
$since = (int) $request->query('since', 0);
$rows = LiveChatMessage::where('livestream_id', $stream->id)
->where('id', '>', $since)
->orderBy('id')
->limit(50)
->get(['id', 'user_id', 'author_name', 'body', 'created_at']);
return response()->json([
'messages' => $rows->map(fn ($m) => [
'id' => $m->id,
'uid' => $m->user_id,
'name' => $m->author_name,
'body' => $m->body,
'at' => $m->created_at?->toISOString(),
])->all(),
]);
}
/**
* POST a chat message. Rate-limited to roughly one message per second
* per user via Laravel's built-in throttle, applied via the route.
* Body is capped at 400 chars (long enough for a sentence, short enough
* that the panel doesn't shear into a wall of text).
*/
public function chatSend(Request $request, LiveStream $stream): JsonResponse
{
if ($stream->status !== LiveStream::STATUS_LIVE) {
return response()->json(['error' => 'Chat is closed when the stream isn\'t live.'], 422);
}
if ($stream->visibility === 'private' && Auth::id() !== $stream->user_id) {
abort(404);
}
$data = $request->validate([
'body' => ['required', 'string', 'max:400'],
]);
$user = Auth::user();
$msg = LiveChatMessage::create([
'livestream_id' => $stream->id,
'user_id' => $user->id,
'author_name' => $user->name,
'body' => trim($data['body']),
'created_at' => now(),
]);
return response()->json([
'ok' => true,
'message' => [
'id' => $msg->id,
'uid' => $msg->user_id,
'name' => $msg->author_name,
'body' => $msg->body,
'at' => $msg->created_at?->toISOString(),
],
]);
}
}
+54 -37
View File
@@ -18,7 +18,7 @@ class MatchEventController extends Controller
$request->validate([ $request->validate([
'round_number' => 'required|integer|min:1', 'round_number' => 'required|integer|min:1',
'name' => 'nullable|string|max:50', 'name' => 'nullable|string|max:50',
'start_time_seconds' => 'nullable|integer|min:0', 'start_time_seconds' => 'nullable|numeric|min:0',
]); ]);
// Check if user owns the video // Check if user owns the video
@@ -45,7 +45,7 @@ class MatchEventController extends Controller
$request->validate([ $request->validate([
'round_number' => 'sometimes|integer|min:1', 'round_number' => 'sometimes|integer|min:1',
'name' => 'required|string|max:50', 'name' => 'required|string|max:50',
'start_time_seconds' => 'nullable|integer|min:0', 'start_time_seconds' => 'nullable|numeric|min:0',
]); ]);
// Check if user owns the video // Check if user owns the video
@@ -87,7 +87,7 @@ class MatchEventController extends Controller
{ {
$request->validate([ $request->validate([
'round_id' => 'required|exists:match_rounds,id', 'round_id' => 'required|exists:match_rounds,id',
'timestamp_seconds' => 'required|integer|min:0', 'timestamp_seconds' => 'required|numeric|min:0',
'action' => 'required|string|max:255', 'action' => 'required|string|max:255',
'points' => 'required|integer|min:1', 'points' => 'required|integer|min:1',
'competitor' => 'required|in:blue,red', 'competitor' => 'required|in:blue,red',
@@ -99,31 +99,10 @@ class MatchEventController extends Controller
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
} }
// Get ALL previous points in this round (ordered by timestamp) // Create the row first with placeholder scores, then recompute the whole
$previousPoints = MatchPoint::where('match_round_id', $request->round_id) // round's running scores. This is the only correct approach when two
->where('timestamp_seconds', '<', $request->timestamp_seconds) // points can share a timestamp (Blue + Red at the same moment) or when
->orderBy('timestamp_seconds', 'asc') // a point is inserted between existing ones.
->pluck('points', 'competitor')
->toArray();
// Calculate cumulative scores by summing each point value
$scoreBlue = 0;
$scoreRed = 0;
if (isset($previousPoints['blue'])) {
$scoreBlue += $previousPoints['blue'];
}
if (isset($previousPoints['red'])) {
$scoreRed += $previousPoints['red'];
}
// Add current point
if ($request->competitor === 'blue') {
$scoreBlue += $request->points;
} else {
$scoreRed += $request->points;
}
$point = MatchPoint::create([ $point = MatchPoint::create([
'video_id' => $video->id, 'video_id' => $video->id,
'match_round_id' => $request->round_id, 'match_round_id' => $request->round_id,
@@ -132,21 +111,46 @@ class MatchEventController extends Controller
'points' => $request->points, 'points' => $request->points,
'competitor' => $request->competitor, 'competitor' => $request->competitor,
'notes' => $request->notes, 'notes' => $request->notes,
'score_blue' => $scoreBlue, 'score_blue' => 0,
'score_red' => $scoreRed, 'score_red' => 0,
]); ]);
$this->recomputeRoundScores($request->round_id);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'point' => $point, 'point' => $point->fresh(),
'message' => 'Point added successfully!', 'message' => 'Point added successfully!',
]); ]);
} }
/**
* Recompute cumulative score_blue/score_red for every point in a round,
* ordered by (timestamp_seconds, id). Handles same-timestamp ties by
* insertion order, so Blue and Red saved at the same moment both end up
* reflecting the running score after that moment.
*/
private function recomputeRoundScores(int $roundId): void
{
$blue = 0;
$red = 0;
MatchPoint::where('match_round_id', $roundId)
->orderBy('timestamp_seconds', 'asc')
->orderBy('id', 'asc')
->get()
->each(function (MatchPoint $p) use (&$blue, &$red) {
if ($p->competitor === 'blue') $blue += (int) $p->points;
else $red += (int) $p->points;
if ((int) $p->score_blue !== $blue || (int) $p->score_red !== $red) {
$p->update(['score_blue' => $blue, 'score_red' => $red]);
}
});
}
public function updatePoint(Request $request, MatchPoint $point) public function updatePoint(Request $request, MatchPoint $point)
{ {
$request->validate([ $request->validate([
'timestamp_seconds' => 'required|integer|min:0', 'timestamp_seconds' => 'required|numeric|min:0',
'action' => 'required|string|max:255', 'action' => 'required|string|max:255',
'points' => 'required|integer|min:1', 'points' => 'required|integer|min:1',
'competitor' => 'required|in:blue,red', 'competitor' => 'required|in:blue,red',
@@ -158,6 +162,7 @@ class MatchEventController extends Controller
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
} }
$roundId = $point->match_round_id;
$point->update([ $point->update([
'timestamp_seconds' => $request->timestamp_seconds, 'timestamp_seconds' => $request->timestamp_seconds,
'action' => $request->action, 'action' => $request->action,
@@ -166,9 +171,11 @@ class MatchEventController extends Controller
'notes' => $request->notes, 'notes' => $request->notes,
]); ]);
$this->recomputeRoundScores($roundId);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'point' => $point, 'point' => $point->fresh(),
'message' => 'Point updated successfully!', 'message' => 'Point updated successfully!',
]); ]);
} }
@@ -180,7 +187,9 @@ class MatchEventController extends Controller
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
} }
$roundId = $point->match_round_id;
$point->delete(); $point->delete();
$this->recomputeRoundScores($roundId);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@@ -193,11 +202,13 @@ class MatchEventController extends Controller
public function storeReview(Request $request, Video $video) public function storeReview(Request $request, Video $video)
{ {
$request->validate([ $request->validate([
'start_time_seconds' => 'required|integer|min:0', 'start_time_seconds' => 'required|numeric|min:0',
'end_time_seconds' => 'nullable|integer|min:0', 'end_time_seconds' => 'nullable|numeric|min:0',
'note' => 'required|string|max:1000', 'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100', 'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10', 'emoji' => 'nullable|string|max:10',
'position_x' => 'nullable|numeric|between:0,1',
'position_y' => 'nullable|numeric|between:0,1',
]); ]);
// Check if user owns the video // Check if user owns the video
@@ -213,6 +224,8 @@ class MatchEventController extends Controller
'note' => $request->note, 'note' => $request->note,
'coach_name' => $request->coach_name, 'coach_name' => $request->coach_name,
'emoji' => $request->emoji ?? '🔥', 'emoji' => $request->emoji ?? '🔥',
'position_x' => $request->position_x,
'position_y' => $request->position_y,
]); ]);
return response()->json([ return response()->json([
@@ -225,11 +238,13 @@ class MatchEventController extends Controller
public function updateReview(Request $request, CoachReview $review) public function updateReview(Request $request, CoachReview $review)
{ {
$request->validate([ $request->validate([
'start_time_seconds' => 'required|integer|min:0', 'start_time_seconds' => 'required|numeric|min:0',
'end_time_seconds' => 'nullable|integer|min:0', 'end_time_seconds' => 'nullable|numeric|min:0',
'note' => 'required|string|max:1000', 'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100', 'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10', 'emoji' => 'nullable|string|max:10',
'position_x' => 'nullable|numeric|between:0,1',
'position_y' => 'nullable|numeric|between:0,1',
]); ]);
// Check if user owns the video // Check if user owns the video
@@ -243,6 +258,8 @@ class MatchEventController extends Controller
'note' => $request->note, 'note' => $request->note,
'coach_name' => $request->coach_name, 'coach_name' => $request->coach_name,
'emoji' => $request->emoji, 'emoji' => $request->emoji,
'position_x' => $request->position_x,
'position_y' => $request->position_y,
]); ]);
return response()->json([ return response()->json([
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use App\Models\VideoAudioTrack;
use Illuminate\Http\Request;
/**
* Site-wide search across videos, channels and playlists.
*
* Lives on /search?q=… so the URL reads like what you'd expect from a modern
* site. The old /videos/search endpoint still works but redirects here, so
* nothing bookmarked earlier breaks.
*
* Match surface:
* - Video title, description, tag list, primary language and the language
* of any secondary audio track attached to it.
* - User name, username (handle), and short bio.
* - Playlist name and description.
*
* We render the existing `videos.index` view so every search result lands in
* the same card component the browse feeds use — one place to style, one place
* to fix bugs.
*/
class SearchController extends Controller
{
public function index(Request $request)
{
$query = trim((string) $request->get('q', ''));
if ($query === '') {
// No query → send the user back to the browse feed instead of an
// empty "no results" page.
return redirect()->route('videos.index');
}
$like = '%' . $query . '%';
$qLower = mb_strtolower($query);
// ── Videos ────────────────────────────────────────────────
// Match on text columns AND on any audio-track language that aligns
// with the query (e.g. searching "arabic" surfaces videos that carry
// an Arabic track even if the primary language is English).
$trackVideoIds = VideoAudioTrack::whereRaw('LOWER(language) = ?', [$qLower])
->pluck('video_id');
$videoHits = Video::public()
->where(function ($q) use ($like, $qLower, $trackVideoIds) {
$q->where('title', 'like', $like)
->orWhere('description','like', $like)
->orWhere('tags', 'like', $like)
->orWhereRaw('LOWER(language) = ?', [$qLower])
->orWhereIn('id', $trackVideoIds);
})
->latest()
->limit(60)
->get();
$videos = $videoHits->where('is_shorts', false)->values();
$shorts = $videoHits->where('is_shorts', true)->values();
// ── Channels ──────────────────────────────────────────────
$channels = User::where(function ($q) use ($like) {
$q->where('name', 'like', $like)
->orWhere('username','like', $like)
->orWhere('bio', 'like', $like);
})
->orderByDesc('id')
->limit(12)
->get();
// ── Playlists ─────────────────────────────────────────────
$playlists = Playlist::where('visibility', 'public')
->where(function ($q) use ($like) {
$q->where('name', 'like', $like)
->orWhere('description','like', $like);
})
->withCount('videos')
->with(['user', 'videos' => fn ($q) => $q->orderBy('playlist_videos.position')->limit(1)])
->latest()
->limit(12)
->get();
return view('videos.index', [
'videos' => $videos,
'shorts' => $shorts,
'playlists' => $playlists,
'channels' => $channels,
'query' => $query,
'langChips' => [],
]);
}
}
+36 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Controllers\VideoController;
use App\Models\SportsMatch; use App\Models\SportsMatch;
use App\Services\NasSyncService; use App\Services\NasSyncService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -34,6 +35,23 @@ class SportsMatchController extends Controller
$this->handleImages($match, $request, $nas); $this->handleImages($match, $request, $nas);
$match->save(); $match->save();
if ($match->video && $match->video->title !== $match->title) {
$match->video->title = $match->title;
$match->video->save();
}
// Prefetch the OG social-share PNG so WhatsApp/Facebook crawlers
// get the composed VS card on first share, not the fallback frame.
if ($match->video) {
// Blocks the response by ~3–5s while the VS PNG renders. That
// wait is intentional: the user typically shares the URL right
// after saving, and WhatsApp/Facebook cache whatever they see
// on first crawl. If the render hasn't finished when the
// crawler hits, we'd be stuck on the thumbnail fallback in
// every future share.
(new VideoController())->renderMatchOgSync($match->video->fresh(['sportsMatch']));
}
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'message' => 'Match saved as draft.', 'message' => 'Match saved as draft.',
@@ -51,6 +69,21 @@ class SportsMatchController extends Controller
$this->handleImages($sportsMatch, $request, $nas); $this->handleImages($sportsMatch, $request, $nas);
$sportsMatch->save(); $sportsMatch->save();
// Keep the parent Video's display title in sync with the match title
// so the match page header, share metadata, and search results all
// reflect the edited value.
if ($sportsMatch->video && $sportsMatch->video->title !== $sportsMatch->title) {
$sportsMatch->video->title = $sportsMatch->title;
$sportsMatch->video->save();
}
if ($sportsMatch->video) {
// Synchronous re-render on edit: guarantees the fresh VS card
// exists at the new versioned URL before the JSON response
// returns and the user can share the link. See renderMatchOgSync.
(new VideoController())->renderMatchOgSync($sportsMatch->video->fresh(['sportsMatch']));
}
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'message' => 'Match updated.', 'message' => 'Match updated.',
@@ -166,7 +199,9 @@ class SportsMatchController extends Controller
// Media text fields (caption/alt/credit/public). Preserve existing image // Media text fields (caption/alt/credit/public). Preserve existing image
// paths already on the record; handleImages() overwrites any replaced ones. // paths already on the record; handleImages() overwrites any replaced ones.
$existingMedia = $match->media ?? []; $existingMedia = $match->media ?? [];
$mediaText = $this->clean($request->input('media', [])); // clean() returns null when the array is entirely empty — coerce back to []
// so array_merge() doesn't blow up on PHP 8's stricter type checking.
$mediaText = $this->clean($request->input('media', [])) ?? [];
if (isset($mediaText['public'])) { if (isset($mediaText['public'])) {
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN); $mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
} }
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Creator studio — the content management surface YouTube calls "YouTube
* Studio". A skeleton for now: a filterable table of the signed-in user's
* videos plus a bulk visibility change endpoint. Analytics, stepped upload
* and schedule are future work.
*/
class StudioController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
/**
* The studio dashboard — defaults to the content tab.
*/
public function index(Request $request)
{
$user = Auth::user();
$filter = $request->get('filter', 'all');
$query = Video::where('user_id', $user->id)->latest();
switch ($filter) {
case 'public': $query->where('visibility', 'public'); break;
case 'unlisted': $query->where('visibility', 'unlisted'); break;
case 'private': $query->where('visibility', 'private'); break;
case 'shorts': $query->where('is_shorts', true); break;
case 'draft': $query->whereIn('status', ['pending', 'processing', 'failed']); break;
}
$videos = $query->paginate(24)->withQueryString();
// Headline numbers for the top bar.
$totals = [
'total' => Video::where('user_id', $user->id)->count(),
'public' => Video::where('user_id', $user->id)->where('visibility', 'public')->count(),
'unlisted' => Video::where('user_id', $user->id)->where('visibility', 'unlisted')->count(),
'private' => Video::where('user_id', $user->id)->where('visibility', 'private')->count(),
'processing' => Video::where('user_id', $user->id)->whereIn('status', ['pending', 'processing'])->count(),
'failed' => Video::where('user_id', $user->id)->where('status', 'failed')->count(),
'total_views' => \DB::table('video_views')
->whereIn('video_id', Video::where('user_id', $user->id)->pluck('id'))
->count(),
];
return view('studio.index', [
'videos' => $videos,
'filter' => $filter,
'totals' => $totals,
]);
}
/**
* Bulk visibility change. POSTs { video_ids: [...], visibility: '...' }.
* Guards against setting visibility on videos the user doesn't own, so a
* crafted request can't flip anyone else's content.
*/
public function bulkVisibility(Request $request)
{
$request->validate([
'video_ids' => ['required', 'array', 'min:1'],
'video_ids.*' => ['integer'],
'visibility' => ['required', 'in:public,unlisted,private'],
]);
$updated = Video::whereIn('id', $request->video_ids)
->where('user_id', Auth::id())
->update(['visibility' => $request->visibility]);
return response()->json(['updated' => $updated]);
}
/**
* Bulk delete. Same owner-only guard as above.
*/
public function bulkDelete(Request $request)
{
$request->validate([
'video_ids' => ['required', 'array', 'min:1'],
'video_ids.*' => ['integer'],
]);
$deleted = Video::whereIn('id', $request->video_ids)
->where('user_id', Auth::id())
->delete();
return response()->json(['deleted' => $deleted]);
}
}
+19 -8
View File
@@ -188,16 +188,27 @@ class SuperAdminController extends Controller
$recentVideos = Video::with('user')->latest()->take(5)->get(); $recentVideos = Video::with('user')->latest()->take(5)->get();
// ── Storage ─────────────────────────────────────────────── // ── Storage ───────────────────────────────────────────────
$disk = Storage::disk('public'); // NAS is the primary store; walking the local disk here would miss
// every file that's been pushed to NAS and purged locally. We use
// the DB-captured file sizes instead: videos.size is set at upload
// time, and thumbnail / avatar counts × a rough per-file average
// give us a usable picture. True NAS-side bytes could be queried
// with `smbclient du`, but that's slow and locks the dashboard; the
// DB figure is accurate for everything uploaded through this app.
$sizeVideos = (int) Video::sum('size'); // bytes
$thumbnailCount = Video::whereNotNull('thumbnail')->count();
$avatarCount = User::whereNotNull('avatar')->count();
$bannerCount = User::whereNotNull('banner')->count();
$postImageCount = \DB::table('post_images')->count();
$sizeVideos = $this->dirSize($disk, 'videos'); // Images are variable but ~120 KB is a solid conservative estimate
$sizeThumbnails = $this->dirSize($disk, 'thumbnails'); // for a web-sized JPEG/PNG upload.
$sizeAvatars = $this->dirSize($disk, 'avatars'); $sizeImages = ($thumbnailCount + $avatarCount + $bannerCount + $postImageCount) * 120 * 1024;
$sizeImages = $this->dirSize($disk, 'images'); $sizeThumbnails = $thumbnailCount * 120 * 1024;
$totalPublicSize = $sizeVideos + $sizeThumbnails + $sizeAvatars + $sizeImages; $sizeAvatars = $avatarCount * 120 * 1024;
$totalPublicSize = $sizeVideos + $sizeImages;
$videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0; $videosUsagePercent = $totalPublicSize > 0 ? round(($sizeVideos / $totalPublicSize) * 100, 1) : 0;
// Convert to MB
$toMb = fn($b) => round($b / 1024 / 1024, 1); $toMb = fn($b) => round($b / 1024 / 1024, 1);
$storage = [ $storage = [
'videos' => $toMb($sizeVideos), 'videos' => $toMb($sizeVideos),
@@ -506,7 +517,7 @@ class SuperAdminController extends Controller
foreach ($uniqueViewers as $viewer) { foreach ($uniqueViewers as $viewer) {
if (!$viewer->country) continue; if (!$viewer->country) continue;
if (!isset($countryMap[$viewer->country])) { if (!isset($countryMap[$viewer->country])) {
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0]; $countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name ?: \App\Data\Countries::name($viewer->country), 'total' => 0];
} }
$countryMap[$viewer->country]['total']++; $countryMap[$viewer->country]['total']++;
} }
+142 -23
View File
@@ -18,7 +18,7 @@ class UserController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['channel']); $this->middleware('auth')->except(['channel', 'channelVideosMore']);
} }
// Typeahead search for members (used by the "Share to member" picker) // Typeahead search for members (used by the "Share to member" picker)
@@ -246,7 +246,14 @@ class UserController extends Controller
switch ($sort) { switch ($sort) {
case 'popular': case 'popular':
$baseQuery->withCount('viewers')->orderByDesc('viewers_count'); // Views first, likes as the tiebreak, created_at as the final
// tiebreak so two videos with the same view+like counts don't
// flip order between page loads.
$baseQuery
->withCount(['viewers as viewers_count', 'likes as likes_count'])
->orderByDesc('viewers_count')
->orderByDesc('likes_count')
->latest();
break; break;
case 'oldest': case 'oldest':
$baseQuery->oldest(); $baseQuery->oldest();
@@ -262,8 +269,16 @@ class UserController extends Controller
? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get() ? $user->playlists()->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get()
: $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get(); : $user->playlists()->public()->where('is_default', false)->with(['videos' => $withFirstVideo])->orderBy('created_at', 'desc')->get();
// Header stats must not leak unlisted/private video view counts to
// visitors. Owners (and ?preview=1 is handled above by setting
// $isOwner=false) see totals across every video they uploaded;
// visitors see totals across public videos only — matching what
// the videos count row already shows.
$viewVideoIds = $isOwner
? $user->videos()->pluck('id')
: Video::public()->where('user_id', $user->id)->pluck('id');
$totalViews = \DB::table('video_views') $totalViews = \DB::table('video_views')
->whereIn('video_id', $user->videos()->pluck('id')) ->whereIn('video_id', $viewVideoIds)
->count(); ->count();
// Filter social links by visibility for the current viewer // Filter social links by visibility for the current viewer
@@ -310,29 +325,41 @@ class UserController extends Controller
} }
// Watch history // Watch history
public function history() public function history(Request $request)
{ {
$user = Auth::user(); $user = Auth::user();
// Get videos the user has watched, ordered by most recently watched // Latest-first by watched_at. Paginate at the join so a user who
// Include private videos since they are the user's own // has watched tens of thousands of videos doesn't pull every row
$videoIds = \DB::table('video_views') // into memory on each page load. Fetch the videos keyed by latest
->where('user_id', $user->id) // view so sort order is deterministic even when the same video has
->orderBy('watched_at', 'desc') // been watched more than once.
->pluck('video_id') //
->unique(); // Each history row is one (video_views + videos) pair, with the
// uniqueness constraint deferred to presentation: duplicates per
$videos = Video::whereIn('id', $videoIds) // video are expected (one row per view event) and the UI collapses
// them with view-count badges.
$per = 48;
$rows = \DB::table('video_views')
->join('videos', 'videos.id', '=', 'video_views.video_id')
->where('video_views.user_id', $user->id)
->where(function ($q) use ($user) { ->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private') $q->where('videos.visibility', '!=', 'private')
->orWhere('user_id', $user->id); ->orWhere('videos.user_id', $user->id);
}) })
->get() ->orderBy('video_views.watched_at', 'desc')
->sortBy(function ($video) use ($videoIds) { ->select('videos.id as id', 'video_views.watched_at')
return $videoIds->search($video->id); ->simplePaginate($per);
});
return view('user.history', compact('videos')); $videoIds = collect($rows->items())->pluck('id')->unique();
$videoMap = Video::whereIn('id', $videoIds)->get()->keyBy('id');
$videos = collect($rows->items())
->map(fn ($r) => $videoMap->get($r->id))
->filter()
->unique('id') // collapse re-watches of the same video on this page
->values();
return view('user.history', ['videos' => $videos, 'paginator' => $rows]);
} }
// Clear watch history // Clear watch history
@@ -345,18 +372,82 @@ class UserController extends Controller
return redirect()->route('history')->with('toast_success', 'Watch history cleared.'); return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
} }
/**
* JSON endpoint powering the "Load more" button on the channel Videos
* tab. Takes a username + optional cursor and returns a page of
* server-rendered video cards + next cursor. Reuses the cursorPaginate
* pattern Phase 2 established for the home feed.
*/
public function channelVideosMore(Request $request, string $username)
{
$owner = User::where('username', $username)->firstOrFail();
$viewer = Auth::user();
$isOwner = $viewer && $viewer->id === $owner->id;
$query = $isOwner
? Video::where('user_id', $owner->id)
: Video::public()->where('user_id', $owner->id);
$paginator = $query->where('is_shorts', false)
->latest('id')
->cursorPaginate(24);
$html = '';
foreach ($paginator->items() as $video) {
$html .= view('components.video-card', ['video' => $video, 'hideChannelInfo' => true])->render();
}
return response()->json([
'items' => $html,
'next_cursor' => optional($paginator->nextCursor())->encode(),
'has_more' => $paginator->hasMorePages(),
]);
}
/**
* Subscriptions feed — the public videos uploaded by every channel the
* viewer is subscribed to, newest first. Replaces the dead href="#" that
* the sidebar's "Subscriptions" item used to point at. Thin on purpose:
* reuses the existing video-card component via the videos/index view.
*/
public function subscriptionsFeed()
{
$me = Auth::user();
$channelIds = $me->subscriptions()->pluck('users.id');
$videos = Video::public()
->whereIn('user_id', $channelIds)
->notShorts()
->latest()
->limit(60)
->get();
return view('videos.index', [
'videos' => $videos,
'filter' => 'subscriptions',
'langChips' => [],
'shorts' => collect(),
'matches' => collect(),
'playlists' => collect(),
]);
}
// Liked videos // Liked videos
public function liked() public function liked()
{ {
$user = Auth::user(); $user = Auth::user();
// Include private videos in liked (user's own private videos) // Include private videos in liked (user's own private videos).
// Cursor pagination matches the Phase 2 feed endpoints — stable
// across inserts (unlike ?page=N which can duplicate or skip rows
// when a new like lands between page fetches) and loads a bounded
// slice regardless of how many thousands of likes the user has.
$videos = $user->likes() $videos = $user->likes()
->where(function ($q) use ($user) { ->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private') $q->where('visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id); ->orWhere('videos.user_id', $user->id);
}) })
->latest() ->latest('videos.id')
->paginate(12); ->cursorPaginate(24);
return view('user.liked', compact('videos')); return view('user.liked', compact('videos'));
} }
@@ -394,6 +485,9 @@ class UserController extends Controller
} else { } else {
$video->likes()->attach($user->id); $video->likes()->attach($user->id);
$liked = true; $liked = true;
// Toggling to like clears any existing dislike — mirrors YT's
// behavior where a video can be liked OR disliked, not both.
$video->dislikes()->detach($user->id);
if ($video->user_id && $video->user_id !== $user->id) { if ($video->user_id && $video->user_id !== $user->id) {
try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {} try { $video->user->notify(new VideoLikedNotification($video, $user)); } catch (\Throwable) {}
} }
@@ -401,7 +495,32 @@ class UserController extends Controller
return response()->json([ return response()->json([
'liked' => $liked, 'liked' => $liked,
'disliked' => false, // was cleared above if we just liked, otherwise toggle didn't touch it
'like_count' => $video->like_count, 'like_count' => $video->like_count,
'dislike_count' => $video->dislike_count,
]);
}
// Toggle dislike (API). Mutually exclusive with the like state.
public function toggleDislike(Video $video)
{
$user = Auth::user();
if ($video->isDislikedBy($user)) {
$video->dislikes()->detach($user->id);
$disliked = false;
} else {
$video->dislikes()->attach($user->id);
$disliked = true;
// Clear any existing like.
$video->likes()->detach($user->id);
}
return response()->json([
'disliked' => $disliked,
'liked' => $video->isLikedBy($user), // guaranteed false here when disliked just became true
'like_count' => $video->like_count,
'dislike_count' => $video->dislike_count,
]); ]);
} }
File diff suppressed because it is too large. Load diff
+65 -53
View File
@@ -4,8 +4,6 @@ namespace App\Jobs;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Video; use App\Models\Video;
use FFMpeg\FFMpeg;
use FFMpeg\Format\Video\X264;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@@ -13,7 +11,6 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class CompressVideoJob implements ShouldQueue class CompressVideoJob implements ShouldQueue
{ {
@@ -30,7 +27,6 @@ class CompressVideoJob implements ShouldQueue
{ {
$video = $this->video; $video = $this->video;
// Get original file path
$originalPath = storage_path('app/' . $video->path); $originalPath = storage_path('app/' . $video->path);
if (!file_exists($originalPath)) { if (!file_exists($originalPath)) {
@@ -38,71 +34,87 @@ class CompressVideoJob implements ShouldQueue
return; return;
} }
// Create compressed file alongside the original
$compressedFilename = 'compressed_' . $video->filename; $compressedFilename = 'compressed_' . $video->filename;
$compressedPath = dirname($originalPath) . '/' . $compressedFilename; $compressedPath = dirname($originalPath) . '/' . $compressedFilename;
try { try {
$ffmpegConfig = Config::get('ffmpeg'); $ffmpegConfig = Config::get('ffmpeg');
$ffmpeg = FFMpeg::create([ $ffmpegBin = $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg';
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg', $ffprobeBin = $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe';
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
'timeout' => $ffmpegConfig['timeout'] ?? 3600, // Detect the source video codec. Browsers on Chrome/Firefox/Android can't
]); // decode HEVC (H.265/hev1/hvc1) — those sources must be replaced with H.264
$ffmpegVideo = $ffmpeg->open($originalPath); // even if the transcode is larger, so downloads and MP4 fallback stay playable.
$srcCodec = '';
try {
$probeOut = [];
exec(escapeshellcmd($ffprobeBin) . ' -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 ' . escapeshellarg($originalPath), $probeOut);
$srcCodec = strtolower(trim($probeOut[0] ?? ''));
} catch (\Throwable $e) {}
$forceReplace = in_array($srcCodec, ['hevc', 'h265', 'hev1', 'hvc1'], true);
// Verify the GPU is actually reachable and able to encode before sending
// the file to it; otherwise fall back to CPU so the job never hangs.
$gpuEnabled = Setting::gpuUsable(); $gpuEnabled = Setting::gpuUsable();
$encoder = Setting::gpuEncoder(); $encoder = Setting::gpuEncoder(); // h264_nvenc / libx264
$preset = Setting::gpuPreset(); $preset = Setting::gpuPreset(); // p1–p7 for NVENC, fast/medium/slow for x264
$device = Setting::gpuDevice(); $device = Setting::gpuDevice();
$hwaccel = Setting::gpuHwaccel();
if ($gpuEnabled) { // Build ffmpeg command directly — php-ffmpeg's X264 format validator
$videoPasses = [ // only accepts 'libx264' and throws on 'h264_nvenc'.
"-c:v {$encoder}", $cmd = [escapeshellcmd($ffmpegBin), '-y'];
"-preset {$preset}",
'-rc vbr', if ($gpuEnabled && $hwaccel !== 'none') {
'-cq 23', $cmd[] = "-hwaccel {$hwaccel}";
'-profile:v high', $cmd[] = "-hwaccel_device {$device}";
'-pix_fmt yuv420p', }
"-gpu {$device}",
]; $cmd[] = '-i ' . escapeshellarg($originalPath);
$cmd[] = "-c:v {$encoder}";
if ($gpuEnabled && str_contains($encoder, 'nvenc')) {
$cmd[] = "-preset {$preset}";
$cmd[] = '-rc vbr';
$cmd[] = '-cq 23';
$cmd[] = "-gpu {$device}";
} else { } else {
$videoPasses = [ $cmd[] = "-preset {$preset}";
'-c:v libx264', $cmd[] = '-crf 23';
'-preset fast',
'-crf 23',
'-profile:v high',
'-pix_fmt yuv420p',
];
} }
$audioPasses = ['-c:a aac', '-b:a 192k'];
$format = new X264('aac', $encoder); $cmd[] = '-profile:v high';
foreach ($videoPasses as $pass) { $cmd[] = '-pix_fmt yuv420p';
$format->addLegacyOption($pass); $cmd[] = '-c:a aac';
} $cmd[] = '-b:a 192k';
foreach ($audioPasses as $pass) { $cmd[] = '-movflags +faststart';
$format->addLegacyOption($pass); $cmd[] = escapeshellarg($compressedPath);
}
$ffmpegVideo->save($format, $compressedPath); $fullCmd = implode(' ', $cmd) . ' 2>&1';
Log::info('CompressVideoJob: Starting', [
'video_id' => $video->id,
'src_codec' => $srcCodec,
'encoder' => $encoder,
'gpu' => $gpuEnabled,
]);
$output = [];
exec($fullCmd, $output, $exitCode);
if ($exitCode !== 0) {
$tail = implode("\n", array_slice($output, -30));
throw new \RuntimeException("FFmpeg exited {$exitCode}:\n{$tail}");
}
// Check if compressed file was created and is smaller
if (file_exists($compressedPath)) { if (file_exists($compressedPath)) {
$originalSize = filesize($originalPath); $originalSize = filesize($originalPath);
$compressedSize = filesize($compressedPath); $compressedSize = filesize($compressedPath);
// Only use compressed file if it's smaller if ($compressedSize < $originalSize || $forceReplace) {
if ($compressedSize < $originalSize) {
// Delete original and rename compressed
unlink($originalPath); unlink($originalPath);
rename($compressedPath, $originalPath); rename($compressedPath, $originalPath);
// Update video record
$video->update([ $video->update([
'size' => $compressedSize, 'size' => $compressedSize,
'filename' => $video->filename, // Keep same filename
'mime_type' => 'video/mp4', 'mime_type' => 'video/mp4',
]); ]);
@@ -110,26 +122,26 @@ class CompressVideoJob implements ShouldQueue
'video_id' => $video->id, 'video_id' => $video->id,
'original_size' => $originalSize, 'original_size' => $originalSize,
'compressed_size' => $compressedSize, 'compressed_size' => $compressedSize,
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%', 'saved' => round(($originalSize - $compressedSize) / max(1, $originalSize) * 100) . '%',
'encoder' => $encoder, 'encoder' => $encoder,
'gpu' => $gpuEnabled, 'gpu' => $gpuEnabled,
'src_codec' => $srcCodec,
'forced' => $forceReplace && $compressedSize >= $originalSize,
]); ]);
} else { } else {
// Compressed file is larger, delete it
unlink($compressedPath); unlink($compressedPath);
Log::info('CompressVideoJob: Compression made file larger, keeping original'); Log::info('CompressVideoJob: Compression made file larger, keeping original', ['src_codec' => $srcCodec]);
} }
} }
$video->update(['status' => 'ready']); $video->update(['status' => 'ready']);
// Chain to HLS generation for GPU-accelerated adaptive playback
\App\Jobs\GenerateHlsJob::dispatch($video); \App\Jobs\GenerateHlsJob::dispatch($video);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('CompressVideoJob failed: ' . $e->getMessage()); Log::error('CompressVideoJob failed: ' . $e->getMessage());
$video->update(['status' => 'ready']); // Mark as ready anyway if (file_exists($compressedPath)) @unlink($compressedPath);
$video->update(['status' => 'ready']);
} }
} }
} }
+9
View File
@@ -18,6 +18,15 @@ class CoachReview extends Model
'note', 'note',
'coach_name', 'coach_name',
'emoji', 'emoji',
'position_x',
'position_y',
];
protected $casts = [
'position_x' => 'float',
'position_y' => 'float',
'start_time_seconds' => 'float',
'end_time_seconds' => 'float',
]; ];
public function video(): BelongsTo public function video(): BelongsTo
+7
View File
@@ -11,6 +11,13 @@ class Comment extends Model
'user_id', 'user_id',
'parent_id', 'parent_id',
'body', 'body',
'hearted',
'pinned',
];
protected $casts = [
'hearted' => 'boolean',
'pinned' => 'boolean',
]; ];
// Relationships // Relationships
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LiveChatMessage extends Model
{
protected $table = 'live_chat_messages';
public $timestamps = false; // explicit created_at only, set via useCurrent()
protected $fillable = [
'livestream_id',
'user_id',
'author_name',
'body',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function livestream()
{
return $this->belongsTo(LiveStream::class, 'livestream_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* A live broadcast.
*
* The row is the authority on whether a stream may exist; the media server
* holds no state of its own and asks this table (through LiveAuthController)
* before it accepts a publisher or a viewer.
*/
class LiveStream extends Model
{
use HasFactory;
protected $table = 'livestreams';
protected $fillable = [
'user_id', 'public_id', 'title', 'description',
'status', 'visibility', 'video_id',
];
/**
* The publish token is a credential. Keeping it out of the default array
* form means it cannot be leaked by a careless toJson() on a model that
* reached a view or an API response.
*/
protected $hidden = ['publish_token', 'publish_token_expires_at'];
protected $casts = [
'publish_token_expires_at' => 'datetime',
'started_at' => 'datetime',
'ended_at' => 'datetime',
'last_seen_at' => 'datetime',
];
public const STATUS_IDLE = 'idle';
public const STATUS_LIVE = 'live';
public const STATUS_ENDED = 'ended';
public const STATUS_FAILED = 'failed';
/** How long a freshly minted publish token stays valid. */
public const PUBLISH_TOKEN_TTL_SECONDS = 120;
protected static function booted(): void
{
// Every stream gets an unpredictable public id, always — never left to
// a caller to remember.
static::creating(function (self $stream) {
$stream->public_id ??= self::newPublicId();
});
}
/* ---------------- Identity ---------------- */
/**
* 32 chars of alphanumeric entropy. Also the media path segment, which is
* why it is constrained to [a-zA-Z0-9] — it has to satisfy the path regex
* the media server is configured with.
*/
public static function newPublicId(): string
{
do {
$id = Str::random(32);
} while (self::where('public_id', $id)->exists());
return $id;
}
/** Route model binding uses the unpredictable id, never the numeric key. */
public function getRouteKeyName(): string
{
return 'public_id';
}
/** The path the media server knows this stream by. */
public function mediaPath(): string
{
return 'live/'.$this->public_id;
}
/* ---------------- Relations ---------------- */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** The recording, once a finished stream has been published as a video. */
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
/* ---------------- State ---------------- */
public function isLive(): bool
{
return $this->status === self::STATUS_LIVE;
}
public function isOwnedBy(?User $user): bool
{
return $user !== null && $this->user_id === $user->id;
}
/**
* May this viewer watch?
*
* Both public and unlisted streams are watchable by anyone holding the
* link — the difference is only whether the stream is listed. Ownership
* additionally grants access before it goes live, so the broadcaster can
* open their own watch page to check themselves.
*/
public function isWatchableBy(?User $user): bool
{
if ($this->isOwnedBy($user)) {
return true;
}
// Visibility first — private streams stay owner-only no matter what.
if (! in_array($this->visibility, ['public', 'unlisted'], true)) {
return false;
}
// Live streams are watchable while they're on; ended streams remain
// permalinked so viewers see "Stream ended" and the replay CTA
// instead of a hard 404.
return $this->isLive() || $this->status === self::STATUS_ENDED;
}
/* ---------------- Publish credential ---------------- */
/**
* Mint a short-lived, single-use token for the broadcaster's browser.
*
* Returned in plaintext to the owner exactly once, at the moment they press
* "Go live". Stored so the media server's auth hook can verify it, then
* consumed on first successful publish.
*/
public function issuePublishToken(): string
{
$token = Str::random(64);
$this->forceFill([
'publish_token' => hash('sha256', $token),
'publish_token_expires_at' => now()->addSeconds(self::PUBLISH_TOKEN_TTL_SECONDS),
])->save();
return $token;
}
/** Constant-time check of a presented token against the stored hash. */
public function publishTokenMatches(?string $presented): bool
{
if (! $presented || ! $this->publish_token || ! $this->publish_token_expires_at) {
return false;
}
if ($this->publish_token_expires_at->isPast()) {
return false;
}
return hash_equals($this->publish_token, hash('sha256', $presented));
}
/** Burn the token so a captured one cannot be replayed. */
public function consumePublishToken(): void
{
$this->forceFill([
'publish_token' => null,
'publish_token_expires_at' => null,
])->save();
}
/* ---------------- Transitions ---------------- */
public function markLive(): void
{
$this->forceFill([
'status' => self::STATUS_LIVE,
'started_at' => $this->started_at ?? now(),
'last_seen_at' => now(),
'ended_at' => null,
])->save();
}
/**
* Put an ended stream back into a startable state.
*
* A stream is a reusable channel, not a one-shot: the owner keeps the same
* link and can go live on it again. Without this, any failed start — a lost
* signal, a refused camera, a server hiccup — would kill the stream
* permanently and force a new link on every retry.
*/
public function reArm(): void
{
if ($this->status !== self::STATUS_ENDED && $this->status !== self::STATUS_FAILED) {
return;
}
$this->forceFill([
'status' => self::STATUS_IDLE,
'ended_at' => null,
'current_viewers' => 0,
])->save();
}
public function markEnded(): void
{
$this->forceFill([
'status' => self::STATUS_ENDED,
'ended_at' => now(),
'current_viewers' => 0,
'publish_token' => null,
'publish_token_expires_at' => null,
])->save();
}
/* ---------------- Display ---------------- */
public function getDurationSecondsAttribute(): ?int
{
if (! $this->started_at) {
return null;
}
return ($this->ended_at ?? Carbon::now())->diffInSeconds($this->started_at);
}
public function getFormattedDurationAttribute(): string
{
$seconds = $this->duration_seconds;
if ($seconds === null) {
return '00:00';
}
return $seconds >= 3600
? sprintf('%d:%02d:%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60), $seconds % 60)
: sprintf('%02d:%02d', intdiv($seconds, 60), $seconds % 60);
}
/* ---------------- Scopes ---------------- */
public function scopeLive($query)
{
return $query->where('status', self::STATUS_LIVE);
}
public function scopeListed($query)
{
return $query->where('visibility', 'public');
}
}
+4
View File
@@ -22,6 +22,10 @@ class MatchPoint extends Model
'score_red', 'score_red',
]; ];
protected $casts = [
'timestamp_seconds' => 'float',
];
public function video(): BelongsTo public function video(): BelongsTo
{ {
return $this->belongsTo(Video::class); return $this->belongsTo(Video::class);
+1 -1
View File
@@ -19,7 +19,7 @@ class MatchRound extends Model
]; ];
protected $casts = [ protected $casts = [
'start_time_seconds' => 'integer', 'start_time_seconds' => 'float',
]; ];
public function video(): BelongsTo public function video(): BelongsTo
+39 -12
View File
@@ -44,31 +44,58 @@ class SportsMatch extends Model
$p = $this->participants ?? []; $p = $this->participants ?? [];
$c = $this->competition ?? []; $c = $this->competition ?? [];
$v = $this->venue ?? []; $v = $this->venue ?? [];
$m = $this->media ?? [];
$trim = fn($x) => (is_string($x) && trim($x) !== '') ? trim($x) : null;
return [ return [
'sport' => $trim($this->sport),
'blue' => [ 'blue' => [
'name' => $this->participant1_name ?: null, 'name' => $trim($this->participant1_name),
'club' => $p['p1_club'] ?? null, 'club' => $trim($p['p1_club'] ?? null),
'flag' => $this->flagFor($p['p1_country'] ?? null), 'flag' => $this->flagFor($p['p1_country'] ?? null),
// Uploader form (sports-match modal) stores these under the
// canonical media.* keys — mirror them to headerData() so the
// scoreboard/VS partials get real image paths.
'club_logo' => $trim($m['club1_logo'] ?? $m['p1_club_logo'] ?? null),
'headshot' => $trim($m['participant1_photo'] ?? $m['p1_headshot'] ?? null),
], ],
'red' => [ 'red' => [
'name' => $this->participant2_name ?: null, 'name' => $trim($this->participant2_name),
'club' => $p['p2_club'] ?? null, 'club' => $trim($p['p2_club'] ?? null),
'flag' => $this->flagFor($p['p2_country'] ?? null), 'flag' => $this->flagFor($p['p2_country'] ?? null),
'club_logo' => $trim($m['club2_logo'] ?? $m['p2_club_logo'] ?? null),
'headshot' => $trim($m['participant2_photo'] ?? $m['p2_headshot'] ?? null),
], ],
'weight_category' => $p['weight_class'] ?? null, 'weight_category' => $trim($p['weight_class'] ?? null),
'championship' => $c['championship_name'] ?? ($this->event_name ?: null), 'division' => $trim($c['division'] ?? null),
'match_number' => $c['match_number'] ?? null, // Sub-header "stage" shown between the two yellow lines above
'court' => $c['court'] ?? null, // the weight class (e.g. "Finals", "SEMI FINAL"). Sourced from
// the SportsMatch->title column — the form's "Match title" input.
'stage' => $trim($this->title),
// "Event title" = the championship / tournament name shown above the
// fighters. Prefer the SportsMatch->event_name column (the field the
// uploader labels "Event title"), fall back to competition.championship_name.
'championship' => $trim($this->event_name) ?? $trim($c['championship_name'] ?? null),
'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null),
'court' => $trim($c['court'] ?? null),
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …
'match_date' => $this->match_date?->format('Y-m-d'),
'referee' => [ 'referee' => [
'name' => $this->referee_name ?: null, 'name' => $trim($this->referee_name),
'flag' => $this->flagFor($p['referee_country'] ?? null), 'flag' => $this->flagFor($p['referee_country'] ?? null),
], ],
'venue' => [ 'venue' => [
'name' => $this->venue_name ?: ($v['name'] ?? null), 'name' => $trim($this->venue_name) ?? $trim($v['name'] ?? null),
'map_link' => $v['map_link'] ?? null, 'map_link' => $trim($v['map_link'] ?? null),
], ],
'event_logo' => $this->media['event_poster'] ?? null, // rel path or null 'event_logo' => $trim($m['event_poster'] ?? null),
/*
* Per-video overlay defaults set by the uploader. Shape:
* ['scorebar'=>bool, 'feed'=>bool, 'matchInfo'=>bool, 'timeline'=>bool,
* 'clubs'=>bool, 'flags'=>bool, 'penalties'=>bool, 'cornerLabels'=>'auto|redblue|karate|taekwondo']
* Anything not set falls back to the app defaults in the JS.
*/
'scoreboard_defaults' => is_array($c['scoreboard'] ?? null) ? $c['scoreboard'] : [],
]; ];
} }
+112
View File
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
'password' => 'hashed', 'password' => 'hashed',
'two_factor_enabled' => 'boolean', 'two_factor_enabled' => 'boolean',
'notification_preferences' => 'array', 'notification_preferences' => 'array',
// TAKEONE account link. The token is a live credential for that
// platform, so it is encrypted at rest and never sent back to a browser.
'takeone_token' => 'encrypted',
'takeone_connected_at' => 'datetime',
]; ];
/**
* Can this user run TAKEONE lookups?
*
* A super admin always can: the platform holds a TAKEONE super-admin token
* on their behalf, so they are connected with full reach without ever
* pasting anything. Everyone else links their own account, and sees only
* what that account may see.
*/
public function takeoneLinked(): bool
{
return $this->takeoneToken() !== null;
}
/** True when the connection is the platform's, not one this user pasted. */
public function takeoneManagedLink(): bool
{
$own = $this->takeone_token;
return ($own === null || $own === '')
&& $this->isSuperAdmin()
&& ! empty(config('services.takeone.admin_token'));
}
/**
* The token to talk to TAKEONE with. A personally linked account always
* wins, so a super admin who deliberately links their own identity is not
* silently upgraded back to the platform credential.
*/
public function takeoneToken(): ?string
{
$own = $this->takeone_token;
if ($own !== null && $own !== '') {
return $own;
}
if ($this->isSuperAdmin()) {
$platform = config('services.takeone.admin_token');
return ! empty($platform) ? $platform : null;
}
return null;
}
// Defaults for each preference key (true = on, false = off) // Defaults for each preference key (true = on, false = off)
private const NOTIF_DEFAULTS = [ private const NOTIF_DEFAULTS = [
'notif_new_comment' => true, 'notif_new_comment' => true,
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
}); });
} }
/**
* Resolve the most-frequent ISO2 country for each user id, aggregating
* video_views + profile_visits and falling back to users.nationality.
* Returns [user_id => 'BH', …]; keys without data are omitted.
*/
public static function topCountriesFor(array $userIds): array
{
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
if (! $userIds) return [];
$tally = [];
$rows = DB::table('video_views')
->selectRaw('user_id, country, COUNT(*) as c')
->whereIn('user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
if (Schema::hasTable('profile_visits')) {
$rows = DB::table('profile_visits')
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
->whereIn('visitor_user_id', $userIds)
->whereNotNull('country')->where('country', '!=', '')
->groupBy('visitor_user_id', 'country')->get();
foreach ($rows as $r) {
$cc = strtoupper($r->country);
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
}
}
$out = [];
foreach ($tally as $uid => $counts) {
arsort($counts);
$out[$uid] = array_key_first($counts);
}
// Fallback to users.nationality for anyone still unresolved.
$missing = array_values(array_diff($userIds, array_keys($out)));
if ($missing) {
$nats = static::whereIn('id', $missing)
->whereNotNull('nationality')->where('nationality', '!=', '')
->pluck('nationality', 'id');
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
}
return $out;
}
public static function generateUniqueUsername(string $name): string public static function generateUniqueUsername(string $name): string
{ {
$base = substr(Str::slug(trim($name)), 0, 18); $base = substr(Str::slug(trim($name)), 0, 18);
@@ -181,7 +281,19 @@ class User extends Authenticatable implements MustVerifyEmail
public function sendEmailVerificationNotification(): void public function sendEmailVerificationNotification(): void
{ {
// Mail failures must never bubble up as a 500 to the user — the account
// exists, the HTTP request was fine, only the delivery pipe is broken.
// We log the failure (ops picks it up) and move on. Re-sending via
// `php artisan app:resend-verification` fixes the user once SMTP recovers.
try {
$this->notify(new VerifyEmail); $this->notify(new VerifyEmail);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('verify-email send failed', [
'user_id' => $this->id,
'email' => $this->email,
'error' => $e->getMessage(),
]);
}
} }
// Role helper methods // Role helper methods
+19
View File
@@ -53,6 +53,11 @@ class Video extends Model
return $this->belongsToMany(User::class, 'video_likes')->withTimestamps(); return $this->belongsToMany(User::class, 'video_likes')->withTimestamps();
} }
public function dislikes()
{
return $this->belongsToMany(User::class, 'video_dislikes')->withTimestamps();
}
public function viewers() public function viewers()
{ {
return $this->belongsToMany(User::class, 'video_views') return $this->belongsToMany(User::class, 'video_views')
@@ -177,6 +182,20 @@ class Video extends Model
return $this->likes()->count(); return $this->likes()->count();
} }
// Dislike helpers — same shape as the like pair above.
public function isDislikedBy($user)
{
if (! $user) {
return false;
}
return $this->dislikes()->where('user_id', $user->id)->exists();
}
public function getDislikeCountAttribute()
{
return $this->dislikes()->count();
}
// Get view count - use direct query to avoid timestamp issues // Get view count - use direct query to avoid timestamp issues
public function getViewCountAttribute() public function getViewCountAttribute()
{ {
@@ -0,0 +1,51 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
/**
* Fires to every super admin when the same email or IP has produced several
* failed login attempts in a short window. Lets ops decide whether to lock
* the account, change the password, or just raise the global throttle.
*/
class BruteForceLoginAlert extends Notification
{
use Queueable;
public function __construct(
public readonly string $email,
public readonly string $ip,
public readonly int $failures,
public readonly int $windowMinutes,
) {}
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
public function toArray(object $notifiable): array
{
return [
'kind' => 'brute_force_login_alert',
'email' => $this->email,
'ip' => $this->ip,
'failures' => $this->failures,
'window_minutes' => $this->windowMinutes,
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('TAKEONE — suspicious login attempts')
->greeting('Possible brute-force login attempt')
->line("The address {$this->email} has had {$this->failures} failed login attempts in the last {$this->windowMinutes} minutes.")
->line("Most recent attempt came from {$this->ip}.")
->line('Review the audit log and consider forcing a password reset if this account looks real.')
->action('Open Admin', url('/admin/audit-logs'));
}
}
+5 -1
View File
@@ -26,8 +26,12 @@ class AppServiceProvider extends ServiceProvider
\Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB \Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB
); );
// Force HTTPS // Force HTTPS unless we're running under phpunit — the test client
// speaks plain http://localhost; forcing https would make every POST
// in a feature test resolve to a 302 instead of hitting the controller.
if (! $this->app->environment('testing')) {
URL::forceScheme('https'); URL::forceScheme('https');
}
// Universal pagination view — used everywhere by default // Universal pagination view — used everywhere by default
Paginator::defaultView('partials.pagination'); Paginator::defaultView('partials.pagination');
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* Blocks impersonation of staff/system accounts.
*
* Applied to both `name` and the email local-part at registration so a user
* cannot sign up as "admin", "superadmin", "takeone", etc. — words that would
* let them pass themselves off as the platform or its operators to other
* users (comments, DMs, channel pages).
*/
class ReservedIdentifier implements ValidationRule
{
private const RESERVED = [
'admin', 'administrator', 'superadmin', 'super-admin', 'super_admin',
'root', 'sysadmin', 'system', 'owner', 'webmaster', 'postmaster',
'moderator', 'mod', 'staff', 'official',
'support', 'help', 'helpdesk', 'contact', 'info', 'noreply', 'no-reply',
'security', 'abuse', 'billing',
'takeone', 'take-one', 'take_one', 'takeonebh', 'takeone-bh',
'play', 'playbh', 'playtakeone',
'anonymous', 'guest', 'null', 'undefined',
];
public function __construct(private readonly string $kind = 'name')
{
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
$candidate = $this->kind === 'email'
? strtolower(trim(strstr($value, '@', true) ?: $value))
: strtolower(trim($value));
// Collapse separators so "super_admin", "super-admin" and "superadmin" all match.
$normalized = preg_replace('/[\s._\-]+/', '', $candidate) ?? $candidate;
foreach (self::RESERVED as $word) {
$target = preg_replace('/[\s._\-]+/', '', $word) ?? $word;
if ($normalized === $target) {
$fail($this->kind === 'email'
? 'That email address is reserved. Please use a different one.'
: 'That name is reserved. Please choose a different one.');
return;
}
}
}
}
+185 -11
View File
@@ -191,16 +191,176 @@ class LlmLyricsService
} }
/**
* Translate arbitrary text (e.g. a user comment) to a target language.
* Returns the translated string, or '' on any failure / when disabled.
*/
public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string
{
$text = trim($text);
if ($text === '' || ! $this->isEnabled()) return '';
$provider = $this->activeProvider();
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
$cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text);
$cached = Cache::get($cacheKey);
if (is_string($cached) && $cached !== '') return $cached;
$hint = $sourceLangHint
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n"
: '';
$prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n"
. $hint
. "Rules:\n"
. " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n"
. " - Translate EVERY word of the source language, including short exclamations,\n"
. " repeated words, and words that happen to also look like a name in the target\n"
. " language. Example: Portuguese \"Linda, linda, linda\" → \"Beautiful, beautiful,\n"
. " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" → \"hello\".\n"
. " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n"
. " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n"
. " it is almost certainly a regular word — translate it.\n"
. " - If the text is already in the target language, repeat it unchanged.\n"
. " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n"
. " like @1:23 or @01:23-02:45 EXACTLY as written.\n"
. " - Keep the same tone and line breaks.\n\n"
. "TEXT:\n" . $text;
// Translation is deterministic — temp 0. If the model echoes the input
// (common when a word happens to also exist as a name in the target
// language, e.g. Portuguese "Linda" → English "Linda"), retry once
// with a stronger directive. If the retry also echoes, we return it.
// Comments cap at 1000 chars → 400 tokens is plenty of headroom and
// keeps the model from drifting into long completions on tiny inputs.
$budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64));
$raw = $this->postProcess($this->call($prompt, $budget, 0.0));
$needsRetry = $sourceLangHint
&& $sourceLangHint !== $targetLang
&& self::looksLikeEcho($text, $raw);
if ($needsRetry) {
$retryPrompt = $prompt
. "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n"
. "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
$retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0));
if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry;
}
if ($raw === '') return '';
Cache::put($cacheKey, $raw, now()->addDays(30));
return $raw;
}
private function postProcess(string $raw): string
{
$raw = trim($raw);
$raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw));
return $raw;
}
/** Normalised equality: ignore case, whitespace, punctuation, emojis. */
private static function looksLikeEcho(string $src, string $out): bool
{
if ($out === '') return false;
$norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
$a = $norm($src); $b = $norm($out);
return $a !== '' && $a === $b;
}
/**
* Translate a sequence of lines, preserving the one-to-one mapping so the
* caller can render them side-by-side with the originals. Empty entries
* are kept as empty strings. Returns an array of the same length as $lines
* (or [] on any failure / when disabled).
*/
public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array
{
if (! $lines || ! $this->isEnabled()) return [];
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
$provider = $this->activeProvider();
$cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines));
$cached = Cache::get($cacheKey);
if (is_array($cached) && count($cached) === count($lines)) return $cached;
// Index non-empty lines so empty separators are preserved verbatim.
$toTranslate = [];
foreach ($lines as $i => $l) {
if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l;
}
if (! $toTranslate) {
Cache::put($cacheKey, $lines, now()->addDays(30));
return $lines;
}
$numbered = [];
foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l;
$hint = $sourceLangHint
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n"
: '';
$prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n"
. $hint
. "Rules:\n"
. " - Output a JSON object mapping each original index (as string) to its translated line.\n"
. " - Keep ONE output line per input line — do NOT merge, split, re-order, or drop any.\n"
. " - Translate every word including short exclamations and repeated words that happen\n"
. " to look like a name in the target language (Portuguese \"Linda\" → \"Beautiful\").\n"
. " - If a line is already in the target language, repeat it unchanged.\n"
. " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n"
. " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n"
. "LINES (index TAB text):\n" . implode("\n", $numbered);
$raw = $this->postProcess($this->call($prompt, 8192, 0.0));
$obj = json_decode($raw, true);
if (! is_array($obj)) return [];
$out = $lines;
$echoCount = 0;
$translatedCount = 0;
foreach ($toTranslate as $i => $src) {
$v = $obj[(string) $i] ?? ($obj[$i] ?? null);
if (is_string($v) && trim($v) !== '') {
$out[$i] = $v;
$translatedCount++;
if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) {
$echoCount++;
}
}
}
// If > 30% of the translated lines just echo the source, nudge the model.
if ($sourceLangHint && $sourceLangHint !== $targetLang
&& $translatedCount > 0 && $echoCount / $translatedCount > 0.3) {
$retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. "
. "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
$raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0));
$obj2 = json_decode($raw2, true);
if (is_array($obj2)) {
foreach ($toTranslate as $i => $src) {
$v = $obj2[(string) $i] ?? ($obj2[$i] ?? null);
if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) {
$out[$i] = $v;
}
}
}
}
Cache::put($cacheKey, $out, now()->addDays(30));
return $out;
}
/** Dispatch to the active provider's adapter. */ /** Dispatch to the active provider's adapter. */
private function call(string $prompt, int $maxTokens): string private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string
{ {
$p = $this->activeProvider(); $p = $this->activeProvider();
if (! $p) return ''; if (! $p) return '';
try { try {
return match ($p['kind']) { return match ($p['kind']) {
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens), 'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
'openai' => $this->callOpenAI($p, $prompt, $maxTokens), 'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
default => $this->callOllama($p, $prompt, $maxTokens), default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
}; };
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']); Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
@@ -208,24 +368,37 @@ class LlmLyricsService
} }
} }
private function callOllama(array $p, string $prompt, int $maxTokens): string private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
{ {
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/'); $endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
// Qwen3-style "thinking" models respect /no_think to skip the hidden
// reasoning phase. Without it they can exhaust num_predict inside
// the thinking block and emit empty content.
$taggedPrompt = $prompt . "\n\n/no_think";
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [ $resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
'model' => $p['model'], 'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]], 'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
'stream' => false, 'stream' => false,
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2], 'think' => false,
// Keep the model resident for an hour so follow-up requests skip
// the cold-start reload (default is 5 minutes). Dramatically
// reduces per-click latency for comment/description translation.
'keep_alive' => '1h',
'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature],
]); ]);
if (! $resp->successful()) { if (! $resp->successful()) {
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
return ''; return '';
} }
$j = $resp->json(); $j = $resp->json();
return (string) ($j['message']['content'] ?? ''); $content = (string) ($j['message']['content'] ?? '');
// Some thinking models still ignore the flag and leak <think>…</think>
// tags into content. Strip them.
$content = preg_replace('/<think>.*?<\/think>\s*/is', '', $content) ?? $content;
return trim($content);
} }
private function callAnthropic(array $p, string $prompt, int $maxTokens): string private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
{ {
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/'); $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
$resp = Http::timeout(120)->withHeaders([ $resp = Http::timeout(120)->withHeaders([
@@ -235,6 +408,7 @@ class LlmLyricsService
])->post($endpoint . '/v1/messages', [ ])->post($endpoint . '/v1/messages', [
'model' => $p['model'], 'model' => $p['model'],
'max_tokens' => $maxTokens, 'max_tokens' => $maxTokens,
'temperature' => $temperature,
'messages' => [['role' => 'user', 'content' => $prompt]], 'messages' => [['role' => 'user', 'content' => $prompt]],
]); ]);
if (! $resp->successful()) { if (! $resp->successful()) {
@@ -245,7 +419,7 @@ class LlmLyricsService
return (string) ($j['content'][0]['text'] ?? ''); return (string) ($j['content'][0]['text'] ?? '');
} }
private function callOpenAI(array $p, string $prompt, int $maxTokens): string private function callOpenAI(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
{ {
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/'); $endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
$resp = Http::timeout(120)->withToken((string) $p['api_key']) $resp = Http::timeout(120)->withToken((string) $p['api_key'])
@@ -254,7 +428,7 @@ class LlmLyricsService
'model' => $p['model'], 'model' => $p['model'],
'messages' => [['role' => 'user', 'content' => $prompt]], 'messages' => [['role' => 'user', 'content' => $prompt]],
'max_tokens' => $maxTokens, 'max_tokens' => $maxTokens,
'temperature' => 0.2, 'temperature' => $temperature,
]); ]);
if (! $resp->successful()) { if (! $resp->successful()) {
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]); Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
+109 -21
View File
@@ -90,9 +90,15 @@ class VideoMergeService
DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) { DB::transaction(function () use ($source, $target, $sourceDir, $targetDir) {
// ── 1. Promote source's primary audio to a new track on target ─ // ── 1. Promote source's primary audio to a new track on target ─
$srcPrimaryExt = pathinfo($source->filename ?: 'audio.mp3', PATHINFO_EXTENSION) ?: 'mp3'; // Layout-aware: the source may be stored either as the modern
// per-track folder ({sourceDir}/tracks/{srcLang}-{srcVideoId}/…)
// or as the legacy flat layout ({sourceDir}/audio.ext,
// {sourceDir}/slides/, …). We detect which from source->path
// and move only what actually exists in each case.
$srcPrimaryPath = (string) $source->path;
$srcPrimaryFolder = $srcPrimaryPath !== '' ? dirname($srcPrimaryPath) : $sourceDir;
$srcPrimaryExt = pathinfo($source->filename ?: ($srcPrimaryPath ?: 'audio.mp3'), PATHINFO_EXTENSION) ?: 'mp3';
$srcPrimaryFilename = "audio.{$srcPrimaryExt}"; $srcPrimaryFilename = "audio.{$srcPrimaryExt}";
$srcPrimaryOldFolder = "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, null);
$newPrimaryTrack = VideoAudioTrack::create([ $newPrimaryTrack = VideoAudioTrack::create([
'video_id' => $target->id, 'video_id' => $target->id,
@@ -108,43 +114,83 @@ class VideoMergeService
} }
$newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack); $newPrimaryFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($target, $newPrimaryTrack);
$this->moveFolder($srcPrimaryOldFolder, $newPrimaryFolder); // Case A: primary audio sits in a dedicated per-track folder → one
// folder move brings the audio, its slides and its thumb along.
// Case B: primary audio sits directly at the video root (legacy) →
// move the individual files, since the video root also holds the
// extra tracks' subfolders which stay put until step 2.
if ($srcPrimaryFolder !== $sourceDir && str_starts_with($srcPrimaryFolder, "{$sourceDir}/tracks/")) {
$this->moveFolder($srcPrimaryFolder, $newPrimaryFolder);
} else {
$this->moveFile($srcPrimaryPath, "{$newPrimaryFolder}/{$srcPrimaryFilename}");
// Legacy slides live at {sourceDir}/slides/ as a sibling of audio.
if (is_dir(storage_path('app/' . "{$sourceDir}/slides"))) {
$this->moveFolder("{$sourceDir}/slides", "{$newPrimaryFolder}/slides");
}
// Opportunistically move thumb.{ext} if present.
foreach (['jpg', 'jpeg', 'png', 'webp'] as $ext) {
$thumb = "{$sourceDir}/thumb.{$ext}";
if (file_exists(storage_path('app/' . $thumb))) {
$this->moveFile($thumb, "{$newPrimaryFolder}/thumb.{$ext}");
}
}
// Legacy primary lyrics live at {sourceDir}/lyrics/primary.json.
// The new per-track layout stores lyrics at
// {newPrimaryFolder}/lyrics.json — move the file so lyricsNasPath
// on the target resolves it immediately.
$legacyPrimaryLyrics = "{$sourceDir}/lyrics/primary.json";
if (file_exists(storage_path('app/' . $legacyPrimaryLyrics))) {
$this->moveFile($legacyPrimaryLyrics, "{$newPrimaryFolder}/lyrics.json");
}
}
$newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]); $newPrimaryTrack->update(['path' => "{$newPrimaryFolder}/{$srcPrimaryFilename}"]);
// ── 2. Move each source extra track's folder into target/tracks/ // ── 2. Move each source extra track's folder into target/tracks/
// The folder basename ({lang}-{track_id}) is globally unique because // The folder basename ({lang}-{track_id}) is globally unique
// track ids are globally unique — no collisions with target's own tracks. // because track ids are globally unique — no collisions with
// target's own tracks. We derive the actual old folder from
// the track's stored path so unusual layouts still work.
foreach ($source->audioTracks as $track) { foreach ($source->audioTracks as $track) {
$basename = $this->nas->trackFolderName($source, $track); $oldFolder = $track->path ? dirname($track->path) : "{$sourceDir}/tracks/" . $this->nas->trackFolderName($source, $track);
$oldFolder = "{$sourceDir}/tracks/{$basename}"; $newFolder = "{$targetDir}/tracks/" . $this->nas->trackFolderName($source, $track);
$newFolder = "{$targetDir}/tracks/{$basename}"; if ($oldFolder !== $newFolder) {
$this->moveFolder($oldFolder, $newFolder); $this->moveFolder($oldFolder, $newFolder);
}
$newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/') $newTrackPath = $track->path && str_starts_with($track->path, $oldFolder . '/')
? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1) ? $newFolder . '/' . substr($track->path, strlen($oldFolder) + 1)
: $track->path; : "{$newFolder}/" . ($track->filename ?: 'audio.mp3');
$track->video_id = $target->id; $track->video_id = $target->id;
$track->path = $newTrackPath; $track->path = $newTrackPath;
$track->save(); $track->save();
// Legacy per-track lyrics live at {sourceDir}/lyrics/track-{id}.json.
// In the new layout they belong inside the moved track folder.
$legacyTrackLyrics = "{$sourceDir}/lyrics/track-{$track->id}.json";
if (file_exists(storage_path('app/' . $legacyTrackLyrics))) {
$this->moveFile($legacyTrackLyrics, "{$newFolder}/lyrics.json");
}
} }
// ── 3. Move ALL slides for the source video ──────────────────── // ── 3. Rewrite every source slide's filename to its new on-disk
// One pass: re-parent every source slide, rewrite any filename // location, derived from the track it belongs to AFTER the
// that started with sourceDir/... to targetDir/..., and if the // per-track folder moves above. This is layout-agnostic —
// slide had NULL audio_track_id (belonged to source's primary), // it works for both new (/tracks/{lang-id}/slides/N) and
// point it at the newly-created primary track. // legacy (/{sourceDir}/slides/N) source layouts.
$sourcePrefix = $sourceDir . '/'; $updatedTracks = VideoAudioTrack::where('video_id', $target->id)->get()->keyBy('id');
$targetPrefix = $targetDir . '/';
$slides = DB::table('video_slides')->where('video_id', $source->id)->get(); $slides = DB::table('video_slides')->where('video_id', $source->id)->get();
foreach ($slides as $s) { foreach ($slides as $s) {
$newFilename = $s->filename; $base = $s->filename ? basename($s->filename) : null;
if (is_string($s->filename) && str_starts_with($s->filename, $sourcePrefix)) { if (!$base) continue; // nothing we can safely rewrite
$newFilename = $targetPrefix . substr($s->filename, strlen($sourcePrefix));
} $owningTrackId = $s->audio_track_id === null ? $newPrimaryTrack->id : $s->audio_track_id;
$owningTrack = $updatedTracks->get($owningTrackId);
$owningFolder = $owningTrack && $owningTrack->path ? dirname($owningTrack->path) : $newPrimaryFolder;
$updates = [ $updates = [
'video_id' => $target->id, 'video_id' => $target->id,
'filename' => $newFilename, 'filename' => "{$owningFolder}/slides/{$base}",
]; ];
if ($s->audio_track_id === null) { if ($s->audio_track_id === null) {
$updates['audio_track_id'] = $newPrimaryTrack->id; $updates['audio_track_id'] = $newPrimaryTrack->id;
@@ -197,12 +243,54 @@ class VideoMergeService
}); });
// ── 10. Best-effort cleanup of the emptied source folder ─────────── // ── 10. Best-effort cleanup of the emptied source folder ───────────
// The legacy lyrics/ sibling is tidied first so pruneEmptyFolder's
// rmdir on sourceDir can succeed.
$this->pruneEmptyFolder("{$sourceDir}/lyrics");
$this->pruneEmptyFolder("{$sourceDir}/tracks"); $this->pruneEmptyFolder("{$sourceDir}/tracks");
$this->pruneEmptyFolder($sourceDir); $this->pruneEmptyFolder($sourceDir);
Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]); Log::info('Merge: complete', ['source_id' => $source->id, 'target_id' => $target->id]);
} }
/**
* Move an individual file (not a directory) on NAS + local. Needed for
* legacy-layout sources where the primary audio and thumb live directly
* in the video root rather than inside a per-track subfolder.
*/
private function moveFile(string $oldRel, string $newRel): void
{
if ($oldRel === '' || $newRel === '' || $oldRel === $newRel) return;
$nasMoved = false;
if ($this->nas->isEnabled()) {
$this->nas->mkdirp(dirname($newRel));
$this->nas->renameNasPath($oldRel, $newRel);
$nasMoved = true;
} else {
DB::table('pending_nas_moves')->insert([
'from_path' => $oldRel,
'to_path' => $newRel,
'kind' => 'file',
'origin' => 'merge',
'created_at' => now(),
'updated_at' => now(),
]);
}
$oldLocal = storage_path('app/' . $oldRel);
$newLocal = storage_path('app/' . $newRel);
$localMoved = false;
if (is_file($oldLocal)) {
@mkdir(dirname($newLocal), 0755, true);
$localMoved = @rename($oldLocal, $newLocal);
}
Log::info('Merge: moveFile', [
'old' => $oldRel, 'new' => $newRel,
'nas' => $nasMoved, 'local' => $localMoved,
'queued' => !$nasMoved,
]);
}
/** /**
* Move a folder on both NAS and local disk. NAS ops are queued in * Move a folder on both NAS and local disk. NAS ops are queued in
* pending_nas_moves when NAS is unreachable; nas:auto-sync drains them. * pending_nas_moves when NAS is unreachable; nas:auto-sync drains them.
+25
View File
@@ -77,6 +77,11 @@ class HtmlSanitizer
/** /**
* Produce safe display HTML. Legacy plain-text values (no tags) are * Produce safe display HTML. Legacy plain-text values (no tags) are
* escaped and converted to <br>; rich values are run through clean(). * escaped and converted to <br>; rich values are run through clean().
*
* Also detects a handful of common markdown-ish prefixes that leaked
* in through AI-generated descriptions ("## Letra", "### Chorus") and
* turns them into real <h2>/<h3> tags before sanitising. Without this
* step a reader saw literal "## Letra" instead of a heading.
*/ */
public static function render(?string $value): string public static function render(?string $value): string
{ {
@@ -85,6 +90,8 @@ class HtmlSanitizer
return ''; return '';
} }
$value = self::promoteLeakedMarkdown($value);
if (strip_tags($value) === $value) { if (strip_tags($value) === $value) {
return nl2br(e($value), false); return nl2br(e($value), false);
} }
@@ -92,6 +99,24 @@ class HtmlSanitizer
return self::clean($value); return self::clean($value);
} }
/**
* Convert markdown-style heading prefixes at the start of a line into
* real HTML tags, so the sanitiser can keep them. Only touches lines
* that start with ##, ###, or **…**; everything else is untouched.
*/
private static function promoteLeakedMarkdown(string $value): string
{
$lines = preg_split("/\r?\n/", $value);
foreach ($lines as $i => $line) {
if (preg_match('/^\s*###\s+(.+?)\s*$/u', $line, $m)) {
$lines[$i] = '<h3>' . e($m[1]) . '</h3>';
} elseif (preg_match('/^\s*##\s+(.+?)\s*$/u', $line, $m)) {
$lines[$i] = '<h2>' . e($m[1]) . '</h2>';
}
}
return implode("\n", $lines);
}
private static function sanitizeChildren(DOMNode $node): void private static function sanitizeChildren(DOMNode $node): void
{ {
foreach (iterator_to_array($node->childNodes) as $child) { foreach (iterator_to_array($node->childNodes) as $child) {
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Support;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Log;
/**
* Notification failures (SMTP down, Gmail 535, Mailgun 4xx, etc.) must never
* crash a user-facing request. Everywhere we previously did
*
* $user->notify(new SomeNotification(...));
*
* we now do
*
* SafeNotifier::send($user, new SomeNotification(...));
*
* which runs the same code, catches any Throwable, and writes a warning line
* so ops still has the signal.
*/
class SafeNotifier
{
/**
* @param mixed $notifiable Anything with ->notify(), usually a User.
*/
public static function send(mixed $notifiable, Notification $notification): void
{
if (! $notifiable || ! is_object($notifiable) || ! method_exists($notifiable, 'notify')) {
return;
}
try {
$notifiable->notify($notification);
} catch (\Throwable $e) {
Log::warning('notification send failed', [
'notification' => $notification::class,
'notifiable' => $notifiable::class,
'notifiable_id'=> $notifiable->getKey() ?? null,
'error' => $e->getMessage(),
]);
}
}
}
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# Wraps `node` with a raised file-descriptor limit. PHP-FPM starts with
# rlimit_files=1024; Chromium opens ~2000 fds on startup and dies before
# Puppeteer can attach, surfacing as "Failed to launch the browser
# process: Code: null". Bumping just this subprocess tree avoids
# restarting FPM to raise the pool-wide limit.
exec /usr/bin/prlimit --nofile=65536 /usr/bin/node "$@"
+2 -1
View File
@@ -14,7 +14,8 @@
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"p7h/nas-file-manager": "dev-main", "p7h/nas-file-manager": "dev-main",
"php-ffmpeg/php-ffmpeg": "^1.4", "php-ffmpeg/php-ffmpeg": "^1.4",
"pragmarx/google2fa-laravel": "^3.0" "pragmarx/google2fa-laravel": "^3.0",
"spatie/browsershot": "^3.61"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.9.1",
Generated
+340 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "40fa327b55e9b6fafab4b2da3f763724", "content-hash": "bdb64417cd2491642a0712fe40bade22",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -1457,6 +1457,90 @@
], ],
"time": "2025-08-22T14:27:06+00:00" "time": "2025-08-22T14:27:06+00:00"
}, },
{
"name": "intervention/image",
"version": "2.7.2",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "04be355f8d6734c826045d02a1079ad658322dad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/04be355f8d6734c826045d02a1079ad658322dad",
"reference": "04be355f8d6734c826045d02a1079ad658322dad",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"guzzlehttp/psr7": "~1.1 || ^2.0",
"php": ">=5.4.0"
},
"require-dev": {
"mockery/mockery": "~0.9.2",
"phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
},
"suggest": {
"ext-gd": "to use GD library based image processing.",
"ext-imagick": "to use Imagick based image processing.",
"intervention/imagecache": "Caching extension for the Intervention Image library"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Image": "Intervention\\Image\\Facades\\Image"
},
"providers": [
"Intervention\\Image\\ImageServiceProvider"
]
},
"branch-alias": {
"dev-master": "2.4-dev"
}
},
"autoload": {
"psr-4": {
"Intervention\\Image\\": "src/Intervention/Image"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "[email protected]",
"homepage": "https://intervention.io/"
}
],
"description": "Image handling and manipulation library with support for Laravel integration",
"homepage": "http://image.intervention.io/",
"keywords": [
"gd",
"image",
"imagick",
"laravel",
"thumbnail",
"watermark"
],
"support": {
"issues": "https://github.com/Intervention/image/issues",
"source": "https://github.com/Intervention/image/tree/2.7.2"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
}
],
"time": "2022-05-21T17:30:32+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "10.50.2", "version": "10.50.2",
@@ -2237,6 +2321,71 @@
}, },
"time": "2026-01-23T15:30:45+00:00" "time": "2026-01-23T15:30:45+00:00"
}, },
{
"name": "league/glide",
"version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/glide.git",
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/glide/zipball/b8e946dd87c79a9dce3290707ab90b5b52602813",
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813",
"shasum": ""
},
"require": {
"intervention/image": "^2.7",
"league/flysystem": "^2.0|^3.0",
"php": "^7.2|^8.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
"phpunit/php-token-stream": "^3.1|^4.0",
"phpunit/phpunit": "^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Glide\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonathan Reinink",
"email": "[email protected]",
"homepage": "http://reinink.ca"
},
{
"name": "Titouan Galopin",
"email": "[email protected]",
"homepage": "https://titouangalopin.com"
}
],
"description": "Wonderfully easy on-demand image manipulation library with an HTTP based API.",
"homepage": "http://glide.thephpleague.com",
"keywords": [
"ImageMagick",
"editing",
"gd",
"image",
"imagick",
"league",
"manipulation",
"processing"
],
"support": {
"issues": "https://github.com/thephpleague/glide/issues",
"source": "https://github.com/thephpleague/glide/tree/2.3.2"
},
"time": "2025-03-21T13:48:39+00:00"
},
{ {
"name": "league/mime-type-detection", "name": "league/mime-type-detection",
"version": "1.16.0", "version": "1.16.0",
@@ -4021,6 +4170,196 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "spatie/browsershot",
"version": "3.61.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/browsershot.git",
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/browsershot/zipball/14d75679390b8b84a71b3a17dc5905928deeb887",
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"spatie/image": "^1.5.3|^2.0",
"spatie/temporary-directory": "^1.1|^2.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0"
},
"require-dev": {
"pestphp/pest": "^1.20",
"spatie/phpunit-snapshot-assertions": "^4.2.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Browsershot\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "[email protected]",
"homepage": "https://github.com/freekmurze",
"role": "Developer"
}
],
"description": "Convert a webpage to an image or pdf using headless Chrome",
"homepage": "https://github.com/spatie/browsershot",
"keywords": [
"chrome",
"convert",
"headless",
"image",
"pdf",
"puppeteer",
"screenshot",
"webpage"
],
"support": {
"source": "https://github.com/spatie/browsershot/tree/3.61.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2023-12-21T10:00:28+00:00"
},
{
"name": "spatie/image",
"version": "2.2.7",
"source": {
"type": "git",
"url": "https://github.com/spatie/image.git",
"reference": "2f802853aab017aa615224daae1588054b5ab20e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image/zipball/2f802853aab017aa615224daae1588054b5ab20e",
"reference": "2f802853aab017aa615224daae1588054b5ab20e",
"shasum": ""
},
"require": {
"ext-exif": "*",
"ext-json": "*",
"ext-mbstring": "*",
"league/glide": "^2.2.2",
"php": "^8.0",
"spatie/image-optimizer": "^1.7",
"spatie/temporary-directory": "^1.0|^2.0",
"symfony/process": "^3.0|^4.0|^5.0|^6.0"
},
"require-dev": {
"pestphp/pest": "^1.22",
"phpunit/phpunit": "^9.5",
"symfony/var-dumper": "^4.0|^5.0|^6.0",
"vimeo/psalm": "^4.6"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Image\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "[email protected]",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Manipulate images with an expressive API",
"homepage": "https://github.com/spatie/image",
"keywords": [
"image",
"spatie"
],
"support": {
"source": "https://github.com/spatie/image/tree/2.2.7"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2023-07-24T13:54:13+00:00"
},
{
"name": "spatie/image-optimizer",
"version": "1.10.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/image-optimizer.git",
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/333c03952289dc2df0a91874636a0dffeb5b6aec",
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"php": "^7.4|^8.0",
"psr/log": "^1.0 | ^2.0 | ^3.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\ImageOptimizer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "[email protected]",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily optimize images using PHP",
"homepage": "https://github.com/spatie/image-optimizer",
"keywords": [
"image-optimizer",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/image-optimizer/issues",
"source": "https://github.com/spatie/image-optimizer/tree/1.10.0"
},
"time": "2026-06-29T08:28:30+00:00"
},
{ {
"name": "spatie/temporary-directory", "name": "spatie/temporary-directory",
"version": "2.3.1", "version": "2.3.1",
+74
View File
@@ -0,0 +1,74 @@
<?php
/*
|--------------------------------------------------------------------------
| Backup
|--------------------------------------------------------------------------
|
| Settings for `php artisan takeone:backup`.
|
| The database and the media library are backed up by DIFFERENT mechanisms on
| purpose. The database is small and must be captured as a consistent
| point-in-time snapshot, so it is copied in full on every run. The media
| library is gigabytes of immutable uploads, so it is mirrored incrementally
| and only when asked — tarring it nightly would burn hours of CPU and fill
| the disk with near-identical archives.
|
*/
return [
// Where snapshots are written. Lives under /data, which is git-ignored, so
// a backup can never be committed by accident.
'path' => env('BACKUP_PATH', base_path('data/backups')),
/*
|--------------------------------------------------------------------------
| Retention
|--------------------------------------------------------------------------
|
| Artifacts older than retain_days are pruned, but the newest keep_minimum
| are ALWAYS kept regardless of age — a box that sat idle for a month must
| still have something to restore from.
|
*/
'retain_days' => (int) env('BACKUP_RETAIN_DAYS', 14),
'keep_minimum' => (int) env('BACKUP_KEEP_MINIMUM', 3),
/*
|--------------------------------------------------------------------------
| Off-server copy
|--------------------------------------------------------------------------
|
| A filesystems.php disk name. A snapshot sitting on the same disk as the
| data survives a bad migration but not a dead disk, so set this to a
| remote disk. The command warns on every run while it is unset.
|
*/
'disk' => env('BACKUP_DISK'),
/*
|--------------------------------------------------------------------------
| Media library
|--------------------------------------------------------------------------
|
| Uploaded video is the irreplaceable asset here — it cannot be regenerated
| from anything. It is mirrored with rsync (incremental, resumable) to
| media_destination, which should be a NAS mount or another machine.
|
| Only runs when --media is passed AND a destination is set.
|
| media_delete stays false by default: a mirror that deletes is one bad
| path away from erasing the copy it was meant to protect.
|
*/
'media_paths' => [
base_path('data/app/users'),
base_path('data/app/public'),
],
'media_destination' => env('BACKUP_MEDIA_DEST'),
'media_delete' => (bool) env('BACKUP_MEDIA_DELETE', false),
];
+55
View File
@@ -0,0 +1,55 @@
<?php
/*
|--------------------------------------------------------------------------
| Live streaming
|--------------------------------------------------------------------------
|
| The media plane runs as MediaMTX on loopback; nginx proxies /live-rtc and
| /live-hls to it. Nothing here is a secret except the TURN credential, which
| comes from the environment.
|
*/
return [
// MediaMTX's control API (localhost only). Used to read live publisher and
// viewer state.
'api_url' => env('LIVE_API_URL', 'http://127.0.0.1:9997'),
/*
|--------------------------------------------------------------------------
| ICE
|--------------------------------------------------------------------------
|
| STUN lets a browser discover its public address. TURN relays the media
| when a direct path cannot be established — which, until a UDP port is
| forwarded to this server, is EVERY broadcaster outside the local network.
|
| With no TURN configured, publishing works from the LAN only.
|
*/
'stun_url' => env('LIVE_STUN_URL', 'stun:stun.l.google.com:19302'),
'turn_url' => env('LIVE_TURN_URL'),
'turn_username' => env('LIVE_TURN_USERNAME'),
'turn_credential' => env('LIVE_TURN_CREDENTIAL'),
/*
|--------------------------------------------------------------------------
| Capture defaults
|--------------------------------------------------------------------------
|
| The target the phone is ASKED for. A camera that cannot do 1080p returns
| the closest it can, and WebRTC's congestion control lowers the bitrate
| when the uplink cannot sustain it — so this is a ceiling, not a promise.
|
*/
'video_width' => (int) env('LIVE_VIDEO_WIDTH', 1920),
'video_height' => (int) env('LIVE_VIDEO_HEIGHT', 1080),
'video_fps' => (int) env('LIVE_VIDEO_FPS', 30),
// ~4.5 Mbit/s is the practical ceiling for 1080p30 over a mobile uplink.
'max_bitrate' => (int) env('LIVE_MAX_BITRATE', 4_500_000),
];
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add drag-and-dropped overlay position for coach notes. Stored as normalized
* 0..1 coordinates of the overlay's CENTER within the video area so it
* survives any viewport / fullscreen resize.
*/
public function up(): void
{
Schema::table('coach_reviews', function (Blueprint $table) {
$table->decimal('position_x', 6, 4)->nullable()->after('emoji');
$table->decimal('position_y', 6, 4)->nullable()->after('position_x');
});
}
public function down(): void
{
Schema::table('coach_reviews', function (Blueprint $table) {
$table->dropColumn(['position_x', 'position_y']);
});
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Frame-accurate timestamps: the match highlights scrubber lands on
* frame boundaries (1/fps seconds). Storing these as INTEGER seconds
* throws away sub-second precision, so we promote the columns to
* decimal(10,3) — 3 decimals cover up to 1000 fps and centuries of
* runtime, and existing integer values migrate losslessly.
*/
public function up(): void
{
// MatchRound.start_time_seconds is used to jump to a round's opening
// frame too, so bring it along.
Schema::table('match_rounds', function (Blueprint $table) {
$table->decimal('start_time_seconds', 10, 3)->nullable()->change();
});
Schema::table('match_points', function (Blueprint $table) {
$table->decimal('timestamp_seconds', 10, 3)->change();
});
Schema::table('coach_reviews', function (Blueprint $table) {
$table->decimal('start_time_seconds', 10, 3)->change();
$table->decimal('end_time_seconds', 10, 3)->nullable()->change();
});
}
public function down(): void
{
// Round back to integer on rollback — sub-second data would be
// truncated but nothing catastrophic happens.
Schema::table('match_rounds', function (Blueprint $table) {
$table->integer('start_time_seconds')->nullable()->change();
});
Schema::table('match_points', function (Blueprint $table) {
$table->integer('timestamp_seconds')->change();
});
Schema::table('coach_reviews', function (Blueprint $table) {
$table->integer('start_time_seconds')->change();
$table->integer('end_time_seconds')->nullable()->change();
});
}
};
@@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Live streaming.
*
* Purely additive: one new table. No existing table is altered — the link to a
* recording is held here as video_id rather than adding a column to `videos`,
* so the music and generic video paths run against exactly the schema they run
* against today.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('livestreams', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
// Public, unpredictable identifier. Used in the watch URL AND as the
// media path, so it must never be guessable or enumerable — knowing
// one stream's id must not hint at another's.
$table->string('public_id', 32)->unique();
$table->string('title');
$table->text('description')->nullable();
// idle → live → ended, or failed.
$table->string('status', 16)->default('idle');
// public = listed on the live index
// unlisted = reachable only with the link
$table->string('visibility', 16)->default('public');
// Short-lived, single-use credential the browser presents to the
// media server when it starts publishing. Not a long-lived stream
// key: it is minted on "Go live" and expires in minutes, so there
// is no durable secret to leak, screenshot, or reuse.
$table->string('publish_token', 64)->nullable();
$table->timestamp('publish_token_expires_at')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('ended_at')->nullable();
// Last time the media server confirmed the publisher is still
// connected — how a stream that died without saying goodbye gets
// reaped.
$table->timestamp('last_seen_at')->nullable();
$table->unsignedInteger('peak_viewers')->default(0);
$table->unsignedInteger('current_viewers')->default(0);
// Set once a finished stream has been turned into a normal video.
$table->foreignId('video_id')->nullable()->constrained()->nullOnDelete();
$table->timestamps();
// The live index reads "public streams that are live, newest first".
$table->index(['status', 'visibility']);
$table->index(['user_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('livestreams');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Dislike counterpart to video_likes.
*
* One row per (user, video) pair. The unique index on the pair prevents a
* user from racking up multiple dislikes against the same video, and the
* foreign keys cascade so cleaning up a user or video drops their dislikes
* for free.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('video_dislikes', function (Blueprint $table) {
$table->id();
$table->foreignId('video_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['video_id', 'user_id']);
$table->index('video_id');
});
}
public function down(): void
{
Schema::dropIfExists('video_dislikes');
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Creator-engagement flags on comments.
*
* - hearted: the video owner has acknowledged the comment with a red
* heart (YouTube's "creator heart"). Visible to everyone.
* - pinned: the owner has anchored the comment to the top of the list.
* The comment component sorts pinned comments ahead of everything
* else, newest-pinned first.
*
* Both default to false and are indexed only on `pinned` because that's the
* one the comments query sorts by.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->boolean('hearted')->default(false)->after('body');
$table->boolean('pinned')->default(false)->after('hearted');
$table->index('pinned');
});
}
public function down(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->dropIndex(['pinned']);
$table->dropColumn(['hearted', 'pinned']);
});
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Live chat messages — one row per message, scoped to a livestream. Simple
* polling model: viewers hit /live/{public_id}/chat?since=<id> every few
* seconds and get back everything newer than their last-seen id.
*
* Cascades: delete the livestream, lose its chat log. Users keep theirs
* nulled out so a deleted account's historical messages still render as
* "deleted user" rather than removing them from the thread.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('live_chat_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('livestream_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('author_name', 255); // snapshot of the sender's name at time of posting
$table->text('body');
$table->timestamp('created_at')->useCurrent();
// Chat pagination reads "messages in this stream newer than id X".
$table->index(['livestream_id', 'id']);
});
}
public function down(): void
{
Schema::dropIfExists('live_chat_messages');
}
};
+88
View File
@@ -0,0 +1,88 @@
# YouTube-parity audit remediation plan
Branch per phase. Feature tests per fix. STOP for approval between phases.
**Safety tag:** `backup/pre-audit-2026-10-21` at commit `5058eaf` on `full-screen-data`.
**Stashed WIP:** `stash@{0}` — pre-audit WIP on `full-screen-data` (recover with `git stash pop` on that branch).
---
## Phase 1 — Security & data integrity
Branch: `fix/phase-1-security`
- [ ] Wrap `sendEmailVerificationNotification` + `NewCommentNotification` mail in try/catch; move to a queued notification channel; log failures. Document `.env` for Gmail app password + Mailgun/Postmark fallback.
- [ ] `php artisan app:resend-verification {--user=}` command.
- [ ] Reserved-name blocklist (`admin`, `superadmin`, `takeone`, `support`, `root`, …) via `App\Rules\ReservedIdentifier` wired into `RegisteredUserController`.
- [ ] Harden login throttling to `5,1` with lockout message; log repeated failures and alert super admins.
- [ ] Confirm `email_verified_at` is not mass-assignable; only the signed-URL verification route flips it.
- [ ] Comment dedupe: raise 5s → 60s window, add client idempotency key header, server reject on duplicate. Disable submit button on first click.
- [ ] `php artisan comments:dedupe --dry-run` cleanup command.
- [ ] Owner-view exclusion in `VideoController@trackProgress`; dedupe by `device_hash` within 30 min.
- [ ] Feature tests for each item.
## Phase 2 — Performance & DOM
Branch: `fix/phase-2-perf`
- [ ] One shared `#globalEditModal` + `#globalCardMenu` per page; populated via `GET /videos/{id}/edit-payload`. Owner-only items gated server-side.
- [ ] Lazy-render reply/edit boxes (template clone on open).
- [ ] Match highlights panel rendered once.
- [ ] `href="javascript:void(0)"` → real `<button>` elements. Hide "Not interested" / "Don't recommend" / "Report" on own videos.
- [ ] Cursor pagination (`?cursor=`) across Home, `/videos` filters, channel Videos, search, history, liked, comments.
- [ ] IntersectionObserver infinite scroll + "Load more" fallback.
- [ ] `loading="lazy"`, explicit `width`/`height`, skeleton placeholders on thumbnails.
## Phase 3 — Feeds, search, trending
Branch: `fix/phase-3-feeds`
- [ ] Music + filter feeds switch to `latest()`.
- [ ] Trending windows: 24 / 168 / 720 h; rank by in-window views.
- [ ] Channel Popular: order by views, tiebreak by likes.
- [ ] Duration badge on card thumbnail. Scoreboard thumb → `<img>` or `aria-hidden`. Strip `CLUBLOGO` / `FLAG` placeholders.
- [ ] Filter tabs: fetch + `pushState`, sticky, `aria-current`.
- [ ] Normalize naming (DB stays `match`, display label "Sports" everywhere).
- [ ] "New to You" → "Recent" (or make personalized).
- [ ] New `SearchController` with `/search?q=` matching title/description/tags/language/channel; include channels + playlists. `/videos/search` becomes 302 redirect. Keep query in input. Merge the two duplicate search forms.
- [ ] `/feed/subscriptions` route backed by `subscriptions` table.
## Phase 4 — Watch page
Branch: `fix/phase-4-watch`
- [ ] Up Next: real `<a href>` cards. Recommender by type/tags/language, cross-channel. Tabs All / From this channel / Same type. Show more.
- [ ] Unified player wrapper for match/music/generic: theater (t), mini (i), PiP, volume slider, captions=lyrics (c), 2x speed, end screen, autoplay countdown. Autoplay-ON forces Loop-OFF.
- [ ] Like count visible, dislike button, Subscribe for guests, viewer `⋯` → Report. Channel row on match videos.
- [ ] `/videos/{id}/download/{format}` signed route; enforce per-video Downloads setting (Off/Everyone/Members/Subscribers) server-side for MP4 **and** MP3.
- [ ] Comments: `?lc=` permalink with scroll + highlight. Heart + pin (creator). "Read more" clamp. @handle prefill on nested reply. Singular/plural counts. Top-level vs reply counts separate.
- [ ] Markdown rendered (or stripped) in descriptions.
- [ ] Dedupe audio Language menu.
- [ ] Hide "Generating lyrics…" overlay unless job active.
- [ ] Edit form prefilled with real values (fix "Generic" / "Loading…" defaults).
- [ ] Streaming: HLS exists (`GenerateHlsJob`, `/videos/{video}/hls/{file}`). Switch player to hls.js when `hls_path` set. Verify `-movflags +faststart` on mp4 encode in `CompressVideoJob`. Add `Cache-Control` + Range on stream route.
## Phase 5 — Shorts
Branch: `fix/phase-5-shorts`
- [ ] Upload flag + auto-detect (vertical, ≤60s).
- [ ] `/shorts/{id}` route + branded 404 at `resources/views/errors/404.blade.php`.
- [ ] `/shorts` feed: 100dvh snap container, single reused `<video>` moved between slots, next 1–2 preloaded, URL via `history.replaceState`, wheel + arrows + swipe + ▲▼. Loop. Muted autoplay + "Tap to unmute"; unmuted state in `localStorage`.
- [ ] Right action column (like/dislike/comments/share). Bottom overlay (avatar/@handle/Subscribe/title). Comments side panel desktop / bottom sheet mobile.
- [ ] Home Shorts shelf. Channel Shorts tab (fix "My Shorts").
## Phase 6 — Channel / Studio / Live
Branch: `fix/phase-6-channel-studio-live`
- [ ] `users.handle` migration (unique, nullable → backfill → required). `/@handle` routes. Tabs `/@handle/{videos,shorts,playlists,posts,about}`. "Preview as visitor" toggle. Header shows description + links. Age/gender/horoscope private by default. Pluralize "1 video"; drop stray "· 0.0" on playlist cards.
- [ ] `/studio/*`: content table + bulk edit, per-video analytics, two-step upload (Details → Visibility: tags + playlist + schedule), file-size + duration + processing-status surfaced.
- [ ] Live: ended state → "Stream ended" + duration + recording (never "Waiting"). Live chat. Like / share / subscribe on viewer page. Channel name link. Private option on `/live/new` matching upload wording.
## Phase 7 — Admin, a11y, consistency
Branch: `fix/phase-7-admin-a11y`
- [ ] Rename "Live Videos" → "Ready". Storage counts NAS (via `NasSyncService`). Fix KPI WoW %. Strip HTML in description excerpts. Full titles with tooltip. Next/Last pagination on Audit Logs, Users, Videos. Error log viewer groups by stack trace in natural order.
- [ ] A11y: `aria-label` on every icon button (menu, search, voice, avatar, ⋯, Like-active, comment menus, seek slider with value text). Skip-to-content link. One `<h1>` per page. `label[for]` on all inputs. Remove duplicate "Prefer not to say". Birth year 13+ cap.
- [ ] Title helper `{page} | Play`. Brand: **Play** displayed; `TAKEONE` stays as `config('app.name')` per CLAUDE.md.
- [ ] Cleanup commands (all `--dry-run` first): `comments:dedupe`, `live:cleanup-test`, `tracks:dedupe`.
## Final deliverables
- Checklist: done / partial / skipped (with reason).
- `.env` keys the user must set.
- Deploy steps: migrations, queue restart, cache clear, asset build.
+62
View File
@@ -0,0 +1,62 @@
# Mail setup
Mail failures are now non-fatal — a down SMTP will log a warning in
`data/logs/laravel.log` but will NOT 500 the user. Still, delivery is a
feature; here's how to fix the current Gmail 535 auth errors.
## Option A — Gmail App Password (quickest, free, ~500 sends/day)
1. On `[email protected]`:
- Enable 2-Step Verification (Google Account → Security).
- Generate an **App Password** (Security → App passwords → "Mail", "Other: TAKEONE").
- Copy the 16-character password Google shows (no spaces).
2. `.env`:
```env
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=platformtakeone@gmail.com
MAIL_PASSWORD=xxxx-xxxx-xxxx-xxxx # the app password, no quotes needed
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=platformtakeone@gmail.com
MAIL_FROM_NAME="TAKEONE"
```
3. `php artisan config:clear && php artisan queue:restart`
4. Smoke test:
```bash
php artisan app:resend-verification --user=<id-of-an-unverified-user>
```
## Option B — Mailgun (recommended for production)
```env
MAIL_MAILER=mailgun
MAILGUN_DOMAIN=mg.takeone.bh
MAILGUN_SECRET=key-...
MAILGUN_ENDPOINT=api.eu.mailgun.net # or api.mailgun.net for US region
MAIL_FROM_ADDRESS=no-reply@takeone.bh
MAIL_FROM_NAME="TAKEONE"
```
## Option C — Postmark (also fine)
```env
MAIL_MAILER=postmark
POSTMARK_TOKEN=...
MAIL_FROM_ADDRESS=no-reply@takeone.bh
MAIL_FROM_NAME="TAKEONE"
```
## Resend verification to users whose first email failed
```bash
# One specific user
php artisan app:resend-verification --user=42
# Everyone still unverified (dry-run first)
php artisan app:resend-verification --all-unverified --dry-run
php artisan app:resend-verification --all-unverified
```
+391
View File
@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Bundled Page</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0e; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #0a0a0e; z-index: 9999; }
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
#__bundler_placeholder { color: #999; font-size: 14px; }
</style>
<noscript>
<style>#__bundler_loading { display: none; }</style>
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
This page requires JavaScript to display.
</div>
</noscript>
</head>
<body>
<div id="__bundler_thumbnail">
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
<polygon points="0,0 620,0 480,800 0,800" fill="#7a2226"></polygon>
<polygon points="580,0 1200,0 1200,800 720,800" fill="#22357a"></polygon>
<text x="600" y="470" font-family="Arial Black, Arial, sans-serif" font-size="220" font-weight="900" font-style="italic" text-anchor="middle" fill="#fffdf5" stroke="#e8c265" stroke-width="4">VS</text>
</svg>
</div>
<div id="__bundler_loading">Unpacking...</div>
<script>
document.addEventListener('DOMContentLoaded', async function() {
const loading = document.getElementById('__bundler_loading');
function setStatus(msg) { if (loading) loading.textContent = msg; }
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
function toBase64(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(bin);
}
// Error sink persists across replaceWith since it's on window, not the DOM.
window.addEventListener('error', function(e) {
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
// events at the element — warn only; real JS errors carry message/error.
if (!e.message && !e.error && e.target && e.target !== window) {
console.warn('[bundle] resource failed to load:',
e.target.tagName, String(e.target.src || e.target.href || ''));
return;
}
var p = document.body || document.documentElement;
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
d.id = '__bundler_err';
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
'[bundle] ' + (e.message || e.type) +
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
}, true);
try {
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
const templateEl = document.querySelector('script[type="__bundler/template"]');
if (!manifestEl || !templateEl) {
setStatus('Error: missing bundle data');
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
return;
}
const manifest = JSON.parse(manifestEl.textContent);
let template = JSON.parse(templateEl.textContent);
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
// document's manifest; frame srcs carry an about:blank#<uuid> marker
// instead of a substitutable uuid. Every document mints the blobs for
// its OWN frames — from local text at the root, or text obtained from
// its DIRECT parent over the relay protocol below — so each frame
// navigation stays same-origin with its initiator and a deduped page
// loads at any depth on any host, including a file:// download's
// opaque origins (where a blob minted by one document cannot be
// navigated to by another).
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
const pageSet = new Set(pageOrder);
const pageTexts = {};
// ── Nested-page text protocol (parent-chain relay) ──
// Page bundles ship once, in the ROOT document's manifest. Every
// document mints the blobs for its OWN frames, obtaining page text
// through a strict parent-chain relay: a document only ever SENDS to
// its direct parent (which, for every document the publisher's own
// runtime minted, is another publisher document) and only ACCEPTS
// text from that direct parent; requests from below are honored only
// for the document's own child frames, and the ROOT never sends
// upward at all. That last rule is load-bearing: the artifact host's
// frame-ancestors allows claude.ai (and friends) to frame an
// artifact, so window.top can be a FOREIGN document — a top-addressed
// protocol would hand page uuids to that ancestor and accept forged
// "page text" from it, minting attacker HTML as an artifact-origin
// blob document. The chain never touches the ancestor: requests stop
// at the root, replies are origin-addressed per hop (never '*' when
// an origin exists), and both directions are origin-gated (on https
// a foreign sender's origin can never equal this document's; in
// opaque contexts — file:// — everything serializes as 'null', where
// an embedded EXTERNAL frame still fails the gate by carrying its
// own real origin, and a sandboxed child of such a frame is excluded
// by the own-child-frames check plus uuid unguessability). Shape is
// deny-by-default: one request form, one reply form, no error
// replies.
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
// contexts such as a sandboxed preview iframe) — location.origin is
// computed from the URL and stays 'https://…' even when the document
// is sandboxed, which would misgate every opaque chain.
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
? window.origin
: '*';
function trustedOrigin(e) {
if (e.origin === window.origin) return true; // https chains; opaque==opaque
// file:// trees: Chrome serializes the file document's origin as
// 'file://' while the blob: documents it mints serialize as 'null',
// so the two legitimate shapes can never string-match — accept the
// pair in both directions. https origins never serialize as either,
// and sender identity (own child frame / direct parent) is checked
// separately.
return (
(e.origin === 'null' &&
(window.origin === 'file://' ||
window.location.protocol === 'file:')) ||
(/^file:/.test(e.origin) && window.origin === 'null')
);
}
// Non-empty only in the root document (nested bundles ship an empty
// island), so it doubles as the root marker: the root answers from
// local text and treats a miss as authoritative — never relaying to
// its (possibly foreign) parent.
const isPageRoot = pageOrder.length > 0;
const blobUrls = {};
const resourceBlobs = {};
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
function mountPage(el, frag, text) {
const pageBlob = new Blob([text], { type: 'text/html' });
const u = URL.createObjectURL(pageBlob);
resourceBlobs[u] = pageBlob;
el.src = u + frag;
}
function deliverPage(uuid, text) {
const frames = pendingFrames[uuid];
delete pendingFrames[uuid];
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
const relays = pendingRelays[uuid];
delete pendingRelays[uuid];
if (relays) {
for (const cw of relays) {
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
}
}
}
function ownFrameWindows() {
const out = [];
for (const f of Array.from(document.querySelectorAll('iframe'))) {
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
}
return out;
}
window.addEventListener('message', function (e) {
const d = e.data;
if (!d || typeof d !== 'object' || !e.source) return;
// Origin gate first; the structural checks below (own-child-frames
// for requests, direct-parent + solicited-uuid for replies, the
// root's never-relay rule) are what hold in fully opaque chains
// where origin strings can't discriminate — a sandboxed root's
// FOREIGN wrapper still fails all three even though both serialize
// as real-vs-'null' rather than matching.
if (!trustedOrigin(e)) return;
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
// Requests are honored only for this document's own child frames.
if (ownFrameWindows().indexOf(e.source) < 0) return;
const uuid = d.__bundler_need;
const text = pageTexts[uuid];
if (typeof text === 'string') {
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
return;
}
if (isPageRoot || window.parent === window) return; // authoritative miss
const waiting = pendingRelays[uuid];
if (waiting) {
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
return;
}
// Never-answered uuids would otherwise accumulate forever under a
// request spray; past the cap (far above any real page count) new
// relays are dropped rather than remembered.
if (Object.keys(pendingRelays).length >= 128) return;
pendingRelays[uuid] = [e.source];
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
} else if (
typeof d.__bundler_page === 'string' &&
UUID_SHAPE.test(d.__bundler_page) &&
typeof d.__bundler_text === 'string'
) {
// The shape test is load-bearing beyond tidiness: the solicited
// check below uses the in operator, which matches prototype keys
// ('__proto__', 'constructor') — an unshaped key would reach
// deliverPage and throw, painting the error banner.
// Text is accepted only from the direct parent — the document
// that minted this one.
if (e.source !== window.parent || window.parent === window) return;
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
deliverPage(d.__bundler_page, d.__bundler_text);
}
});
const uuids = Object.keys(manifest);
setStatus('Unpacking ' + uuids.length + ' assets...');
await Promise.all(uuids.map(async (uuid) => {
const entry = manifest[uuid];
try {
const binaryStr = atob(entry.data);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
let finalBytes = bytes;
if (entry.compressed) {
if (typeof DecompressionStream !== 'undefined') {
const ds = new DecompressionStream('gzip');
const writer = ds.writable.getWriter();
const reader = ds.readable.getReader();
writer.write(bytes);
writer.close();
const chunks = [];
let totalLen = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
totalLen += value.length;
}
finalBytes = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
} else {
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
}
}
if (pageSet.has(uuid)) {
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
return;
}
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
// Strict artifact hosts allow font-src data: but not blob:.
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
(entry.compressed ? toBase64(finalBytes) : entry.data);
} else {
const blob = new Blob([finalBytes], { type: entry.mime });
blobUrls[uuid] = URL.createObjectURL(blob);
resourceBlobs[blobUrls[uuid]] = blob;
}
} catch (err) {
console.error('Failed to decode asset ' + uuid + ':', err);
const blob = new Blob([], { type: entry.mime });
blobUrls[uuid] = URL.createObjectURL(blob);
resourceBlobs[blobUrls[uuid]] = blob;
}
}));
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
const resourceMap = {};
for (const entry of extResources) {
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
}
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
// consumers read these Blobs directly. Survives the swap like the error sink.
window.__resourceBlobs = resourceBlobs;
setStatus('Rendering...');
// Page uuids have no blob here — they ride inside about:blank#<uuid>
// frame markers the pass below resolves after the swap.
for (const uuid of uuids) {
if (pageSet.has(uuid)) continue;
template = template.split(uuid).join(blobUrls[uuid]);
}
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
template = template.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '');
const resourceScript = '<script>window.__resources = ' +
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
';</' + 'script>';
// Inject after <head> so the DOCTYPE stays first; prepending the script
// would push the parser into quirks mode. DOMParser always emits a <head>
// (synthesizing one if the source HTML omitted it) but may carry
// attributes through, so match the full opening tag. slice() rather than
// replace() keeps us clear of $-pattern substitution in resourceScript.
const headOpen = template.match(/<head[^>]*>/i);
if (headOpen) {
const i = headOpen.index + headOpen[0].length;
template = template.slice(0, i) + resourceScript + template.slice(i);
}
// Parse the template and swap the root element. Scripts inserted via
// DOMParser/replaceWith are inert per spec — re-create each with
// createElement so they execute, awaiting onload for src scripts to
// preserve ordering (React before ReactDOM before Babel before text/babel).
const doc = new DOMParser().parseFromString(template, 'text/html');
document.documentElement.replaceWith(doc.documentElement);
// Resolve page-frame markers: mint this document's own blob for each
// framed page — from local text when this is the root, otherwise by
// asking the direct parent over the relay protocol above. An
// unanswered request leaves the frame on about:blank, the same
// degraded state as a refused embed.
{
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
const needed = [];
for (const el of Array.from(document.querySelectorAll('iframe'))) {
const m = MARKER.exec(el.getAttribute('src') || '');
if (!m) continue;
const text = pageTexts[m[1]];
if (typeof text === 'string') {
mountPage(el, m[2] || '', text);
} else if (window.parent !== window && !isPageRoot) {
const had = pendingFrames[m[1]];
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
if (!had) needed.push(m[1]);
}
}
for (const uuid of needed) {
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
}
}
const dead = Array.from(document.scripts);
for (const old of dead) {
const s = document.createElement('script');
for (const a of old.attributes) s.setAttribute(a.name, a.value);
s.textContent = old.textContent;
// text/babel scripts with a src: read and inline. transformScriptTags
// does XHR against the src, but blob:null/ from a file:// origin is
// silently dropped. Inlining makes it a plain inline babel script,
// which transformScriptTags handles unconditionally.
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
const pre = resourceBlobs[s.src.split('#')[0]];
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
s.removeAttribute('src');
}
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
old.replaceWith(s);
if (p) await p;
}
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
// which fired before we swapped the document. Trigger manually if present.
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
window.Babel.transformScriptTags();
}
} catch (err) {
setStatus('Error unpacking: ' + err.message);
console.error('Bundle unpack error:', err);
}
});
</script>
<script type="__bundler/manifest">
{"634cd639-9aa5-4e99-93cf-a35ae77b2724":{"mime":"image/webp","compressed":false,"data":"UklGRvTaAQBXRUJQVlA4WAoAAAAgAAAAkQMArwQASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZWUDggBtkBAHBCB50BKpIDsAQ+USaPRaOiISclkmqI4AoJaUCysa0j+oobVxBkm/YlAdvU4vUrVn6p/y94G65+i5wliAHgw8xP2g6YP/E9Bf6W6fv+j48ftDHnGLqhM79X/6s2CCH3N0IXpT46b8k8mP0D/A72/z37l/sf5H0JPq7xJex/4P/1/3/qP/R/0d/h/y/tn/zPAn9o/wPQO/WP8l/5vVqgWeGqBO4q+9/+3+29Zf2L/V//L/S/AJ/Rv8B/6P8t7c/9zxXfyf/S/cz4Cf7D/r/WD/5f/96Cv2n/pew5+037adu0oBDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcDjwXbEMuTM5CeDg0/WbfYG+wN9gb7A32BvsDfYG+wN9gb7A32BTOiZ/s/u9oCmXy05wZPHPMfTnL633x7vzZzRd7E74ap4Mp2WUd0VyWrhkClM+spcZ1VTpB0aQjld7eiVLVtmAfWEdANCJ0eFvsDfYG+wN9gb7A32BvsDfYG+wN9gbJLYFTAG0xAzI6yHjv74Wcp42UesdhAWEll0Mz77JaUHlulxgqy+mQczgDbGkrVJcHFlufap6I14MODzHIj4F5x2QValCk7J2+xcfxzZ5PjhYM+KVx57CekqiCUSkolJRKSiUlEpKJSUSkolJRI5nMN8+PfaP/ppGXaK90lr75ZWuF9bNrauU95rEzdUb1C1qZvsyk6ee95lySUR1MMLe+EaEbA+E+8nlgXyG6OR9nH0ZnNIalWTZMQkfeTPYorOy1Sbnr6WUqF6yG3G36Um43KVRDvK9XenCUSkolJRKSiUlEpKJSUSkolJQw8SkbOxva7a/dbRhay/9p6k1ps2tLL3PsGeB3wmUc2O1r6Ko/YIvzJQc3U823Qht+gk3DOwMXSPzPd1Rvk4qGS4lADC6jMNLREiRDWbffaISXTWM1SYEApKk342E26eK2J/CS/KKc8EVwEA5hmsX9nP8COuVi3YchhVhMrBh3BDuCHcEO4IdwQ7gh3BDuBiybDoJyHc6CgsI2nhNxcK4ivBgZvAqaCNDzcelDlw/42MaI44iLd6aOUkPa8wOZjckLrCfg74lJifr6Fl7z9i11am5PIHpxW5Lh4k3BIe8znZeLqHsmSU30RQmaCNtofvX3laWBKIwywl3UO+EhtmqXCHcXy9ynkiDU0YfurvhvE7zx522PN2UlEpKJSUSkolJRKSiUlEpJMXhiiR1qJ8wdnNkUdpHrFPa6B9Trd/6xgHcCtMzbEUX/yr6C3e1ucYiafg9kimrNPFPNxmJOBhvdJ6EmCz8EO4r/DHV+z4OTGDW6wudaAGkifd4DZNTLCVHs3KSzJSwdsATv7gqklIqM1QZFTGQHReShA0/1asKVrvbdM+rofcn/JfddtaGmGOujFdGK6MV0YroxXRiujFNpLVXaxIvpx3hifcd7liCmLjfmrHyV65wDvnMSivzOk/Z1pIBa4mVBTb0y7SwY+7z8xyyMmQh7kNSCrFl216OJd35YarFe6rX2AfRpOHXPXxGUOSSr2krq58AObG7z0K9/pMo/OCzuaroHIiScJkSv32huF6MHQ1EzJJR5UpRGEUD3GcQugdd7ZLSdd7C/gNgidIbgEXlrqT32z1GaJEh6JD0SHokPRIeiQ9Eh6AoJPaRhTn8rsZCbovqMXKFY/fqFYv4kryByMluIi9b85ACjpfIpD/n+uHIEY1/vy9NW8J3mHKIVMWvDrHwvArTbWahABxEn/4MfQwOL8e7qWBz036DLHmnEb6wb/G5cLoWVPRU0HNWVPMI0D+hUvL4wSEXmqJSoqWpegUU2X3xLFRTvB+Ad8dVl8Mu0Q9+vxUoVpeb8YgLCE9kxSkIOV+AfV5eCHcEO4IdwQ7gh3BDuCFudkHNn5AZZJm1k14VrlyYOrkTelg986azm/sU7cIQaLRH3awJCRbooUEC1Ek75bzWrEehXhOOoXXASBjvqypB4A+nf45d6rSL0PzbPObtdlLIInJHdRsx3AXtWpgkXu56zIjFFO/o+8RrGqY5H+7+eh0q2fm/qMZo82AyCujHkwpC6uQnxt+ql1w7GbuDk37GsOcp5hMnB2QCSIgqpHEs1irSpVkFAqnf9QB4e32BvsDfYG+wN9gb7A3xLiZxiATkzmUJil8/0ZNBhV4qpM2PxOXeZXpwESkXrLO9Y72EVtFaCZOhDEXNyNUDS3bqkmbuaB//9p5MGu0n7KE+Sua45KtgLXknZ58fqHl3wp/qhSl1RmR4H7OCRsH1LSr9cTAqRdB1XbiZQXi8l5dgFyzr8NYMcytr0Rma04S4DasMs7H7eT6pgUE4VvYL1kuHUgW6rEZHE3cyrodkBF3xzc8cEUIbKndn0KwnRBKJSUSkolJRKSiUlEo6kOI5c0wxEFWb8fKoVSLzQhwoEV2MY9584Z4wHZdqj5hEWu76ISmrh5Uwg1qecy/b56qr4pOkVwH191QtcafaXzo+AIveV774pKr6odSyZVIg5ZIljpmLXwcOuu7Z6h73OvualT+AcqPMvtZtPHvgrjbmUdKcPrrHt+qPrbYijbyHrtylxcO+2AYr3qSd0GzGfmHYF7w1WGCGtFUQygCMWGgHldn3uCTnMm4LrWiOLj+zKHZXpLqgva1UJn0QN4gbxA3iBvEDeIG8BeGRNnhORiTRsYCTwm/Wyboie+RH2TDnrkA49wBhTEJOPZdDoPkh9bID5uGOVRyd6KJ/LZKWedSQT6xv/kd1jsNnX4Gcq+KpK+ks1iLdxAvL9dVOZgQCfbhRJ9Ud6IbNRhpNxA8CHurPmsBAQnl3oGyYIchZBfFZhEx4htp24RLwPhFdG3ePXKgWQKgdLAnipfcb4hsmdCbQcb8YTYK9jf0I+Lf8SWjeRLN123mPthgspiCA9QroxXRiujFdGK6MV0Il+A16DJE5OEN3WAAO8kM0Gy485YQCYkQdlxoxxq4P6dDkoToeua2tOq3FGHc7nVRxF9OZOwhai69+EtTg3HXkEzcTBRRL6ZG9FrDA386i697WywkkzNmh1WSxHSWZ7lLLNn1WEr7lcy0VqClSbvIvMah3YoGEMG0WJDTxY79+LgT4lHBErwe/2kgQ/nCotjPRHK+BonkFVqtkCujFdGK6MV0YroxXRfPkMZzx6+PtaN2HAqNrO/UfyYO3iuO87jvHAsmWu9zk45ub4UsguiESaAjsu1dn2Evvmgtl0hy4xNxwTlgC2xlW7XU+C2J4TxB0u55GgYDmwnDNbdsArbMWq5ZGOMAjfUFR8gH+RmxmtRJ72OF3+RbVt+d8nIILRohEJLI7oIEqjvf/t+dzC3h3MqL4+Hgpa1WQgB2/1A7fYG+wN9gb7A32BukcZ3BdYzmixZOKsedwI+gJZIEDEVvq2SYOBlfC5HWKGRKbyrrUdklIWAX0QMNmf7j/DfV98yzm0asQT1pPnq5/xg+0+QOWgJIvkpUWXp/o5qqGwgmPBfImydZJwnTmzk1+K4jAWI6RWlDOaXMTWLuMH4WlOUc1FaDooj0wDXJ7+mJnAkLpNQEYHjah7GD3EV2Yk3a6MV0YroxXRiujFbwBf8c8AZMz+AWk6vaLearIoD6L134Ryaahu7ieexHDqchVojiWPc1bOmbZjGao22xkxeqLOSMQA3uOLPoN0tJ0OsldnW3U0bW9KtZD7LP57F5tAmYRkz7j8cHvUnSzVbfIwFVz7ESOXwQ2qG1BjGg1r3M3oAzfNa2lk1krwG0o8BqOng2vr1+YPQvF+GJWIRH2QyotACNbIfhvoN570YroxXRiujFdGK6EeiOZU5zmBSHaf6HJggxi2sKqoXKnYq+OYy5NwTDmHOB0MjNm79HKeAxMnaXfn6KNXaUIuyrUAg3l0+HUpmJF7pUSrIROA4y+eqbDB4QUTuRnZszseLy5uQSnW+2kbHuwEj9WeRW8TXS5r4mhL7MkqNxC3L4lijAjLundafKs4kV3W+sqBcE8VcdxLbE36k9MaVY93F882ef8bVdIG7nQL9sfp8ThWqUlEpLine truncated
</script>
<script type="__bundler/ext_resources">
[{"id":"flagJp","uuid":"8f819462-17b1-46f6-8aa3-2de374e257f7"},{"id":"flagBr","uuid":"cc3aaea8-173f-4338-b7fa-2bae75a84d2b"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"dc229245-056d-4703-a7d0-641172e38b84"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"96676beb-9588-43e1-aef2-459578e9a205"}]
</script>
<script type="__bundler/page_order">
[]
</script>
<script type="__bundler/template">
"<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<script src=\"efd1862c-8a2a-4b74-9741-9eb4a7b79af6\"><\u002Fscript>\n\n\n\n<\u002Fhead>\n<body>\n<x-dc>\n<helmet>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<style>/* vietnamese */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"786f769a-eb68-482a-90e4-5f4c6341055c\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"e50aced0-6710-4914-b239-a64494fce97d\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"66dcdbee-b198-4b92-90c2-93c3b6f9111b\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"0be5c901-33ac-4507-bb55-5dafed4159e7\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"2f8f7fe2-ce99-4a10-b583-ecf8c782a45d\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"754fb47d-ebf2-4909-b5a0-f9abb2ed5c4d\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"7b059d00-08e1-424c-9715-c5b65f9a9411\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"faa370e3-1d36-48e0-b7c4-1a760ef73c77\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"dcfb04b8-57f1-4948-b81e-dcad7d26a879\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"eb64d5f4-973e-4865-8cd1-89be941b16be\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"11b56c5f-a515-42d7-bf11-6aa8bed5ecf6\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"3cf32598-e7d4-4db2-8d36-ceee8f725195\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152Line truncated
</script>
</body>
</html>
+391
View File
@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Bundled Page</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0e; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #0a0a0e; z-index: 9999; }
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
#__bundler_placeholder { color: #999; font-size: 14px; }
</style>
<noscript>
<style>#__bundler_loading { display: none; }</style>
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
This page requires JavaScript to display.
</div>
</noscript>
</head>
<body>
<div id="__bundler_thumbnail">
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
<polygon points="0,0 620,0 480,800 0,800" fill="#7a2226"></polygon>
<polygon points="580,0 1200,0 1200,800 720,800" fill="#22357a"></polygon>
<text x="600" y="470" font-family="Arial Black, Arial, sans-serif" font-size="220" font-weight="900" font-style="italic" text-anchor="middle" fill="#fffdf5" stroke="#e8c265" stroke-width="4">VS</text>
</svg>
</div>
<div id="__bundler_loading">Unpacking...</div>
<script>
document.addEventListener('DOMContentLoaded', async function() {
const loading = document.getElementById('__bundler_loading');
function setStatus(msg) { if (loading) loading.textContent = msg; }
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
function toBase64(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(bin);
}
// Error sink persists across replaceWith since it's on window, not the DOM.
window.addEventListener('error', function(e) {
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
// events at the element — warn only; real JS errors carry message/error.
if (!e.message && !e.error && e.target && e.target !== window) {
console.warn('[bundle] resource failed to load:',
e.target.tagName, String(e.target.src || e.target.href || ''));
return;
}
var p = document.body || document.documentElement;
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
d.id = '__bundler_err';
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
'[bundle] ' + (e.message || e.type) +
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
}, true);
try {
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
const templateEl = document.querySelector('script[type="__bundler/template"]');
if (!manifestEl || !templateEl) {
setStatus('Error: missing bundle data');
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
return;
}
const manifest = JSON.parse(manifestEl.textContent);
let template = JSON.parse(templateEl.textContent);
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
// document's manifest; frame srcs carry an about:blank#<uuid> marker
// instead of a substitutable uuid. Every document mints the blobs for
// its OWN frames — from local text at the root, or text obtained from
// its DIRECT parent over the relay protocol below — so each frame
// navigation stays same-origin with its initiator and a deduped page
// loads at any depth on any host, including a file:// download's
// opaque origins (where a blob minted by one document cannot be
// navigated to by another).
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
const pageSet = new Set(pageOrder);
const pageTexts = {};
// ── Nested-page text protocol (parent-chain relay) ──
// Page bundles ship once, in the ROOT document's manifest. Every
// document mints the blobs for its OWN frames, obtaining page text
// through a strict parent-chain relay: a document only ever SENDS to
// its direct parent (which, for every document the publisher's own
// runtime minted, is another publisher document) and only ACCEPTS
// text from that direct parent; requests from below are honored only
// for the document's own child frames, and the ROOT never sends
// upward at all. That last rule is load-bearing: the artifact host's
// frame-ancestors allows claude.ai (and friends) to frame an
// artifact, so window.top can be a FOREIGN document — a top-addressed
// protocol would hand page uuids to that ancestor and accept forged
// "page text" from it, minting attacker HTML as an artifact-origin
// blob document. The chain never touches the ancestor: requests stop
// at the root, replies are origin-addressed per hop (never '*' when
// an origin exists), and both directions are origin-gated (on https
// a foreign sender's origin can never equal this document's; in
// opaque contexts — file:// — everything serializes as 'null', where
// an embedded EXTERNAL frame still fails the gate by carrying its
// own real origin, and a sandboxed child of such a frame is excluded
// by the own-child-frames check plus uuid unguessability). Shape is
// deny-by-default: one request form, one reply form, no error
// replies.
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
// contexts such as a sandboxed preview iframe) — location.origin is
// computed from the URL and stays 'https://…' even when the document
// is sandboxed, which would misgate every opaque chain.
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
? window.origin
: '*';
function trustedOrigin(e) {
if (e.origin === window.origin) return true; // https chains; opaque==opaque
// file:// trees: Chrome serializes the file document's origin as
// 'file://' while the blob: documents it mints serialize as 'null',
// so the two legitimate shapes can never string-match — accept the
// pair in both directions. https origins never serialize as either,
// and sender identity (own child frame / direct parent) is checked
// separately.
return (
(e.origin === 'null' &&
(window.origin === 'file://' ||
window.location.protocol === 'file:')) ||
(/^file:/.test(e.origin) && window.origin === 'null')
);
}
// Non-empty only in the root document (nested bundles ship an empty
// island), so it doubles as the root marker: the root answers from
// local text and treats a miss as authoritative — never relaying to
// its (possibly foreign) parent.
const isPageRoot = pageOrder.length > 0;
const blobUrls = {};
const resourceBlobs = {};
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
function mountPage(el, frag, text) {
const pageBlob = new Blob([text], { type: 'text/html' });
const u = URL.createObjectURL(pageBlob);
resourceBlobs[u] = pageBlob;
el.src = u + frag;
}
function deliverPage(uuid, text) {
const frames = pendingFrames[uuid];
delete pendingFrames[uuid];
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
const relays = pendingRelays[uuid];
delete pendingRelays[uuid];
if (relays) {
for (const cw of relays) {
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
}
}
}
function ownFrameWindows() {
const out = [];
for (const f of Array.from(document.querySelectorAll('iframe'))) {
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
}
return out;
}
window.addEventListener('message', function (e) {
const d = e.data;
if (!d || typeof d !== 'object' || !e.source) return;
// Origin gate first; the structural checks below (own-child-frames
// for requests, direct-parent + solicited-uuid for replies, the
// root's never-relay rule) are what hold in fully opaque chains
// where origin strings can't discriminate — a sandboxed root's
// FOREIGN wrapper still fails all three even though both serialize
// as real-vs-'null' rather than matching.
if (!trustedOrigin(e)) return;
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
// Requests are honored only for this document's own child frames.
if (ownFrameWindows().indexOf(e.source) < 0) return;
const uuid = d.__bundler_need;
const text = pageTexts[uuid];
if (typeof text === 'string') {
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
return;
}
if (isPageRoot || window.parent === window) return; // authoritative miss
const waiting = pendingRelays[uuid];
if (waiting) {
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
return;
}
// Never-answered uuids would otherwise accumulate forever under a
// request spray; past the cap (far above any real page count) new
// relays are dropped rather than remembered.
if (Object.keys(pendingRelays).length >= 128) return;
pendingRelays[uuid] = [e.source];
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
} else if (
typeof d.__bundler_page === 'string' &&
UUID_SHAPE.test(d.__bundler_page) &&
typeof d.__bundler_text === 'string'
) {
// The shape test is load-bearing beyond tidiness: the solicited
// check below uses the in operator, which matches prototype keys
// ('__proto__', 'constructor') — an unshaped key would reach
// deliverPage and throw, painting the error banner.
// Text is accepted only from the direct parent — the document
// that minted this one.
if (e.source !== window.parent || window.parent === window) return;
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
deliverPage(d.__bundler_page, d.__bundler_text);
}
});
const uuids = Object.keys(manifest);
setStatus('Unpacking ' + uuids.length + ' assets...');
await Promise.all(uuids.map(async (uuid) => {
const entry = manifest[uuid];
try {
const binaryStr = atob(entry.data);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
let finalBytes = bytes;
if (entry.compressed) {
if (typeof DecompressionStream !== 'undefined') {
const ds = new DecompressionStream('gzip');
const writer = ds.writable.getWriter();
const reader = ds.readable.getReader();
writer.write(bytes);
writer.close();
const chunks = [];
let totalLen = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
totalLen += value.length;
}
finalBytes = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
} else {
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
}
}
if (pageSet.has(uuid)) {
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
return;
}
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
// Strict artifact hosts allow font-src data: but not blob:.
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
(entry.compressed ? toBase64(finalBytes) : entry.data);
} else {
const blob = new Blob([finalBytes], { type: entry.mime });
blobUrls[uuid] = URL.createObjectURL(blob);
resourceBlobs[blobUrls[uuid]] = blob;
}
} catch (err) {
console.error('Failed to decode asset ' + uuid + ':', err);
const blob = new Blob([], { type: entry.mime });
blobUrls[uuid] = URL.createObjectURL(blob);
resourceBlobs[blobUrls[uuid]] = blob;
}
}));
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
const resourceMap = {};
for (const entry of extResources) {
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
}
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
// consumers read these Blobs directly. Survives the swap like the error sink.
window.__resourceBlobs = resourceBlobs;
setStatus('Rendering...');
// Page uuids have no blob here — they ride inside about:blank#<uuid>
// frame markers the pass below resolves after the swap.
for (const uuid of uuids) {
if (pageSet.has(uuid)) continue;
template = template.split(uuid).join(blobUrls[uuid]);
}
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
template = template.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '');
const resourceScript = '<script>window.__resources = ' +
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
';</' + 'script>';
// Inject after <head> so the DOCTYPE stays first; prepending the script
// would push the parser into quirks mode. DOMParser always emits a <head>
// (synthesizing one if the source HTML omitted it) but may carry
// attributes through, so match the full opening tag. slice() rather than
// replace() keeps us clear of $-pattern substitution in resourceScript.
const headOpen = template.match(/<head[^>]*>/i);
if (headOpen) {
const i = headOpen.index + headOpen[0].length;
template = template.slice(0, i) + resourceScript + template.slice(i);
}
// Parse the template and swap the root element. Scripts inserted via
// DOMParser/replaceWith are inert per spec — re-create each with
// createElement so they execute, awaiting onload for src scripts to
// preserve ordering (React before ReactDOM before Babel before text/babel).
const doc = new DOMParser().parseFromString(template, 'text/html');
document.documentElement.replaceWith(doc.documentElement);
// Resolve page-frame markers: mint this document's own blob for each
// framed page — from local text when this is the root, otherwise by
// asking the direct parent over the relay protocol above. An
// unanswered request leaves the frame on about:blank, the same
// degraded state as a refused embed.
{
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
const needed = [];
for (const el of Array.from(document.querySelectorAll('iframe'))) {
const m = MARKER.exec(el.getAttribute('src') || '');
if (!m) continue;
const text = pageTexts[m[1]];
if (typeof text === 'string') {
mountPage(el, m[2] || '', text);
} else if (window.parent !== window && !isPageRoot) {
const had = pendingFrames[m[1]];
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
if (!had) needed.push(m[1]);
}
}
for (const uuid of needed) {
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
}
}
const dead = Array.from(document.scripts);
for (const old of dead) {
const s = document.createElement('script');
for (const a of old.attributes) s.setAttribute(a.name, a.value);
s.textContent = old.textContent;
// text/babel scripts with a src: read and inline. transformScriptTags
// does XHR against the src, but blob:null/ from a file:// origin is
// silently dropped. Inlining makes it a plain inline babel script,
// which transformScriptTags handles unconditionally.
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
const pre = resourceBlobs[s.src.split('#')[0]];
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
s.removeAttribute('src');
}
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
old.replaceWith(s);
if (p) await p;
}
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
// which fired before we swapped the document. Trigger manually if present.
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
window.Babel.transformScriptTags();
}
} catch (err) {
setStatus('Error unpacking: ' + err.message);
console.error('Bundle unpack error:', err);
}
});
</script>
<script type="__bundler/manifest">
{"ace74a53-87ed-4d99-ad8b-261f3f162016":{"mime":"image/webp","compressed":false,"data":"UklGRvTaAQBXRUJQVlA4WAoAAAAgAAAAkQMArwQASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZWUDggBtkBAHBCB50BKpIDsAQ+USaPRaOiISclkmqI4AoJaUCysa0j+oobVxBkm/YlAdvU4vUrVn6p/y94G65+i5wliAHgw8xP2g6YP/E9Bf6W6fv+j48ftDHnGLqhM79X/6s2CCH3N0IXpT46b8k8mP0D/A72/z37l/sf5H0JPq7xJex/4P/1/3/qP/R/0d/h/y/tn/zPAn9o/wPQO/WP8l/5vVqgWeGqBO4q+9/+3+29Zf2L/V//L/S/AJ/Rv8B/6P8t7c/9zxXfyf/S/cz4Cf7D/r/WD/5f/96Cv2n/pew5+037adu0oBDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcEO4IdwQ7gh3BDuCHcDjwXbEMuTM5CeDg0/WbfYG+wN9gb7A32BvsDfYG+wN9gb7A32BTOiZ/s/u9oCmXy05wZPHPMfTnL633x7vzZzRd7E74ap4Mp2WUd0VyWrhkClM+spcZ1VTpB0aQjld7eiVLVtmAfWEdANCJ0eFvsDfYG+wN9gb7A32BvsDfYG+wN9gbJLYFTAG0xAzI6yHjv74Wcp42UesdhAWEll0Mz77JaUHlulxgqy+mQczgDbGkrVJcHFlufap6I14MODzHIj4F5x2QValCk7J2+xcfxzZ5PjhYM+KVx57CekqiCUSkolJRKSiUlEpKJSUSkolJRI5nMN8+PfaP/ppGXaK90lr75ZWuF9bNrauU95rEzdUb1C1qZvsyk6ee95lySUR1MMLe+EaEbA+E+8nlgXyG6OR9nH0ZnNIalWTZMQkfeTPYorOy1Sbnr6WUqF6yG3G36Um43KVRDvK9XenCUSkolJRKSiUlEpKJSUSkolJQw8SkbOxva7a/dbRhay/9p6k1ps2tLL3PsGeB3wmUc2O1r6Ko/YIvzJQc3U823Qht+gk3DOwMXSPzPd1Rvk4qGS4lADC6jMNLREiRDWbffaISXTWM1SYEApKk342E26eK2J/CS/KKc8EVwEA5hmsX9nP8COuVi3YchhVhMrBh3BDuCHcEO4IdwQ7gh3BDuBiybDoJyHc6CgsI2nhNxcK4ivBgZvAqaCNDzcelDlw/42MaI44iLd6aOUkPa8wOZjckLrCfg74lJifr6Fl7z9i11am5PIHpxW5Lh4k3BIe8znZeLqHsmSU30RQmaCNtofvX3laWBKIwywl3UO+EhtmqXCHcXy9ynkiDU0YfurvhvE7zx522PN2UlEpKJSUSkolJRKSiUlEpJMXhiiR1qJ8wdnNkUdpHrFPa6B9Trd/6xgHcCtMzbEUX/yr6C3e1ucYiafg9kimrNPFPNxmJOBhvdJ6EmCz8EO4r/DHV+z4OTGDW6wudaAGkifd4DZNTLCVHs3KSzJSwdsATv7gqklIqM1QZFTGQHReShA0/1asKVrvbdM+rofcn/JfddtaGmGOujFdGK6MV0YroxXRiujFNpLVXaxIvpx3hifcd7liCmLjfmrHyV65wDvnMSivzOk/Z1pIBa4mVBTb0y7SwY+7z8xyyMmQh7kNSCrFl216OJd35YarFe6rX2AfRpOHXPXxGUOSSr2krq58AObG7z0K9/pMo/OCzuaroHIiScJkSv32huF6MHQ1EzJJR5UpRGEUD3GcQugdd7ZLSdd7C/gNgidIbgEXlrqT32z1GaJEh6JD0SHokPRIeiQ9Eh6AoJPaRhTn8rsZCbovqMXKFY/fqFYv4kryByMluIi9b85ACjpfIpD/n+uHIEY1/vy9NW8J3mHKIVMWvDrHwvArTbWahABxEn/4MfQwOL8e7qWBz036DLHmnEb6wb/G5cLoWVPRU0HNWVPMI0D+hUvL4wSEXmqJSoqWpegUU2X3xLFRTvB+Ad8dVl8Mu0Q9+vxUoVpeb8YgLCE9kxSkIOV+AfV5eCHcEO4IdwQ7gh3BDuCFudkHNn5AZZJm1k14VrlyYOrkTelg986azm/sU7cIQaLRH3awJCRbooUEC1Ek75bzWrEehXhOOoXXASBjvqypB4A+nf45d6rSL0PzbPObtdlLIInJHdRsx3AXtWpgkXu56zIjFFO/o+8RrGqY5H+7+eh0q2fm/qMZo82AyCujHkwpC6uQnxt+ql1w7GbuDk37GsOcp5hMnB2QCSIgqpHEs1irSpVkFAqnf9QB4e32BvsDfYG+wN9gb7A3xLiZxiATkzmUJil8/0ZNBhV4qpM2PxOXeZXpwESkXrLO9Y72EVtFaCZOhDEXNyNUDS3bqkmbuaB//9p5MGu0n7KE+Sua45KtgLXknZ58fqHl3wp/qhSl1RmR4H7OCRsH1LSr9cTAqRdB1XbiZQXi8l5dgFyzr8NYMcytr0Rma04S4DasMs7H7eT6pgUE4VvYL1kuHUgW6rEZHE3cyrodkBF3xzc8cEUIbKndn0KwnRBKJSUSkolJRKSiUlEo6kOI5c0wxEFWb8fKoVSLzQhwoEV2MY9584Z4wHZdqj5hEWu76ISmrh5Uwg1qecy/b56qr4pOkVwH191QtcafaXzo+AIveV774pKr6odSyZVIg5ZIljpmLXwcOuu7Z6h73OvualT+AcqPMvtZtPHvgrjbmUdKcPrrHt+qPrbYijbyHrtylxcO+2AYr3qSd0GzGfmHYF7w1WGCGtFUQygCMWGgHldn3uCTnMm4LrWiOLj+zKHZXpLqgva1UJn0QN4gbxA3iBvEDeIG8BeGRNnhORiTRsYCTwm/Wyboie+RH2TDnrkA49wBhTEJOPZdDoPkh9bID5uGOVRyd6KJ/LZKWedSQT6xv/kd1jsNnX4Gcq+KpK+ks1iLdxAvL9dVOZgQCfbhRJ9Ud6IbNRhpNxA8CHurPmsBAQnl3oGyYIchZBfFZhEx4htp24RLwPhFdG3ePXKgWQKgdLAnipfcb4hsmdCbQcb8YTYK9jf0I+Lf8SWjeRLN123mPthgspiCA9QroxXRiujFdGK6MV0Il+A16DJE5OEN3WAAO8kM0Gy485YQCYkQdlxoxxq4P6dDkoToeua2tOq3FGHc7nVRxF9OZOwhai69+EtTg3HXkEzcTBRRL6ZG9FrDA386i697WywkkzNmh1WSxHSWZ7lLLNn1WEr7lcy0VqClSbvIvMah3YoGEMG0WJDTxY79+LgT4lHBErwe/2kgQ/nCotjPRHK+BonkFVqtkCujFdGK6MV0YroxXRfPkMZzx6+PtaN2HAqNrO/UfyYO3iuO87jvHAsmWu9zk45ub4UsguiESaAjsu1dn2Evvmgtl0hy4xNxwTlgC2xlW7XU+C2J4TxB0u55GgYDmwnDNbdsArbMWq5ZGOMAjfUFR8gH+RmxmtRJ72OF3+RbVt+d8nIILRohEJLI7oIEqjvf/t+dzC3h3MqL4+Hgpa1WQgB2/1A7fYG+wN9gb7A32BukcZ3BdYzmixZOKsedwI+gJZIEDEVvq2SYOBlfC5HWKGRKbyrrUdklIWAX0QMNmf7j/DfV98yzm0asQT1pPnq5/xg+0+QOWgJIvkpUWXp/o5qqGwgmPBfImydZJwnTmzk1+K4jAWI6RWlDOaXMTWLuMH4WlOUc1FaDooj0wDXJ7+mJnAkLpNQEYHjah7GD3EV2Yk3a6MV0YroxXRiujFbwBf8c8AZMz+AWk6vaLearIoD6L134Ryaahu7ieexHDqchVojiWPc1bOmbZjGao22xkxeqLOSMQA3uOLPoN0tJ0OsldnW3U0bW9KtZD7LP57F5tAmYRkz7j8cHvUnSzVbfIwFVz7ESOXwQ2qG1BjGg1r3M3oAzfNa2lk1krwG0o8BqOng2vr1+YPQvF+GJWIRH2QyotACNbIfhvoN570YroxXRiujFdGK6EeiOZU5zmBSHaf6HJggxi2sKqoXKnYq+OYy5NwTDmHOB0MjNm79HKeAxMnaXfn6KNXaUIuyrUAg3l0+HUpmJF7pUSrIROA4y+eqbDB4QUTuRnZszseLy5uQSnW+2kbHuwEj9WeRW8TXS5r4mhL7MkqNxC3L4lijAjLundafKs4kV3W+sqBcE8VcdxLbE36k9MaVY93F882ef8bVdIG7nQL9sfp8ThWqUlEpLine truncated
</script>
<script type="__bundler/ext_resources">
[{"id":"flagJp","uuid":"00166f10-cf88-4a70-bbd1-280330a31a6b"},{"id":"flagBr","uuid":"10064871-2703-4007-829b-9fe2ea8c9830"},{"id":"https://unpkg.com/[email protected]/umd/react.production.min.js","uuid":"02eff75b-1dc5-4564-a819-548848bc3ea6"},{"id":"https://unpkg.com/[email protected]/umd/react-dom.production.min.js","uuid":"13d22153-cc4e-43c7-b6eb-12374e56dc44"}]
</script>
<script type="__bundler/page_order">
[]
</script>
<script type="__bundler/template">
"<!DOCTYPE html>\n<html><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<script src=\"290e25e2-2bac-49af-b3d1-10cdd1ae8fbb\"><\u002Fscript>\n\n\n\n<\u002Fhead>\n<body>\n<x-dc>\n<helmet>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<style>/* vietnamese */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"e49d5281-88ab-4404-9f97-f101a278fd68\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"41d613c6-fe3e-4f74-a5ee-c82165b70e04\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Anton';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"73180845-6d9d-47f1-b736-31e7c919b715\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"024f77f2-3778-4e20-945a-dfc5de912f19\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"20b18051-9da7-42dd-a265-bdcef03ef454\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(\"a141991d-2d31-4e13-9ba3-a7e1903cb3a4\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"d996ac61-9dd4-4628-92ec-575a6a8ebab7\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"4c129e83-dcd6-40f3-9b2f-75eb52c2570f\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(\"b0420d9b-b5d9-4e9b-a506-be5ec972d317\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"643b5739-aeb8-4056-81ee-78f1669802f5\") format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"1c70acd1-9588-4697-b346-56c7675f7674\") format('woff2');\n unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Barlow Condensed';\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(\"fc1721cb-bce6-4bcc-8311-e4be3be85090\") format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152Line truncated
</script>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Score bar — exact markup</title>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@700&display=swap" rel="stylesheet">
<style>body{margin:0;background:#111;font-family:'Barlow Condensed',sans-serif}</style>
</head>
<body>
<!--
SCORE BAR — copy this markup verbatim. Do not restructure it.
Placement: inside the player container, which must be position:relative.
Data holes are marked with {{ }} — replace ONLY those, nothing else.
Hide a part by setting its element's display to none (never remove the element's siblings' flex sizing).
Rules that break the design if changed:
- the panel has transform:skewX(-9deg); ONE inner row has transform:skewX(9deg). Never counter-skew individual children.
- every text span keeps flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis
- the logo (46px) and score (60px) boxes keep flex:none
- bottom:56px keeps the bar above the 48px control band
-->
<div style="position:relative;width:1150px;height:200px;background:#1b1b1e">
<div style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;pointer-events:none">
<!-- RED / AKA -->
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ redName }}</span>
</div>
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ redClub }}</span>
<span style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#ffdf6b;color:#3a2a00;font-weight:700;flex:none">SENSHU</span>
</div>
</div>
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">{{ redLabel }}</span>
<span style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">{{ redScore }}</span>
</div>
</div>
</div>
<!-- CENTER: round + clock -->
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
<div style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">{{ roundLabel }}</div>
<div style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">{{ clock }}</div>
<div style="transform:skewX(9deg);display:flex;gap:4px">
<span style="width:20px;height:3px;background:#e8534a"></span>
<span style="width:20px;height:3px;background:#e8534a"></span>
<span style="width:20px;height:3px;background:#3a3734"></span>
</div>
</div>
<!-- BLUE / AO — exact mirror -->
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">{{ blueLabel }}</span>
<span style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">{{ blueScore }}</span>
</div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ blueName }}</span>
<span style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
</div>
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ blueClub }}</span>
</div>
</div>
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
</div>
</div>
</div>
<!-- POINT TIMELINE — one segment per point event, red/blue, #2a2724 when empty -->
<div style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#2a2724"></div>
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#e8534a"></div>
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#2a2724"></div>
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
</div>
</div>
</body>
</html>
+2537
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -9,5 +9,8 @@
"axios": "^1.6.4", "axios": "^1.6.4",
"laravel-vite-plugin": "^1.0.0", "laravel-vite-plugin": "^1.0.0",
"vite": "^5.0.0" "vite": "^5.0.0"
},
"dependencies": {
"puppeteer": "^24.43.1"
} }
} }
+418
View File
@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coach Review — Design Mockups</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css">
<style>
:root {
--bg: #0f0f0f;
--bg-panel: #181818;
--text-primary: #f1f1f1;
--text-secondary: #aaa;
--brand-red: #e61e1e;
--border: #262626;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 32px 16px;
}
.page {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 28px;
}
@media (max-width: 900px) { .page { grid-template-columns: 1fr; } }
.mockup {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 24px;
position: relative;
}
.mockup h2 {
margin: 0 0 4px;
font-size: 15px;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--text-secondary);
font-weight: 700;
}
.mockup h2 .letter {
display: inline-block;
background: var(--brand-red);
color: #fff;
width: 24px; height: 24px;
border-radius: 6px;
text-align: center;
line-height: 24px;
font-size: 13px;
margin-right: 8px;
letter-spacing: 0;
}
.mockup .vibe { font-size: 12px; color: var(--text-secondary); margin: 0 0 18px; }
.mockup .stage {
background: #0f0f0f;
border: 1px solid #202020;
border-radius: 10px;
padding: 14px;
min-height: 260px;
}
.mockup .stage > * + * { margin-top: 8px; }
.pill { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 600; text-transform: uppercase; }
/* ─── Option A: Sticky-note ─────────────────────────── */
.a-card {
display: flex; gap: 14px;
background: rgba(234, 179, 8, .05);
border: 1px solid rgba(234, 179, 8, .18);
border-left: 4px solid rgba(234, 179, 8, .7);
border-radius: 8px;
padding: 14px 16px;
}
.a-card .emoji { font-size: 26px; line-height: 1; flex-shrink: 0; }
.a-card .body { flex: 1; min-width: 0; }
.a-card .note { font-size: 15px; font-weight: 600; color: #f1f1f1; margin: 0 0 6px; }
.a-card .divider { height: 1px; background: rgba(255,255,255,.08); margin: 8px 0; }
.a-card .meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.a-card .meta-left { font-size: 12px; color: var(--text-secondary); }
.a-card .meta-left b { color: #f1f1f1; font-weight: 500; }
.a-card .meta-left .dot { margin: 0 6px; opacity: .5; }
.a-card .tools { display: inline-flex; gap: 4px; }
/* ─── Option B: Timeline dot ────────────────────────── */
.b-timeline {
position: relative;
padding-left: 32px;
}
.b-timeline::before {
content: '';
position: absolute;
left: 12px; top: 0; bottom: 0;
width: 2px; background: rgba(255,255,255,.08);
}
.b-item { position: relative; margin-bottom: 22px; }
.b-item:last-child { margin-bottom: 0; }
.b-dot {
position: absolute; left: -26px; top: 2px;
width: 12px; height: 12px; border-radius: 50%;
background: var(--brand-red);
box-shadow: 0 0 0 3px rgba(230,30,30,.2), 0 0 0 6px var(--bg-panel);
}
.b-time {
font-size: 11px; font-weight: 700; letter-spacing: .06em;
color: var(--brand-red); text-transform: uppercase;
margin-bottom: 6px;
}
.b-card {
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.06);
border-radius: 8px;
padding: 10px 12px;
display: flex; align-items: flex-start; gap: 10px;
}
.b-card .emoji { font-size: 18px; line-height: 1.2; flex-shrink: 0; }
.b-card .body { flex: 1; min-width: 0; }
.b-card .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 4px; }
.b-card .author { font-size: 11.5px; color: var(--text-secondary); }
.b-card .tools { display: inline-flex; gap: 4px; align-self: center; }
/* ─── Option C: Media card ──────────────────────────── */
.c-card {
display: flex; gap: 14px;
background: #141414;
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
padding: 12px;
}
.c-thumb {
width: 72px; height: 72px; flex-shrink: 0;
border-radius: 8px;
background: linear-gradient(135deg, rgba(230,30,30,.28), rgba(230,30,30,.08));
border: 1px solid rgba(230,30,30,.35);
display: flex; align-items: center; justify-content: center;
font-size: 30px;
}
.c-body { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
.c-head { display: flex; align-items: center; gap: 8px; }
.c-time {
font-size: 11px; font-weight: 700; letter-spacing: .04em;
color: #3ea6ff;
background: rgba(62, 166, 255, .12);
padding: 2px 8px; border-radius: 4px;
font-variant-numeric: tabular-nums;
}
.c-note { font-size: 14px; font-weight: 600; color: #f1f1f1; margin: 4px 0; line-height: 1.3; }
.c-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.c-author { font-size: 11.5px; color: var(--text-secondary); }
.c-tools { display: inline-flex; gap: 4px; }
/* ─── Option D: Chat-bubble ─────────────────────────── */
.d-row { display: flex; gap: 10px; align-items: flex-end; }
.d-avatar {
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #1f2937, #111827);
border: 2px solid var(--brand-red);
display: flex; align-items: center; justify-content: center;
font-size: 20px; line-height: 1;
box-shadow: 0 4px 12px rgba(0,0,0,.4);
}
.d-bubble {
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.08);
border-radius: 14px 14px 14px 4px;
padding: 10px 14px;
flex: 1; min-width: 0;
}
.d-bubble .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 6px; line-height: 1.35; }
.d-bubble .meta {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding-top: 6px; border-top: 1px solid rgba(255,255,255,.06);
}
.d-bubble .meta-left { font-size: 11px; color: var(--text-secondary); }
.d-bubble .meta-left b { color: #f1f1f1; font-weight: 500; }
.d-bubble .meta-left .dot { margin: 0 6px; opacity: .5; }
.d-tools { display: inline-flex; gap: 4px; }
/* ─── Shared button styling for mockups ─────────────── */
.btn-icon {
width: 26px; height: 26px; padding: 0;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 5px; border: none;
background: rgba(255,255,255,.06);
color: var(--text-primary); cursor: pointer;
font-size: 12px; line-height: 1;
transition: background .12s;
}
.btn-icon:hover { background: rgba(255,255,255,.14); }
.btn-slowmo {
width: auto; padding: 0 10px; gap: 4px;
background: rgba(230,30,30,.16); color: var(--brand-red);
font-size: 11px;
}
.btn-slowmo:hover { background: rgba(230,30,30,.28); color: #fff; }
/* header */
.header {
text-align: center; margin-bottom: 32px;
}
.header h1 {
margin: 0 0 6px;
font-size: 26px;
color: #fff;
}
.header p {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1>Coach Review Card — 4 design directions</h1>
<p>Same content, four different visual approaches. Pick one and I'll build it.</p>
</div>
<div class="page">
<!-- ═══ Option A: Sticky-note ═══════════════════════════ -->
<div class="mockup">
<h2><span class="letter">A</span> Sticky-note</h2>
<p class="vibe">Warm amber tint, chunky emoji, feels like a coach's Post-it on a locker.</p>
<div class="stage">
<div class="a-card">
<span class="emoji">👀</span>
<div class="body">
<p class="note">Keep Your Hand Up</p>
<div class="divider"></div>
<div class="meta">
<div class="meta-left">
<b>Ghassan</b><span class="dot">·</span>00:48 – 00:52
</div>
<div class="tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
<div class="a-card">
<span class="emoji">🤔</span>
<div class="body">
<p class="note">Missed the counter opportunity — great angle but no follow-up</p>
<div class="divider"></div>
<div class="meta">
<div class="meta-left">
<b>Coach Sara</b><span class="dot">·</span>02:05 – 02:20
</div>
<div class="tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ Option B: Timeline dot ═══════════════════════════ -->
<div class="mockup">
<h2><span class="letter">B</span> Timeline dot</h2>
<p class="vibe">Vertical spine with red dots. Reads like a session log — nice for many notes.</p>
<div class="stage">
<div class="b-timeline">
<div class="b-item">
<span class="b-dot"></span>
<div class="b-time">00:48 — 00:52</div>
<div class="b-card">
<span class="emoji">👀</span>
<div class="body">
<p class="note">Keep Your Hand Up</p>
<div class="author">Ghassan</div>
</div>
<div class="tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
<div class="b-item">
<span class="b-dot"></span>
<div class="b-time">02:05 — 02:20</div>
<div class="b-card">
<span class="emoji">🤔</span>
<div class="body">
<p class="note">Missed the counter — no follow-up after the angle change</p>
<div class="author">Coach Sara</div>
</div>
<div class="tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
<div class="b-item">
<span class="b-dot"></span>
<div class="b-time">03:25</div>
<div class="b-card">
<span class="emoji">😄</span>
<div class="body">
<p class="note">Excellent exit and re-entry — perfect execution</p>
<div class="author">Ghassan</div>
</div>
<div class="tools">
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ Option C: Media card ═══════════════════════════ -->
<div class="mockup">
<h2><span class="letter">C</span> Media card</h2>
<p class="vibe">Big emoji tile on the left, blue time badge — feels like a video-platform "content card".</p>
<div class="stage">
<div class="c-card">
<div class="c-thumb">👀</div>
<div class="c-body">
<div class="c-head"><span class="c-time">00:48 → 00:52</span></div>
<p class="c-note">Keep Your Hand Up</p>
<div class="c-foot">
<span class="c-author">Ghassan</span>
<div class="c-tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
<div class="c-card">
<div class="c-thumb">🤔</div>
<div class="c-body">
<div class="c-head"><span class="c-time">02:05 → 02:20</span></div>
<p class="c-note">Missed the counter — no follow-up after the angle change</p>
<div class="c-foot">
<span class="c-author">Coach Sara</span>
<div class="c-tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ Option D: Chat-bubble ═══════════════════════════ -->
<div class="mockup">
<h2><span class="letter">D</span> Chat-bubble</h2>
<p class="vibe">Emoji as coach's avatar + message bubble. Feels like WhatsApp — casual + dense.</p>
<div class="stage">
<div class="d-row">
<div class="d-avatar">👀</div>
<div class="d-bubble">
<p class="note">Keep Your Hand Up</p>
<div class="meta">
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>00:48–00:52</div>
<div class="d-tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
<div class="d-row" style="margin-top: 14px;">
<div class="d-avatar">🤔</div>
<div class="d-bubble">
<p class="note">Missed the counter — no follow-up after the angle change</p>
<div class="meta">
<div class="meta-left"><b>Coach Sara</b><span class="dot">·</span>02:05–02:20</div>
<div class="d-tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
<div class="d-row" style="margin-top: 14px;">
<div class="d-avatar">😄</div>
<div class="d-bubble">
<p class="note">Excellent exit and re-entry — perfect execution</p>
<div class="meta">
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>03:25</div>
<div class="d-tools">
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+21 -5
View File
@@ -351,24 +351,40 @@
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
</div> </div>
<div class="al-page-links"> <div class="al-page-links">
{{-- First --}}
@if($logs->onFirstPage()) @if($logs->onFirstPage())
<span class="disabled"><i class="bi bi-chevron-left"></i></span> <span class="disabled" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></span>
@else @else
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a> <a href="{{ $logs->url(1) }}" aria-label="First page" title="First page"><i class="bi bi-chevron-double-left" aria-hidden="true"></i></a>
@endif
{{-- Prev --}}
@if($logs->onFirstPage())
<span class="disabled" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></span>
@else
<a href="{{ $logs->previousPageUrl() }}" aria-label="Previous page" title="Previous page"><i class="bi bi-chevron-left" aria-hidden="true"></i></a>
@endif @endif
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url) @foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
@if($page == $logs->currentPage()) @if($page == $logs->currentPage())
<span class="active">{{ $page }}</span> <span class="active" aria-current="page">{{ $page }}</span>
@else @else
<a href="{{ $url }}">{{ $page }}</a> <a href="{{ $url }}">{{ $page }}</a>
@endif @endif
@endforeach @endforeach
{{-- Next --}}
@if($logs->hasMorePages()) @if($logs->hasMorePages())
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a> <a href="{{ $logs->nextPageUrl() }}" aria-label="Next page" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></a>
@else @else
<span class="disabled"><i class="bi bi-chevron-right"></i></span> <span class="disabled" title="Next page"><i class="bi bi-chevron-right" aria-hidden="true"></i></span>
@endif
{{-- Last --}}
@if($logs->currentPage() < $logs->lastPage())
<a href="{{ $logs->url($logs->lastPage()) }}" aria-label="Last page" title="Last page ({{ $logs->lastPage() }})"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></a>
@else
<span class="disabled" title="Last page"><i class="bi bi-chevron-double-right" aria-hidden="true"></i></span>
@endif @endif
</div> </div>
</div> </div>
+26 -7
View File
@@ -292,13 +292,16 @@
{{-- Ready Videos --}} {{-- Ready Videos --}}
<div class="col-6 col-lg-4 col-xl-2"> <div class="col-6 col-lg-4 col-xl-2">
<div class="stat-card clickable-card" onclick="openDashModal('Live Videos','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view live videos"> <div class="stat-card clickable-card" onclick="openDashModal('Ready','videos',{status:'ready'},'bi-check-circle-fill')" title="Click to view ready videos">
<div class="stat-card-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div> <div class="stat-card-accent" style="background: linear-gradient(90deg,#22c55e,#4ade80);"></div>
<div class="stat-card-top"> <div class="stat-card-top">
<div class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div> <div class="stat-icon" style="background:rgba(34,197,94,.15);color:#4ade80;"><i class="bi bi-check-circle-fill"></i></div>
</div> </div>
<div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</div> <div class="stat-value">{{ number_format($videosByStatus->get('ready', 0)) }}</div>
<div class="stat-label">Live Videos</div> {{-- "Ready" matches the DB status column; "Live Videos" was misleading
— nothing streams live from this card, it just counts videos that
finished processing successfully. --}}
<div class="stat-label">Ready</div>
@php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp @php $failPct = $stats['totalVideos'] > 0 ? round(($failedCount/$stats['totalVideos'])*100,1) : 0; @endphp
<div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}"> <div class="stat-growth {{ $failPct > 5 ? 'down' : 'flat' }}">
<i class="bi bi-x-circle"></i> {{ $failedCount }} failed <i class="bi bi-x-circle"></i> {{ $failedCount }} failed
@@ -479,10 +482,10 @@
@else @else
@php $maxViews = $viewsByCountry->first()->total; @endphp @php $maxViews = $viewsByCountry->first()->total; @endphp
@foreach($viewsByCountry as $i => $row) @foreach($viewsByCountry as $i => $row)
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? $row->country ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')"> <div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
<div class="country-rank">{{ $i + 1 }}</div> <div class="country-rank">{{ $i + 1 }}</div>
<div class="country-flag" title="{{ $row->country }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div> <div class="country-flag" title="{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</div> <div class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}</div>
<div class="country-bar-wrap"> <div class="country-bar-wrap">
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div> <div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
</div> </div>
@@ -741,8 +744,21 @@ new Chart(document.getElementById('typeChart'), {
// ── Country Chart ─────────────────────────────────────────────── // ── Country Chart ───────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty()) @if($viewsByCountry->isNotEmpty())
@php
// Blade's @json() parser can't handle a multi-line arrow function with
// a `[` array literal inside its body — the directive reads until the
// closing paren and sees the inner `[` as an unmatched bracket. Build
// the array in PHP first, then hand the plain variable to @json().
$countryChartData = $viewsByCountry->map(function ($r) {
return [
'country' => $r->country,
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
'total' => $r->total,
];
})->values();
@endphp
(function() { (function() {
const countryData = @json($viewsByCountry); const countryData = @json($countryChartData);
const labels = countryData.map(r => r.country_name || r.country || ''); const labels = countryData.map(r => r.country_name || r.country || '');
const values = countryData.map(r => r.total); const values = countryData.map(r => r.total);
const maxVal = Math.max(...values); const maxVal = Math.max(...values);
@@ -1015,7 +1031,10 @@ if (typeChartInst) {
// Country bar chart click // Country bar chart click
const countryChartInst = Chart.getChart('countryChart'); const countryChartInst = Chart.getChart('countryChart');
if (countryChartInst) { if (countryChartInst) {
const countryData = @json($viewsByCountry ?? collect()); const countryData = @json(($viewsByCountry ?? collect())->map(fn($r) => [
'country' => $r->country,
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
])->values());
countryChartInst.options.onClick = function(evt, elements) { countryChartInst.options.onClick = function(evt, elements) {
if (!elements.length) return; if (!elements.length) return;
const row = countryData[elements[0].index]; const row = countryData[elements[0].index];
+56 -8
View File
@@ -64,26 +64,74 @@
</button> </button>
</form> </form>
{{-- Log lines --}} {{-- Log lines, grouped — each entry's stack trace (lines beginning with
`#N `, `[stacktrace]`, or `Stack trace:`) is folded into its parent
header line. Headers show their level color; stack trace lines
render in a muted indent and preserve their natural #0 → #N order
inside each group even when the overall list is newest-first. --}}
@if(empty($lines)) @if(empty($lines))
<div style="text-align: center; padding: 60px; color: var(--text-secondary);"> <div style="text-align: center; padding: 60px; color: var(--text-secondary);">
<i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;"></i> <i class="bi bi-check-circle" style="font-size: 48px; color: #22c55e; display: block; margin-bottom: 12px;" aria-hidden="true"></i>
No log entries found{{ $filter || $level ? ' matching your filter' : '' }}. No log entries found{{ $filter || $level ? ' matching your filter' : '' }}.
</div> </div>
@else @else
@php
// Group the flat list of lines into [{header, trace[]}, …].
// Any line that is NOT a header starts/continues the current
// group's trace. Lines that look like a header (ERROR/WARNING/
// INFO/DEBUG) start a new group.
$groups = [];
$current = null;
$isHeader = fn ($l) => (bool) preg_match('/\.(ERROR|WARNING|INFO|DEBUG|CRITICAL|ALERT|EMERGENCY|NOTICE):/', $l);
foreach ($lines as $line) {
if ($isHeader($line) || $current === null) {
if ($current) $groups[] = $current;
$current = ['header' => $line, 'trace' => []];
} else {
$current['trace'][] = $line;
}
}
if ($current) $groups[] = $current;
@endphp
<div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;"> <div id="logOutput" style="font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; background: #0a0a0a; border: 1px solid var(--border-color); border-radius: 10px; overflow: auto; max-height: calc(100vh - 260px); padding: 12px 16px;">
@foreach($lines as $line) @foreach($groups as $gi => $g)
@php @php
$color = '#aaa'; $color = '#aaa';
if (str_contains($line, '.ERROR:')) $color = '#f87171'; if (str_contains($g['header'], '.ERROR:') || str_contains($g['header'], '.CRITICAL:')) $color = '#f87171';
elseif (str_contains($line, '.WARNING:')) $color = '#fbbf24'; elseif (str_contains($g['header'], '.WARNING:')) $color = '#fbbf24';
elseif (str_contains($line, '.INFO:')) $color = '#6ee7b7'; elseif (str_contains($g['header'], '.INFO:')) $color = '#6ee7b7';
elseif (str_contains($line, '.DEBUG:')) $color = '#93c5fd'; elseif (str_contains($g['header'], '.DEBUG:')) $color = '#93c5fd';
$hasTrace = !empty($g['trace']);
@endphp @endphp
<div class="log-line" style="color: {{ $color }}; border-bottom: 1px solid #1a1a1a; padding: 3px 0; white-space: pre-wrap; word-break: break-all;">{{ $line }}</div> <div class="log-group" style="border-bottom: 1px solid #1a1a1a; padding: 4px 0;">
<div class="log-line log-head" style="color: {{ $color }}; white-space: pre-wrap; word-break: break-all; display:flex; gap:8px; align-items:flex-start;">
@if($hasTrace)
<button type="button"
onclick="this.closest('.log-group').classList.toggle('open')"
aria-label="Toggle stack trace"
style="flex:none;background:none;border:none;color:inherit;font-family:inherit;font-size:11px;cursor:pointer;padding:0 4px;opacity:.75;">
<i class="bi bi-chevron-right log-caret" aria-hidden="true"></i>
</button>
@else
<span style="flex:none;display:inline-block;width:20px;"></span>
@endif
<span>{{ $g['header'] }}</span>
</div>
@if($hasTrace)
<div class="log-trace" style="display:none; padding: 4px 0 4px 28px; color: #6b7280; white-space: pre-wrap; word-break: break-all;">
@foreach($g['trace'] as $tl)
<div>{{ $tl }}</div>
@endforeach @endforeach
</div> </div>
@endif @endif
</div>
@endforeach
</div>
<style>
.log-group.open .log-trace { display: block !important; }
.log-group.open .log-caret::before { content: "\F282"; /* bi-chevron-down */ }
</style>
@endif
</div> </div>
@@ -0,0 +1,40 @@
@extends('admin.layout')
@section('title', 'OG Previews')
@section('content')
<div style="padding: 24px;">
<h1 style="margin: 0 0 8px; color: #fff;">Generated VS Cards</h1>
<p style="color: #aaa; margin: 0 0 20px;">
These are the 1200×630 PNGs that WhatsApp, Facebook, and Twitter show
when a sports match video is shared. Generated on save; regenerated on
edit. Filename is <code>match-{video_id}-{updated_at}.png</code>.
</p>
@if($files->isEmpty())
<p style="color: #aaa;">No previews cached yet. Save a match video to generate one.</p>
@else
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px;">
@foreach($files as $f)
<div style="background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden;">
<a href="{{ route('admin.og-previews.file', $f['name']) }}" target="_blank" style="display: block; background: #000;">
<img src="{{ route('admin.og-previews.file', $f['name']) }}"
alt="{{ $f['name'] }}"
style="display: block; width: 100%; height: auto;">
</a>
<div style="padding: 12px 14px; color: #ccc; font-size: 13px;">
<div style="font-weight: 600; color: #fff;">Video #{{ $f['id'] }}</div>
<div style="color: #888; margin-top: 4px; font-family: monospace; font-size: 11px; word-break: break-all;">
{{ $f['name'] }}
</div>
<div style="color: #888; margin-top: 4px;">
{{ number_format($f['size'] / 1024, 1) }} KB &middot;
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endsection
@@ -294,7 +294,7 @@ function flagEmoji(string $code): string {
<div class="country-row"> <div class="country-row">
<span class="country-rank">{{ $i + 1 }}</span> <span class="country-rank">{{ $i + 1 }}</span>
<span class="country-flag">{!! flagEmoji($row->country) !!}</span> <span class="country-flag">{!! flagEmoji($row->country) !!}</span>
<span class="country-name">{{ $row->country_name ?? $row->country }}</span> <span class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) }}</span>
<div class="country-bar-wrap"> <div class="country-bar-wrap">
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div> <div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
</div> </div>
@@ -423,7 +423,7 @@ function flagEmoji(string $code): string {
<td> <td>
@if($view->country) @if($view->country)
{!! flagEmoji($view->country) !!} {!! flagEmoji($view->country) !!}
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span> <span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? \App\Data\Countries::name($view->country) }}</span>
@else @else
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span> <span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
@endif @endif
@@ -498,7 +498,7 @@ new Chart(document.getElementById('dailyChart'), {
// ── Country chart ─────────────────────────────────────────────────────────── // ── Country chart ───────────────────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty()) @if($viewsByCountry->isNotEmpty())
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!}; const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? \App\Data\Countries::name($r->country)))->values()) !!};
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!}; const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
const countryMax = Math.max(...countryData); const countryMax = Math.max(...countryData);
+16 -3
View File
@@ -125,12 +125,25 @@ $adminNeedsOtp = auth()->user()->two_factor_enabled &&
</div> </div>
@endif @endif
<div style="min-width:0;"> <div style="min-width:0;">
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"> {{-- Full title in the title attribute so the
admin can hover and read the whole thing
when the visible row ellipsizes. --}}
<div style="font-weight:500;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
title="{{ $video->title }}">
{{ $video->title }} {{ $video->title }}
</div> </div>
@if($video->description) @if($video->description)
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"> @php
{{ Str::limit($video->description, 55) }} // Descriptions are stored as sanitized rich HTML
// (see HtmlSanitizer). Blade's {{ }} would show
// the escaped tags verbatim in the admin table,
// so strip them to a plain-text preview — and
// put the full stripped text in the tooltip too.
$descPlain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $video->description)));
@endphp
<div style="font-size:11px;color:var(--text-2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;"
title="{{ $descPlain }}">
{{ Str::limit($descPlain, 55) }}
</div> </div>
@endif @endif
</div> </div>
+4 -1
View File
@@ -38,7 +38,10 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')" required /> {{-- COPPA / platform minimum age: 13 at sign-up. maxYear caps
the year dropdown at (this year − 13). --}}
<x-date-picker name="birthday" label="Date of Birth" :value="old('birthday')"
:max-year="(int) date('Y') - 13" required />
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -122,7 +122,12 @@
/* ── Open / close ── */ /* ── Open / close ── */
function openModal() { function openModal() {
document.getElementById('tcOverlay_' + id).classList.add('open'); var ov = document.getElementById('tcOverlay_' + id);
// Reparent to <body> at open time — a transformed ancestor (e.g. an
// opening Bootstrap modal) would otherwise constrain our position:fixed
// overlay to its own stacking context and clip Cropme's canvas.
if (ov && ov.parentNode !== document.body) document.body.appendChild(ov);
ov.classList.add('open');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} }
window['openCropperModal_' + id] = openModal; window['openCropperModal_' + id] = openModal;
@@ -135,42 +140,161 @@
if (e.target === this) window.closeCropperModal(id); if (e.target === this) window.closeCropperModal(id);
}); });
/* ── Preload a File into the cropper ── */ /* ── Preload a File into the cropper ─────────────────────────────────
* Uses URL.createObjectURL (fast, memory-friendly) instead of FileReader
* data URLs, which are slow / can silently fail with large images.
*/
function preloadFile(file) { function preloadFile(file) {
if (!file) return; if (!file) return;
originalFile = file; originalFile = file;
document.getElementById('tcFileName_' + id).textContent = file.name; document.getElementById('tcFileName_' + id).textContent = file.name;
var reader = new FileReader(); var url = URL.createObjectURL(file);
reader.onload = function (e) { initCropper(e.target.result); }; initCropper(url);
reader.readAsDataURL(file);
} }
window['tcPreload_' + id] = preloadFile; window['tcPreload_' + id] = preloadFile;
function initCropper(dataUrl) { /*
* ───────────────────────────────────────────────────────────────────
* DIY cropper — no library. Renders the loaded image in the canvas
* with a viewport frame of the desired vw:vh aspect (dashed red
* border, dark scrim outside via box-shadow). Pan by dragging the
* image, zoom via the slider (or wheel), rotate via the slider.
* Save clips the visible viewport region to an offscreen canvas.
* ───────────────────────────────────────────────────────────────────
*/
var _st = null; // cropper state — replaces Cropme's `cropperInst`
function initCropper(imageUrl) {
document.getElementById('tcPlaceholder_' + id).style.display = 'none'; document.getElementById('tcPlaceholder_' + id).style.display = 'none';
document.getElementById('tcSaveBtn_' + id).disabled = false; document.getElementById('tcSaveBtn_' + id).disabled = false;
document.getElementById('tcAsIsBtn_' + id).disabled = false; document.getElementById('tcAsIsBtn_' + id).disabled = false;
var canvas = document.getElementById('tcCanvas_' + id); var canvas = document.getElementById('tcCanvas_' + id);
if (cropperInst) { cropperInst.destroy(); cropperInst = null; } canvas.innerHTML = '';
canvas.className = 'tc-canvas tc-canvas-active';
cropperInst = new Cropme(canvas, { // ── layout: image element + viewport frame (frame drawn via box-shadow scrim) ──
container: { width: '100%', height: 320 }, var img = document.createElement('img');
viewport: { img.alt = '';
width: vw, height: vh, img.style.cssText =
type: shape, 'position:absolute;top:50%;left:50%;transform-origin:center center;' +
border: { enable: true, width: 2, color: '#ef4444' } 'user-select:none;-webkit-user-drag:none;pointer-events:none;max-width:none;';
}, var frame = document.createElement('div');
transformOrigin: 'viewport', frame.style.cssText =
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false }, 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +
rotation: { enable: true, slider: false } 'border:2px dashed #ef4444;box-sizing:border-box;pointer-events:none;' +
}); 'box-shadow:0 0 0 2000px rgba(0,0,0,.55);' +
cropperInst.bind({ url: dataUrl }).then(function () { (shape === 'circle' ? 'border-radius:50%;' : '');
document.getElementById('tcZoom_' + id).value = 0; canvas.appendChild(img);
document.getElementById('tcRot_' + id).value = 0; canvas.appendChild(frame);
});
// Reset sliders
var zoomEl = document.getElementById('tcZoom_' + id);
var rotEl = document.getElementById('tcRot_' + id);
zoomEl.value = 0; rotEl.value = 0;
// Init state
_st = {
img: img, frame: frame, canvas: canvas,
imgW: 0, imgH: 0,
canvasW: 0, canvasH: 0,
vpW: 0, vpH: 0, // viewport display size (px inside canvas)
baseScale: 1, // scale that makes image "cover" the viewport
scale: 1, deg: 0, tx: 0, ty: 0,
ready: false,
imageUrl: imageUrl,
};
img.onload = function () {
_st.imgW = img.naturalWidth || 1;
_st.imgH = img.naturalHeight || 1;
layoutCropper();
_st.ready = true;
};
img.onerror = function (e) {
console.error('[image-cropper]', id, 'image failed to load', e);
};
img.src = imageUrl;
// If already cached, onload may have fired synchronously — no-op if not.
if (img.complete && img.naturalWidth > 0) img.onload();
} }
// Fit the viewport inside the canvas, then fit the image to "cover" the viewport.
function layoutCropper() {
var s = _st; if (!s) return;
s.canvasW = s.canvas.clientWidth || 320;
s.canvasH = s.canvas.clientHeight || 320;
// Viewport: same aspect as vw:vh, fits inside canvas with ~10% padding.
var pad = 20;
var maxW = s.canvasW - pad * 2;
var maxH = s.canvasH - pad * 2;
var vAspect = vw / vh;
if (maxW / vAspect <= maxH) { s.vpW = maxW; s.vpH = Math.round(maxW / vAspect); }
else { s.vpH = maxH; s.vpW = Math.round(maxH * vAspect); }
s.frame.style.width = s.vpW + 'px';
s.frame.style.height = s.vpH + 'px';
// Base scale: image must at least cover the viewport.
s.baseScale = Math.max(s.vpW / s.imgW, s.vpH / s.imgH);
s.scale = s.baseScale;
s.tx = 0; s.ty = 0; s.deg = 0;
applyTransform();
}
function applyTransform() {
var s = _st; if (!s) return;
// Image is centered via top/left 50% translate + own translate + scale + rotate.
// Set explicit width so scale multiplies pixels rather than object-fit.
s.img.style.width = s.imgW + 'px';
s.img.style.height = s.imgH + 'px';
s.img.style.marginLeft = (-s.imgW / 2) + 'px';
s.img.style.marginTop = (-s.imgH / 2) + 'px';
s.img.style.transform =
'translate(' + s.tx + 'px,' + s.ty + 'px)' +
' rotate(' + s.deg + 'deg)' +
' scale(' + s.scale + ')';
}
// Drag to pan (mouse + touch)
(function bindPan() {
var canvas = document.getElementById('tcCanvas_' + id);
var dragging = false, sx = 0, sy = 0, otx = 0, oty = 0;
function down(e) {
if (!_st || !_st.ready) return;
dragging = true;
var p = e.touches ? e.touches[0] : e;
sx = p.clientX; sy = p.clientY; otx = _st.tx; oty = _st.ty;
canvas.style.cursor = 'grabbing';
e.preventDefault();
}
function move(e) {
if (!dragging || !_st) return;
var p = e.touches ? e.touches[0] : e;
_st.tx = otx + (p.clientX - sx);
_st.ty = oty + (p.clientY - sy);
applyTransform();
}
function up() { dragging = false; canvas.style.cursor = 'grab'; }
canvas.addEventListener('mousedown', down);
canvas.addEventListener('touchstart', down, { passive: false });
window.addEventListener('mousemove', move);
window.addEventListener('touchmove', move, { passive: false });
window.addEventListener('mouseup', up);
window.addEventListener('touchend', up);
// Wheel zoom
canvas.addEventListener('wheel', function (e) {
if (!_st || !_st.ready) return;
e.preventDefault();
var delta = e.deltaY < 0 ? 1.08 : 1 / 1.08;
_st.scale = Math.max(_st.baseScale, Math.min(_st.baseScale * 6, _st.scale * delta));
var pct = Math.round(((_st.scale - _st.baseScale) / (_st.baseScale * 5)) * 100);
document.getElementById('tcZoom_' + id).value = pct;
applyTransform();
}, { passive: false });
canvas.style.cursor = 'grab';
})();
/* ── Internal file input (the "Choose image" button inside the modal) ── */ /* ── Internal file input (the "Choose image" button inside the modal) ── */
document.getElementById('tcInput_' + id).addEventListener('change', function () { document.getElementById('tcInput_' + id).addEventListener('change', function () {
if (this.files && this.files[0]) preloadFile(this.files[0]); if (this.files && this.files[0]) preloadFile(this.files[0]);
@@ -182,14 +306,16 @@
/* ── Zoom / rotate sliders ── */ /* ── Zoom / rotate sliders ── */
document.getElementById('tcZoom_' + id).addEventListener('input', function () { document.getElementById('tcZoom_' + id).addEventListener('input', function () {
if (!cropperInst || !cropperInst.properties.image) return; if (!_st || !_st.ready) return;
// 0 → baseScale (fit), 100 → baseScale * 6 (max zoom-in)
var p = parseFloat(this.value) / 100; var p = parseFloat(this.value) / 100;
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p; _st.scale = _st.baseScale + (_st.baseScale * 5) * p;
var s = cropperInst.properties; applyTransform();
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
}); });
document.getElementById('tcRot_' + id).addEventListener('input', function () { document.getElementById('tcRot_' + id).addEventListener('input', function () {
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10)); if (!_st || !_st.ready) return;
_st.deg = parseInt(this.value, 10) || 0;
applyTransform();
}); });
/* ── Helpers ── */ /* ── Helpers ── */
@@ -250,24 +376,64 @@
.catch(onFail); .catch(onFail);
} }
/* ── Crop & Save ── */ /* ── Crop & Save ─────────────────────────────────────────────────────
* Renders the visible viewport region to an offscreen canvas at the
* output size, then returns the result as a JPEG data URL.
* The image is transformed by (translate → rotate → scale) — the
* offscreen canvas replays those same transforms scaled up to the
* output resolution so nothing is lost.
*/
function renderCroppedBase64() {
var s = _st; if (!s || !s.ready) return null;
var outW = outputWidth > 0 ? outputWidth : vw;
var outH = Math.round(outW * (vh / vw));
var cv = document.createElement('canvas');
cv.width = outW;
cv.height = outH;
var ctx = cv.getContext('2d');
// Circle crop mask (matches the round viewport look on save)
if (shape === 'circle') {
ctx.save();
ctx.beginPath();
ctx.arc(outW / 2, outH / 2, Math.min(outW, outH) / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
}
// 1 display px inside the viewport == (outW / s.vpW) output px
var pxRatio = outW / s.vpW;
ctx.translate(outW / 2, outH / 2);
ctx.scale(pxRatio, pxRatio);
ctx.translate(s.tx, s.ty);
ctx.rotate(s.deg * Math.PI / 180);
ctx.scale(s.scale, s.scale);
ctx.drawImage(s.img, -s.imgW / 2, -s.imgH / 2);
if (shape === 'circle') ctx.restore();
return cv.toDataURL('image/jpeg', 0.92);
}
window['tcSave_' + id] = function () { window['tcSave_' + id] = function () {
if (!cropperInst) return; if (!_st || !_st.ready) return;
var btn = document.getElementById('tcSaveBtn_' + id); var btn = document.getElementById('tcSaveBtn_' + id);
var txt = document.getElementById('tcSaveBtnText_' + id); var txt = document.getElementById('tcSaveBtnText_' + id);
btn.disabled = true; btn.disabled = true;
txt.textContent = 'Saving…'; txt.textContent = 'Saving…';
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' }; var base64 = renderCroppedBase64();
if (!base64) { btn.disabled = false; txt.textContent = 'Crop & Save'; return; }
cropperInst.crop(cropOpts).then(function (base64) {
if (isCallbackMode) { if (isCallbackMode) {
var cbName = originalFile ? originalFile.name : 'cropped.png'; var cbName = originalFile ? originalFile.name : 'cropped.jpg';
deliverResult(base64ToFile(base64, cbName)); deliverResult(base64ToFile(base64, cbName));
return; return;
} }
if (isFormMode) { if (isFormMode) {
var fname = originalFile ? originalFile.name : 'cropped.png'; var fname = originalFile ? originalFile.name : 'cropped.jpg';
setOnTargetInput(base64ToFile(base64, fname)); setOnTargetInput(base64ToFile(base64, fname));
window.closeCropperModal(id); window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success'); if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
@@ -286,7 +452,6 @@
txt.textContent = 'Crop & Save'; txt.textContent = 'Crop & Save';
}); });
} }
});
}; };
/* ── Upload as-is ── */ /* ── Upload as-is ── */
@@ -3,9 +3,38 @@
/videos?filter=playlists. Wrapped in @once so it emits only once. --}} /videos?filter=playlists. Wrapped in @once so it emits only once. --}}
@once @once
<style> <style>
/* Base styles for video card */ /* Base styles for video card — every card in the grid renders at the same
overall size regardless of type (video / shorts / match / playlist). The
grid handles equal widths; height parity is enforced here by making the
card a column-flex container, the thumb a strict 16/9 box, and every
text row a fixed line-height so missing channel/meta rows never shrink
the card. */
.yt-video-card { .yt-video-card {
cursor: pointer; cursor: pointer;
display: flex;
flex-direction: column;
}
/* When the 3-dot menu is open, promote this card above its siblings so
the dropdown-menu (Bootstrap default z-index 1000) isn't visually
covered by later cards in the grid. Cards are position:static by
default, so siblings later in the DOM naturally paint on top even
though the dropdown itself has a high z-index — the stacking is
compared at the card level, not the dropdown level. `:has()` triggers
only while a menu is `.show`ing, so this doesn't affect the normal
grid layout. */
.yt-video-card:has(.dropdown-menu.show) {
position: relative;
z-index: 50;
}
/* Same fix for the ⋮ menu container — .yt-video-info has overflow:hidden
(to hard-cap card height for grid parity), which was clipping the
dropdown's bottom edge. Allow the dropdown to escape by giving its
wrapper a stacking context and letting overflow leak upward from that
ancestor only when a menu is open. */
.yt-video-card .yt-video-info:has(.dropdown-menu.show) {
overflow: visible;
} }
.yt-video-card .yt-video-thumb { .yt-video-card .yt-video-thumb {
@@ -61,7 +90,11 @@
.yt-video-card .yt-video-thumb video { .yt-video-card .yt-video-thumb video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; /* Match the still-image thumbnail: always cover the 16/9 box so
portrait and landscape sources render at the same visible size,
instead of letterboxing (which made portrait previews look
smaller than landscape ones). */
object-fit: cover;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -212,6 +245,22 @@
display: flex; display: flex;
margin-top: 12px; margin-top: 12px;
gap: 12px; gap: 12px;
/* Reserve a floor for the info block so every card lands at the same
height when titles are short. Two-line titles are allowed to grow
the block vertically — overflow:visible so no row is hidden. */
min-height: 76px;
}
/* On channel pages the avatar + channel-name row is suppressed by the
component's hideChannelInfo prop; drop the floor so the card doesn't
leave empty space where the row used to be. */
.yt-video-card .yt-video-info-no-channel { min-height: 60px; }
.yt-video-card .yt-video-details {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
} }
.yt-video-card .yt-channel-icon { .yt-video-card .yt-channel-icon {
@@ -237,20 +286,32 @@
font-weight: 500; font-weight: 500;
color: #fff; color: #fff;
margin: 0 0 4px; margin: 0 0 4px;
line-height: 1.3;
/* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
words (e.g. "SPARTAWORRIOR" or "EVIS ― 帰還を待つ覇者の鼓動")
break mid-word so they can't widen the card beyond its grid track.
Line-clamp replaces the single-line ellipsis the old rule used. */
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
line-height: 1.3; overflow-wrap: anywhere;
word-break: break-word;
max-height: calc(1.3em * 2);
} }
.yt-video-card .yt-video-title a { .yt-video-card .yt-video-title a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
display: flex; display: block;
align-items: baseline; max-width: 100%;
gap: 5px; overflow-wrap: anywhere;
word-break: break-word;
} }
/* Inline children (flags, corner-colored fighter spans) must not break
the single-line layout. */
.yt-video-card .yt-video-title a > * { vertical-align: middle; }
.yt-video-card .yt-video-title a .fi { display: inline-block; }
.vc-lang-flag { .vc-lang-flag {
display: inline-block; display: inline-block;
width: 16px; width: 16px;
@@ -274,6 +335,8 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
line-height: 22px;
height: 22px;
} }
.yt-video-card a.yt-channel-name { .yt-video-card a.yt-channel-name {
@@ -390,305 +453,5 @@
height: 24px; height: 24px;
} }
/* Cute Edit Modal Styles */
.cute-edit-modal {
max-width: 380px;
margin: auto;
}
.cute-edit-content {
background: linear-gradient(145deg, #1f1f1f 0%, #2a2a2a 100%);
border: 1px solid #3a3a3a;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.cute-edit-header {
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
padding: 16px 20px;
display: flex;
align-items: center;
gap: 10px;
position: relative;
}
.cute-edit-icon {
font-size: 20px;
}
.cute-edit-header h5 {
margin: 0;
color: white;
font-size: 16px;
font-weight: 600;
}
.btn-close-cute {
position: absolute;
right: 12px;
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.btn-close-cute:hover {
background: rgba(255,255,255,0.3);
}
.cute-edit-body {
padding: 20px;
}
.cute-form-group {
margin-bottom: 16px;
}
.cute-form-group label {
display: flex;
align-items: center;
gap: 6px;
color: #ccc;
font-size: 13px;
margin-bottom: 8px;
font-weight: 500;
}
.cute-form-group label i {
color: #ff6b8a;
font-size: 14px;
}
.cute-input, .cute-textarea {
width: 100%;
background: #151515;
border: 1px solid #333;
border-radius: 10px;
padding: 10px 12px;
color: #fff;
font-size: 13px;
transition: all 0.2s;
}
.cute-input:focus, .cute-textarea:focus {
outline: none;
border-color: #ff6b8a;
box-shadow: 0 0 0 3px rgba(255, 107, 138, 0.15);
}
.cute-input::placeholder, .cute-textarea::placeholder {
color: #555;
}
/* Type Options */
.cute-type-options, .cute-privacy-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cute-type-option, .cute-privacy-option {
flex: 1;
min-width: 80px;
cursor: pointer;
}
.cute-type-option input, .cute-privacy-option input {
display: none;
}
.cute-type-option span, .cute-privacy-option span {
display: block;
padding: 8px 10px;
background: #151515;
border: 1px solid #333;
border-radius: 8px;
font-size: 12px;
color: #aaa;
text-align: center;
transition: all 0.2s;
}
.cute-type-option:hover span, .cute-privacy-option:hover span {
border-color: #555;
background: #1a1a1a;
}
.cute-type-option.active span, .cute-privacy-option.active span {
background: rgba(255, 107, 138, 0.15);
border-color: #ff6b8a;
color: #ff6b8a;
}
/* Shorts Toggle in Edit Modal */
.cute-shorts-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
margin-top: 8px;
}
.cute-shorts-toggle input {
opacity: 0;
width: 0;
height: 0;
}
.cute-shorts-slider {
position: relative;
width: 44px;
height: 24px;
background-color: #333;
border-radius: 12px;
transition: 0.3s;
margin-right: 10px;
}
.cute-shorts-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
transition: 0.3s;
}
.cute-shorts-toggle input:checked + .cute-shorts-slider {
background-color: #e63030;
}
.cute-shorts-toggle input:checked + .cute-shorts-slider:before {
transform: translateX(20px);
}
.cute-shorts-label {
color: #aaa;
font-size: 12px;
}
/* Thumbnail Upload */
.cute-thumbnail-upload {
border: 2px dashed #444;
border-radius: 10px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #151515;
}
.cute-thumbnail-upload:hover {
border-color: #ff6b8a;
background: rgba(255, 107, 138, 0.05);
}
.cute-thumbnail-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #666;
}
.cute-thumbnail-preview i {
font-size: 24px;
color: #ff6b8a;
}
.cute-thumbnail-preview span {
font-size: 12px;
}
/* Status */
.cute-status {
padding: 10px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
display: none;
}
.cute-status.success {
display: block;
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.cute-status.error {
display: block;
background: rgba(239, 68, 68, 0.15);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
/* Actions */
.cute-edit-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.cute-btn-cancel, .cute-btn-save {
padding: 10px 20px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.cute-btn-cancel {
background: #333;
color: #aaa;
}
.cute-btn-cancel:hover {
background: #444;
color: #fff;
}
.cute-btn-save {
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
color: white;
box-shadow: 0 4px 15px rgba(255, 107, 138, 0.3);
}
.cute-btn-save:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255, 107, 138, 0.4);
}
.cute-btn-save:disabled {
background: #444;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
@media (max-width: 420px) {
.cute-edit-modal {
max-width: 320px;
margin: 10px;
}
.cute-type-options, .cute-privacy-options {
flex-direction: column;
}
}
</style> </style>
@endonce @endonce
@@ -0,0 +1,377 @@
{{-- ══════════════════════════════════════════════════════════════════════
SCOREBAR MINI — the same match scorebar shown on the full player,
rendered inside a video-card thumbnail during hover playback.
Uses the design's fixed 1150 px canvas (identical to the main player's
msb-scale block). Every card scales its own canvas via ResizeObserver
so the scorebar keeps its pixel-perfect proportions at any card size.
Static-only: fighter names, flags, clubs, corner labels, "Round 1"
and 0-0 / 0:00. No live-scoring script — hover previews cover the
first few seconds of the match where those values are always the
baseline anyway.
════════════════════════════════════════════════════════════════════════ --}}
@php
$sbm_hd = $video->sportsMatch?->headerData();
$sbm_red = $sbm_hd['red'] ?? null;
$sbm_blue = $sbm_hd['blue'] ?? null;
$sbm_show = $sbm_hd && (($sbm_red['name'] ?? null) || ($sbm_blue['name'] ?? null));
@endphp
@if ($sbm_show)
@php
$sbm_redName = $sbm_red['name'] ?? 'RED';
$sbm_blueName = $sbm_blue['name'] ?? 'BLUE';
$sbm_redClub = $sbm_red['club'] ?? '';
$sbm_blueClub = $sbm_blue['club'] ?? '';
$sbm_redFlag = strtolower(trim((string) ($sbm_red['flag'] ?? '')));
$sbm_blueFlag = strtolower(trim((string) ($sbm_blue['flag'] ?? '')));
$sbm_redLogo = !empty($sbm_red['club_logo']) ? route('media.thumbnail', $sbm_red['club_logo']) : null;
$sbm_blueLogo = !empty($sbm_blue['club_logo']) ? route('media.thumbnail', $sbm_blue['club_logo']) : null;
// Live-scoring payload — same shape as the full-player scoreboard uses.
// Rounds drive the round label + per-round clock. Points give us the
// score at any t (last point ≤ t defines the score).
$sbm_rounds = $video->matchRounds()
->orderBy('round_number')
->get(['round_number', 'name', 'start_time_seconds'])
->map(fn ($r) => [
'n' => (int) $r->round_number,
'name' => (string) ($r->name ?? ''),
'start' => (float) ($r->start_time_seconds ?? 0),
])->values()->all();
if (empty($sbm_rounds)) $sbm_rounds = [['n' => 1, 'name' => '', 'start' => 0]];
$sbm_points = $video->matchPoints()
->orderBy('timestamp_seconds')
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
->flatMap(function ($p) {
// Same rule the full-player uses: a "both" event emits two feed rows
// — one per corner — so the ticker shows both fighters scoring.
$side = ($p->competitor === 'both') ? 'red' : $p->competitor;
$rows = [[
't' => (float) $p->timestamp_seconds,
'action' => (string) ($p->action ?? 'Point'),
'pts' => (int) $p->points,
'side' => $side,
'sr' => (int) ($p->score_red ?? 0),
'sb' => (int) ($p->score_blue ?? 0),
]];
if ($p->competitor === 'both') {
$rows[] = array_merge($rows[0], ['side' => 'blue', 't' => (float) $p->timestamp_seconds + 0.001]);
}
return $rows;
})->values()->all();
$sbm_sport = $sbm_hd['sport'] ?? null;
$sbm_data = json_encode(['rounds' => $sbm_rounds, 'points' => $sbm_points, 'sport' => $sbm_sport], JSON_HEX_APOS | JSON_HEX_QUOT);
@endphp
<div class="sbm" data-sbm data-sbm-state='{{ $sbm_data }}'>
{{-- Live scoring ticker — sits in its OWN scaled canvas at the
top-right of the thumb. Populated by the sbm live-sync JS. --}}
<div class="sbm-feed-scale">
<div class="sbm-feed" data-sbm-feed>
<div class="sbm-feed-hdr"><span class="sbm-feed-dot"></span><span>Live scoring</span></div>
<div class="sbm-feed-list" data-sbm-feed-list></div>
</div>
</div>
<div class="sbm-scale">
{{-- ══ Scorebar markup — same design + inline styles as the
overlay.blade.php `data-msb-part="scorebar"` block, but stripped
of the live-sync data-msb hooks. ══ --}}
<div style="position:absolute;left:0;right:0;bottom:14px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;pointer-events:none">
{{-- RED / AKA --}}
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
@if($sbm_redLogo) background-image:url('{{ $sbm_redLogo }}'); @endif"></div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
@if ($sbm_redFlag !== '')
<span class="fi fi-{{ $sbm_redFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
@endif
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redName }}</span>
</div>
@if ($sbm_redClub !== '')
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redClub }}</span>
</div>
@endif
</div>
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
<span data-sbm-red-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
</div>
</div>
</div>
{{-- CENTER: round + clock --}}
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
<div data-sbm-round style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
<div data-sbm-clock style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
<div style="transform:skewX(9deg);display:flex;gap:4px">
<span style="width:20px;height:3px;background:#e8534a"></span>
<span style="width:20px;height:3px;background:#e8534a"></span>
<span style="width:20px;height:3px;background:#3a3734"></span>
</div>
</div>
{{-- BLUE / AO --}}
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
<span data-sbm-blue-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
</div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_blueName }}</span>
@if ($sbm_blueFlag !== '')
<span class="fi fi-{{ $sbm_blueFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
@endif
</div>
@if ($sbm_blueClub !== '')
<div style="display:flex;align-items:center;gap:8px;min-width:0;justify-content:flex-end">
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right">{{ $sbm_blueClub }}</span>
</div>
@endif
</div>
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
@if($sbm_blueLogo) background-image:url('{{ $sbm_blueLogo }}'); @endif"></div>
</div>
</div>
</div>
</div>
</div>
@endif
@once
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ══ SCOREBAR MINI — same 1150 px canvas as the full player scorebar.
Sits above the hover-preview <video> (z-index 6, one above the
video at 5) and only appears while the video is active. ══ */
.yt-video-thumb .sbm {
position: absolute; inset: 0;
z-index: 6;
font-family: 'Barlow Condensed', sans-serif;
color: #efe9e0;
pointer-events: none;
opacity: 0;
transition: opacity .25s ease .05s;
overflow: hidden;
-webkit-font-smoothing: antialiased;
--sbm-scale: 0.4;
}
.sbm * { box-sizing: border-box; }
/* Show only while the hover video is playing (playVideo() adds .active) */
.yt-video-thumb:has(video.active) .sbm { opacity: 1; }
.sbm .sbm-scale {
position: absolute; bottom: 0; left: 0;
width: 1150px; height: 100px;
transform-origin: bottom left;
transform: scale(var(--sbm-scale));
}
/* ── Live scoring feed (top-right) — SAME 1150 px design canvas as the
full-player msb-feed, so the sizes and positions are pixel-identical.
Scaled by --sbm-scale (same as the bottom scorebar) so the feed
shrinks/grows in lockstep with the rest of the overlay. ── */
.sbm .sbm-feed-scale {
position: absolute; top: 0; left: 0;
width: 1150px; height: 100%;
transform-origin: top left;
transform: scale(var(--sbm-scale));
pointer-events: none;
}
/* Verbatim copy of .msb-feed / .msb-feed .hdr / .msb-feed .entry from
videos/partials/match/scoreboard/styles.blade.php — only the class
names are prefixed .sbm- to avoid colliding with the full player
when a card is opened as a sub-page. */
.sbm .sbm-feed {
position: absolute; top: 22px; right: 24px;
width: 262px;
display: flex; flex-direction: column; gap: 7px;
}
.sbm .sbm-feed-hdr {
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
font-size: 12px; letter-spacing: .3em; color: #d5cfc7; text-transform: uppercase;
font-family: 'Barlow Condensed', sans-serif;
text-shadow: 0 1px 6px rgba(0,0,0,.9);
}
.sbm .sbm-feed-dot {
width: 7px; height: 7px; border-radius: 50%;
background: #e8534a; animation: sbmPulse 1.6s infinite;
}
.sbm .sbm-feed-list { display: flex; flex-direction: column; gap: 7px; }
.sbm .sbm-feed-entry {
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
padding: 7px 10px;
background: rgba(10,10,12,.62); backdrop-filter: blur(6px);
border-right: 3px solid transparent;
animation: sbmRiseIn .45s ease both;
}
.sbm .sbm-feed-entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; font-family: 'Barlow Condensed', sans-serif; }
.sbm .sbm-feed-entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; font-family: 'Barlow Condensed', sans-serif; }
.sbm .sbm-feed-entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
/* Hide feed on very small thumbs (matches the msb-small breakpoint scaled
down for cards — below ~340 px it becomes illegible). */
.sbm.sbm-small .sbm-feed { display: none; }
@keyframes sbmPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
@keyframes sbmRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
</style>
<script>
(function () {
if (window._sbmBound) return;
window._sbmBound = true;
// ── Fit the fixed 1150 px scorebar canvas to the parent thumb ──
function fit(el) {
const host = el.parentElement;
if (!host) return;
const w = host.clientWidth;
if (!w) return;
el.style.setProperty('--sbm-scale', (w / 1150));
// Below ~340 px thumb width the ticker would shrink under legibility
// (matches the full player's `msb-small` breakpoint at 700 px, halved
// for card contexts). Hide the feed on those tiny cards.
el.classList.toggle('sbm-small', w < 340);
}
// ── Live scoring sync ─────────────────────────────────────────
// Each .sbm carries its match state as a data attribute. Bind the
// sibling <video>'s timeupdate to updates on THIS card's scorebar
// only — no globals, no ID collisions with other cards on the page.
function fmtClock(sec) {
sec = Math.max(0, Math.floor(sec || 0));
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
}
function currentRound(rounds, t) {
if (!rounds || !rounds.length) return { n: 1, name: '', start: 0 };
let cur = rounds[0];
for (const r of rounds) { if (t >= (r.start || 0)) cur = r; }
return cur;
}
function lastPoint(points, t) {
if (!points || !points.length) return null;
let last = null;
for (const p of points) { if (p.t <= t + 0.001) last = p; else break; }
return last;
}
// Same label rules as the full player: karate → Yuko/Waza-ari/Ippon,
// taekwondo → Punch/Body kick/Head kick/Turning body, else action text.
function pointLabel(p, sport) {
sport = (sport || '').toLowerCase();
if (sport.startsWith('taekwondo')) {
if (p.pts >= 4) return 'Turning body';
if (p.pts === 3) return 'Head kick';
if (p.pts === 2) return 'Body kick';
if (p.pts === 1) return 'Punch';
}
if (p.pts === 3) return 'Ippon';
if (p.pts === 2) return 'Waza-ari';
if (p.pts === 1) return 'Yuko';
return p.action || 'Point';
}
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
const feedColor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
function bindLive(sbm) {
let state = null;
try { state = JSON.parse(sbm.getAttribute('data-sbm-state') || 'null'); }
catch (e) { return; }
if (!state) return;
const thumb = sbm.parentElement;
const video = thumb && thumb.querySelector(':scope > video');
if (!video) return;
const redEl = sbm.querySelector('[data-sbm-red-score]');
const blueEl = sbm.querySelector('[data-sbm-blue-score]');
const roundEl = sbm.querySelector('[data-sbm-round]');
const clockEl = sbm.querySelector('[data-sbm-clock]');
const feedEl = sbm.querySelector('[data-sbm-feed-list]');
let lastR = null, lastB = null, lastRoundTxt = null, lastClk = null, lastFeedSig = null;
const tick = () => {
const t = video.currentTime || 0;
const p = lastPoint(state.points, t);
const r = currentRound(state.rounds, t);
const sr = p ? p.sr : 0, sb = p ? p.sb : 0;
if (sr !== lastR) { if (redEl) redEl.textContent = sr; lastR = sr; }
if (sb !== lastB) { if (blueEl) blueEl.textContent = sb; lastB = sb; }
const roundTxt = r.name || ('Round ' + r.n);
if (roundTxt !== lastRoundTxt) { if (roundEl) roundEl.textContent = roundTxt; lastRoundTxt = roundTxt; }
const clk = fmtClock(Math.max(0, t - (r.start || 0)));
if (clk !== lastClk) { if (clockEl) clockEl.textContent = clk; lastClk = clk; }
// Live scoring ticker — newest 3 point events, newest on top.
if (feedEl) {
const seen = (state.points || []).filter(pt => pt.t <= t + 0.001);
const last4 = seen.slice(-4).reverse();
const sig = last4.map(pt => pt.t + ':' + pt.side + ':' + pt.pts).join(',');
if (sig !== lastFeedSig) {
feedEl.innerHTML = last4.map(pt => {
const col = feedColor(pt.side);
return '<div class="sbm-feed-entry" style="border-color:' + col + '">'
+ '<span class="ts">' + fmtClock(pt.t) + '</span>'
+ '<span class="name">' + esc(pointLabel(pt, state.sport)) + '</span>'
+ '<span class="pts" style="color:' + col + '">+' + pt.pts + '</span>'
+ '</div>';
}).join('');
lastFeedSig = sig;
}
}
};
video.addEventListener('timeupdate', tick);
video.addEventListener('seeked', tick);
video.addEventListener('loadedmetadata', tick);
// Reset to 0-0 / 0:00 when the video stops (matches VS mini restart).
video.addEventListener('pause', tick);
video.addEventListener('emptied', tick);
tick();
}
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
for (const e of entries) {
const el = e.target.querySelector(':scope > .sbm');
if (el) fit(el);
}
}) : null;
function register(el) {
fit(el);
if (ro) ro.observe(el.parentElement);
if (!el._sbmBound) { el._sbmBound = true; bindLive(el); }
}
document.querySelectorAll('.sbm').forEach(register);
new MutationObserver(muts => {
for (const m of muts) {
for (const n of m.addedNodes) {
if (!(n instanceof Element)) continue;
if (n.matches?.('.sbm')) register(n);
n.querySelectorAll?.('.sbm').forEach(register);
}
}
}).observe(document.body, { childList: true, subtree: true });
})();
</script>
@endonce
@@ -0,0 +1,604 @@
{{-- ══════════════════════════════════════════════════════════════════════
VS MINI — the arena intro card rendered INSIDE a video-card thumbnail.
No countdown / no skip button; identical layout to the full-page VS
intro (fixed 1920×1080 stage, transform-scaled to fit the thumb).
Behavior:
- Sits at z-index 1 (above the still image, BELOW the hover video
which uses z-index 2 + .active { opacity: 1 }).
- On mouseleave from the thumb, animations restart so the intro
plays again the next time the user's mouse is off the card.
════════════════════════════════════════════════════════════════════════ --}}
@php
$vsm_hd = $video->sportsMatch?->headerData();
$vsm_red = $vsm_hd['red'] ?? null;
$vsm_blue = $vsm_hd['blue'] ?? null;
$vsm_show = $vsm_hd && (($vsm_red['name'] ?? null) || ($vsm_blue['name'] ?? null));
@endphp
@if ($vsm_show)
@php
$vsm_flag = fn(?string $c) => (($c = strtolower(trim((string) $c))) !== '' ? $c : 'xx');
$vsm_cname = fn (?string $c) => \App\Data\Countries::name($c);
$vsm_img = fn(?string $p) => $p ? route('media.thumbnail', $p) : null;
$vsm_str = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
// Every placeholder from the design is always in the DOM; empty ones
// are hidden by `.vsm-nullable:empty` / `.vsm-nullable-hide` in the
// CSS below. Same treatment as the full-page VS.
$vsm_event = $vsm_str($vsm_hd['championship'] ?? '');
$vsm_stage = $vsm_str($vsm_hd['stage'] ?? '');
$vsm_weight = $vsm_str($vsm_hd['weight_category'] ?? '');
$vsm_matchNo = $vsm_str($vsm_hd['match_number'] ?? '');
$vsm_court = $vsm_str($vsm_hd['court'] ?? '');
$vsm_ref = $vsm_str($vsm_hd['referee']['name'] ?? '');
// Per-fighter chips (Record / Rank / Stats) — fields aren't in
// headerData() today, so fall back to raw participants JSON.
$vsm_parts = $video->sportsMatch?->participants ?? [];
$vsm_redRecord = $vsm_str($vsm_parts['p2_record'] ?? '');
$vsm_redRank = $vsm_str($vsm_parts['p2_rank'] ?? '');
$vsm_redStats = $vsm_str($vsm_parts['p2_stats'] ?? '');
$vsm_blueRecord = $vsm_str($vsm_parts['p1_record'] ?? '');
$vsm_blueRank = $vsm_str($vsm_parts['p1_rank'] ?? '');
$vsm_blueStats = $vsm_str($vsm_parts['p1_stats'] ?? '');
$vsm_redName = $vsm_str($vsm_red['name'] ?? '');
$vsm_blueName = $vsm_str($vsm_blue['name'] ?? '');
$vsm_redClub = $vsm_str($vsm_red['club'] ?? '');
$vsm_blueClub = $vsm_str($vsm_blue['club'] ?? '');
$vsm_redFlag = $vsm_str($vsm_red['flag'] ?? '');
$vsm_blueFlag = $vsm_str($vsm_blue['flag'] ?? '');
$vsm_redLogo = $vsm_img($vsm_red['club_logo'] ?? null);
$vsm_blueLogo = $vsm_img($vsm_blue['club_logo'] ?? null);
@endphp
<div class="vs-mini vs-mini-run" data-vs-mini>
<div class="vs-mini-stage">
{{-- RED panel --}}
<div class="vs-mini-panel vs-mini-panel-red">
<div class="vs-mini-photo"
@if($vsm_img($vsm_red['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_red['headshot']) }}')"@endif></div>
<div class="vs-mini-scrim vs-mini-scrim-red"></div>
<div class="vs-mini-fade"></div>
</div>
{{-- BLUE panel --}}
<div class="vs-mini-panel vs-mini-panel-blue">
<div class="vs-mini-photo vs-mini-photo-rev"
@if($vsm_img($vsm_blue['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_blue['headshot']) }}')"@endif></div>
<div class="vs-mini-scrim vs-mini-scrim-blue"></div>
<div class="vs-mini-fade"></div>
</div>
<div class="vs-mini-divider"></div>
{{-- RED identity (left) — every placeholder always in DOM ── --}}
<div class="vs-mini-info vs-mini-info-red">
<div class="vs-mini-tag vs-mini-tag-red">AKA · RED</div>
<div class="vs-mini-flag-row">
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_redFlag) }} {{ $vsm_redFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
<div class="vs-mini-country vs-mini-country-red vsm-nullable">{{ $vsm_redFlag !== '' ? strtoupper($vsm_cname($vsm_redFlag) ?? $vsm_redFlag) : '' }}</div>
</div>
<div class="vs-mini-name vsm-nullable">{{ $vsm_redName }}</div>
<div class="vs-mini-club-row">
<div class="vs-mini-logo {{ $vsm_redLogo ? '' : 'vsm-nullable-hide' }}"
@if($vsm_redLogo) style="background-image:url('{{ $vsm_redLogo }}')"@endif></div>
<div class="vs-mini-club vsm-nullable">{{ $vsm_redClub }}</div>
</div>
<div class="vs-mini-chips-row">
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redRecord }}</div>
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_redRank }}</div>
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redStats }}</div>
</div>
</div>
{{-- BLUE identity (right) ── --}}
<div class="vs-mini-info vs-mini-info-blue">
<div class="vs-mini-tag vs-mini-tag-blue">AO · BLUE</div>
<div class="vs-mini-flag-row vs-mini-flag-row-r">
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_blueFlag) }} {{ $vsm_blueFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
<div class="vs-mini-country vs-mini-country-blue vsm-nullable">{{ $vsm_blueFlag !== '' ? strtoupper($vsm_cname($vsm_blueFlag) ?? $vsm_blueFlag) : '' }}</div>
</div>
<div class="vs-mini-name vsm-nullable">{{ $vsm_blueName }}</div>
<div class="vs-mini-club-row vs-mini-club-row-r">
<div class="vs-mini-logo {{ $vsm_blueLogo ? '' : 'vsm-nullable-hide' }}"
@if($vsm_blueLogo) style="background-image:url('{{ $vsm_blueLogo }}')"@endif></div>
<div class="vs-mini-club vsm-nullable">{{ $vsm_blueClub }}</div>
</div>
<div class="vs-mini-chips-row">
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueRecord }}</div>
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_blueRank }}</div>
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueStats }}</div>
</div>
</div>
{{-- Top: event + stage + weight (all in DOM) --}}
<div class="vs-mini-top">
<div class="vs-mini-top-event vsm-nullable">{{ $vsm_event }}</div>
<div class="vs-mini-top-stage-row {{ $vsm_stage === '' ? 'vsm-nullable-hide' : '' }}">
<div class="vs-mini-top-line vs-mini-top-line-l"></div>
<div class="vs-mini-top-stage">{{ $vsm_stage }}</div>
<div class="vs-mini-top-line vs-mini-top-line-r"></div>
</div>
<div class="vs-mini-top-weight vsm-nullable">{{ $vsm_weight }}</div>
</div>
{{-- Center VS --}}
<div class="vs-mini-center">
<div class="vs-mini-word">VS
<div class="vs-mini-shine-wrap"><div class="vs-mini-shine"></div></div>
</div>
</div>
{{-- .vs-mini-flash removed — the intro's full-thumb white flash at
t=1.5s was firing on every mouseleave-triggered replay, which
read as strobing on the left edge of the card. --}}
{{-- Bottom chips — Match / Court / Referee always in DOM; each hides
via data-vsm-empty and the flanking diamonds collapse via :has(). --}}
<div class="vs-mini-bottom">
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_matchNo === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Match</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_matchNo }}</span>
</div>
<div class="vs-mini-diamond vs-mini-diamond-mc"></div>
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_court === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Court</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_court }}</span>
</div>
<div class="vs-mini-diamond vs-mini-diamond-cr"></div>
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_ref === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Referee</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_ref }}</span>
</div>
</div>
</div>
</div>
@endif
@once
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
<style>
/* ══ VS MINI — arena intro inside a video-card thumbnail.
Same 1920×1080 canvas as the full-page overlay, transform-scaled
to fit the thumb. Every child in ABSOLUTE PIXELS on that canvas.
z-index 1: below the hover <video> (which we bump to 5 for match
cards), above the still <img>. ══ */
.yt-video-thumb .vs-mini {
position: absolute; inset: 0; z-index: 1;
background: #050507;
font-family: 'Barlow Condensed', sans-serif;
color: #e8e6e0;
overflow: hidden;
pointer-events: none;
--vs-mini-scale: 0.5;
--vs-mini-w: 1920px;
--vs-mini-h: 1080px;
}
/* When a match card is hovered, the VS mini EXPLODES outward — the
inverse of its implode entrance. Each part flies away in the direction
opposite to where it came from, clearing the stage for the video.
The whole overlay fades to fully hidden by the time the panels are
off-screen (~0.6s), so the video takes over cleanly. */
.yt-video-card:hover .yt-video-thumb .vs-mini {
animation: vsmFadeOut .55s .25s ease forwards;
}
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-red { animation: vsmExplodeL .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-blue { animation: vsmExplodeR .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-red { animation: vsmExplodeInfoL .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-blue { animation: vsmExplodeInfoR .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-top { animation: vsmExplodeTop .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-bottom { animation: vsmExplodeBot .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-center { animation: vsmExplodeVS .6s cubic-bezier(.34,1.56,.64,1) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-divider { animation: vsmDividerOut .35s ease forwards !important; }
/* Force the hover-preview video above the VS mini's stacking context.
The default rule sets video { z-index: 2 } but the VS mini itself
is z-index 1 + a stacking context, so we bump the video to be safe. */
.yt-video-thumb:has(.vs-mini) video { z-index: 5 !important; background: transparent !important; }
.vs-mini .vs-mini-stage {
position: absolute;
left: 50%; top: 50%;
width: var(--vs-mini-w); height: var(--vs-mini-h);
transform: translate(-50%, -50%) scale(var(--vs-mini-scale));
transform-origin: center center;
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
overflow: hidden;
will-change: transform;
/* Hidden until JS's fit() has measured the parent and set the real
--vs-mini-scale. Without this, a card whose parent had 0 height at
registration time (grid still resolving on mobile) would paint the
stage at the fallback 0.5 scale — larger than the ~640×360 mobile
thumb — clipping to just the center (looks like a broken card). */
visibility: hidden;
}
.vs-mini.vs-mini-fit .vs-mini-stage { visibility: visible; }
/* ══ Touch / no-hover devices: skip the orchestrated intro entirely and
render every element in its final state. The intro relies on ~1.3s
of CSS animation-delays; on mobile it was fragile — anything that
interrupted timing (late layout resolve, ResizeObserver refires
when the browser toolbar hides/shows, portrait class toggles that
swap `vsmPanelL` ↔ `vsmPanelT`) left cards frozen mid-animation.
On mobile users are scrolling, not hovering to watch a preview,
so the intro adds no value — show the final composition instead. ══ */
/* Same "no-animation, final-state" treatment when the parent explicitly
requests it — used by the OG-image render route so Browsershot
screenshots the composed final frame regardless of what Chromium
thinks its hover capability is. */
.vs-mini-static,
.vs-mini-static .vs-mini-panel,
.vs-mini-static .vs-mini-info,
.vs-mini-static .vs-mini-top,
.vs-mini-static .vs-mini-center,
.vs-mini-static .vs-mini-bottom {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.vs-mini-static .vs-mini-panel-red { transform: translateX(0) !important; }
.vs-mini-static .vs-mini-panel-blue { transform: translateX(0) !important; }
.vs-mini-static .vs-mini-top { transform: translateX(-50%) !important; }
.vs-mini-static .vs-mini-bottom { transform: translateX(-50%) !important; }
.vs-mini-static .vs-mini-center { transform: translate(-50%, -52%) !important; }
.vs-mini-static .vs-mini-photo,
.vs-mini-static .vs-mini-word,
.vs-mini-static .vs-mini-shine { animation: none !important; }
@media (hover: none) {
.vs-mini,
.vs-mini .vs-mini-panel,
.vs-mini .vs-mini-info,
.vs-mini .vs-mini-top,
.vs-mini .vs-mini-center,
.vs-mini .vs-mini-bottom {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.vs-mini .vs-mini-panel-red { transform: translateX(0) !important; }
.vs-mini .vs-mini-panel-blue { transform: translateX(0) !important; }
.vs-mini .vs-mini-top { transform: translateX(-50%) !important; }
.vs-mini .vs-mini-bottom { transform: translateX(-50%) !important; }
.vs-mini .vs-mini-center { transform: translate(-50%, -52%) !important; }
/* Also skip the ambient photo drift + VS pulse/shine — they were
running fine, but there's no need to burn cycles on animation
loops for a scrolling grid on mobile. */
.vs-mini .vs-mini-photo,
.vs-mini .vs-mini-word,
.vs-mini .vs-mini-shine { animation: none !important; }
}
/* Animations only run while the .vs-mini-run class is set (removed +
re-added on mouseleave to force a replay from the intro). */
/* ── Panels ── */
.vs-mini .vs-mini-panel { position: absolute; overflow: hidden; opacity: 0; }
.vs-mini .vs-mini-panel-red {
inset: 0 auto 0 0; width: 56%;
background: oklch(0.28 0.09 25);
clip-path: polygon(0 0, 100% 0, 82% 100%, 0 100%);
}
.vs-mini .vs-mini-panel-blue {
inset: 0 0 0 auto; width: 56%;
background: oklch(0.28 0.09 255);
clip-path: polygon(18% 0, 100% 0, 100% 100%, 0 100%);
}
.vs-mini.vs-mini-run .vs-mini-panel-red { animation: vsmPanelL .9s cubic-bezier(.22,1,.36,1) both; }
.vs-mini.vs-mini-run .vs-mini-panel-blue { animation: vsmPanelR .9s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-photo {
position: absolute; inset: 0;
background-size: cover; background-position: center;
animation: vsmDrift 18s ease-in-out infinite;
}
.vs-mini .vs-mini-photo-rev { animation-direction: reverse; }
.vs-mini .vs-mini-scrim-red { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(115deg, oklch(0.45 0.18 25 / 0.55) 0%, transparent 55%); }
.vs-mini .vs-mini-scrim-blue { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(245deg, oklch(0.45 0.15 255 / 0.55) 0%, transparent 55%); }
.vs-mini .vs-mini-fade {
position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(to top, rgba(5,5,7,.95) 0%, rgba(5,5,7,.72) 22%, rgba(5,5,7,.30) 42%, transparent 62%),
linear-gradient(to bottom, rgba(5,5,7,.82) 0%, rgba(5,5,7,.35) 18%, transparent 32%);
}
/* ── Divider ── */
.vs-mini .vs-mini-divider {
position: absolute; top: -6%; bottom: -6%; left: 50%;
width: 3px; margin-left: -1.5px;
transform: rotate(10.15deg);
background: linear-gradient(to bottom, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
pointer-events: none; filter: blur(1px);
}
/* ── Fighter identity (fixed px on 1920×1080 canvas) ── */
.vs-mini .vs-mini-info {
position: absolute; z-index: 6; max-width: 44%;
display: flex; flex-direction: column; gap: 13px;
opacity: 0;
}
.vs-mini .vs-mini-info-red { left: 43.2px; bottom: 118.8px; align-items: flex-start; }
.vs-mini .vs-mini-info-blue { right: 43.2px; bottom: 118.8px; align-items: flex-end; text-align: right; }
.vs-mini.vs-mini-run .vs-mini-info-red { animation: vsmRiseUp .8s .7s cubic-bezier(.22,1,.36,1) both; }
.vs-mini.vs-mini-run .vs-mini-info-blue { animation: vsmRiseUp .8s .85s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-tag {
font: 800 21.6px/1 'Barlow Condensed', sans-serif;
letter-spacing: .35em; color: #fff;
padding: 5.4px 15.1px 5.4px 18.9px;
}
.vs-mini .vs-mini-tag-red { background: oklch(0.55 0.20 25); }
.vs-mini .vs-mini-tag-blue { background: oklch(0.50 0.16 255); }
.vs-mini .vs-mini-flag-row { display: flex; align-items: center; gap: 15.1px; }
.vs-mini .vs-mini-flag-row-r { flex-direction: row-reverse; }
.vs-mini .vs-mini-flag {
width: 56.2px; height: auto; aspect-ratio: 4/3;
background-size: cover !important; background-position: center !important;
border: 1px solid rgba(255,255,255,.35);
box-shadow: 0 4px 18px rgba(0,0,0,.6);
display: inline-block; line-height: 0;
}
.vs-mini .vs-mini-country { font: 700 32.4px/1 'Barlow Condensed', sans-serif; letter-spacing: .28em; }
.vs-mini .vs-mini-country-red { color: oklch(0.85 0.05 25); }
.vs-mini .vs-mini-country-blue { color: oklch(0.85 0.05 255); }
.vs-mini .vs-mini-name {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 71.3px; line-height: .95;
text-transform: uppercase; color: #fff;
text-shadow: 0 6px 30px rgba(0,0,0,.8);
}
.vs-mini .vs-mini-club-row { display: flex; align-items: center; gap: 13px; margin-top: 4.3px; }
.vs-mini .vs-mini-club-row-r { flex-direction: row-reverse; }
.vs-mini .vs-mini-logo {
width: 69.1px; height: 69.1px;
border-radius: 50%;
background: rgba(255,255,255,.06);
background-size: cover; background-position: center;
border: 1px solid rgba(255,255,255,.2);
}
.vs-mini .vs-mini-club {
font: 600 30.2px/1.1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
color: rgba(232,230,224,.9);
}
/* Fighter chips row (Record / Rank / Stats) — matches full-page VS. */
.vs-mini .vs-mini-chips-row { display: flex; flex-wrap: wrap; gap: 9.7px; margin-top: 5.4px; }
.vs-mini .vs-mini-info-blue .vs-mini-chips-row { justify-content: flex-end; }
.vs-mini .vs-mini-chip-fighter {
font: 700 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
padding: 5.4px 14px;
background: rgba(10,10,14,.62);
border: 1px solid rgba(255,255,255,.25);
color: #fff;
}
.vs-mini .vs-mini-chip-fighter-gold {
border-color: oklch(0.85 0.16 85 / .6);
color: oklch(0.87 0.14 85);
}
/* ══ Placeholder hiding — parity with the full-page VS. ══ */
.vs-mini .vsm-nullable:empty { display: none; }
.vs-mini .vsm-nullable-hide { display: none; }
.vs-mini .vs-mini-chip.vsm-nullable-chip[data-vsm-empty="1"] { display: none; }
.vs-mini .vs-mini-bottom .vs-mini-diamond-mc:has(+ .vs-mini-chip[data-vsm-empty="1"]),
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-mc,
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-cr,
.vs-mini .vs-mini-bottom .vs-mini-diamond-cr:has(+ .vs-mini-chip[data-vsm-empty="1"]) { display: none; }
/* ── Top block ── */
.vs-mini .vs-mini-top {
position: absolute; top: 34.6px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 10.8px;
z-index: 8; width: 92%; pointer-events: none; opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-top { animation: vsmDropIn .8s .5s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-top-event {
font: 700 30.2px/1.05 'Barlow Condensed', sans-serif;
letter-spacing: .42em; text-transform: uppercase;
color: rgba(232,230,224,.92); text-align: center;
text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
.vs-mini .vs-mini-top-stage-row { display: flex; align-items: center; gap: 17.3px; }
.vs-mini .vs-mini-top-line { height: 2px; width: 64.8px; }
.vs-mini .vs-mini-top-line-l { background: linear-gradient(to left, oklch(0.85 0.16 85), transparent); }
.vs-mini .vs-mini-top-line-r { background: linear-gradient(to right, oklch(0.85 0.16 85), transparent); }
.vs-mini .vs-mini-top-stage {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 38.9px;
letter-spacing: .30em; padding-left: .3em;
color: oklch(0.85 0.16 85); text-transform: uppercase;
}
.vs-mini .vs-mini-top-weight {
font: 700 42px/1.1 'Anton', 'Barlow Condensed', sans-serif;
letter-spacing: .28em; padding-left: .28em; text-transform: uppercase;
color: #fff; text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
/* ── Center VS ── */
.vs-mini .vs-mini-center {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -52%);
z-index: 7; pointer-events: none;
display: flex; align-items: center; justify-content: center;
opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-center { animation: vsmSlam .7s 1.1s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-word {
position: relative;
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 183.6px; font-style: italic;
color: #fffdf5; line-height: 1;
-webkit-text-stroke: 2px oklch(0.85 0.16 85 / 0.6);
animation: vsmPulse 2.4s ease-in-out infinite;
overflow: visible;
}
.vs-mini .vs-mini-shine-wrap { position: absolute; inset: -10% -20%; overflow: hidden; pointer-events: none; }
.vs-mini .vs-mini-shine {
position: absolute; top: 0; bottom: 0; width: 34%;
background: linear-gradient(to right, transparent, rgba(255,255,255,.16), transparent);
animation: vsmShine 5s ease-in-out infinite;
}
/* .vs-mini-flash rules removed with the element itself. */
/* ── Bottom chips ── */
.vs-mini .vs-mini-bottom {
position: absolute; bottom: 32.4px; left: 50%; transform: translateX(-50%);
display: flex; gap: 17.3px; z-index: 8; align-items: center;
flex-wrap: wrap; justify-content: center; max-width: 94%; opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-bottom { animation: vsmRiseC .8s 1.3s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-chip {
display: flex; align-items: baseline; gap: 8.6px;
background: rgba(10,10,14,.72);
border: 1px solid oklch(0.85 0.16 85 / .45);
padding: 10.8px 23.8px; backdrop-filter: blur(6px);
}
.vs-mini .vs-mini-chip-lbl {
font: 600 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .30em; text-transform: uppercase;
color: rgba(232,230,224,.65);
}
.vs-mini .vs-mini-chip-val {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 34.6px; color: #fff;
}
.vs-mini .vs-mini-diamond {
width: 6px; height: 6px; transform: rotate(45deg);
background: oklch(0.85 0.16 85);
}
/* ── Portrait canvas (rare — vertical match cards) ── */
.vs-mini.vs-mini-portrait { --vs-mini-w: 1080px; --vs-mini-h: 1920px; }
.vs-mini.vs-mini-portrait .vs-mini-panel-red { inset: 0 0 auto 0; width: 100%; height: 56%;
clip-path: polygon(0 0, 100% 0, 100% 82%, 0 96%); }
.vs-mini.vs-mini-portrait .vs-mini-panel-blue { inset: auto 0 0 0; width: 100%; height: 56%;
clip-path: polygon(0 18%, 100% 4%, 100% 100%, 0 100%); }
.vs-mini.vs-mini-portrait .vs-mini-divider {
left: -4%; right: -4%; top: 50%; bottom: auto;
width: auto; height: 3px; margin-top: -1.5px; margin-left: 0;
transform: rotate(-4.48deg);
background: linear-gradient(to right, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
}
.vs-mini.vs-mini-portrait .vs-mini-info-red { left: 43.2px; top: 129.6px; bottom: auto; }
.vs-mini.vs-mini-portrait .vs-mini-info-blue { right: 43.2px; bottom: 129.6px; top: auto; }
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-red { animation-name: vsmPanelT; }
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-blue { animation-name: vsmPanelB; }
/* Hide the shimmer skeleton on match cards — the VS mini fills the thumb. */
.yt-video-thumb:has(.vs-mini)::before { display: none; }
/* Animations */
@keyframes vsmPulse {
0%,100% { text-shadow: 0 0 30px rgba(255,215,120,.55), 0 0 90px rgba(255,170,60,.3); transform: scale(1); }
50% { text-shadow: 0 0 65px rgba(255,220,130,1), 0 0 160px rgba(255,170,60,.7); transform: scale(1.045); }
}
@keyframes vsmShine { 0% { transform: translateX(-130%) skewX(-18deg); } 60%,100% { transform: translateX(230%) skewX(-18deg); } }
@keyframes vsmDrift { 0% { transform: translate3d(0,0,0) scale(1.02); } 50% { transform: translate3d(0,-1.2%,0) scale(1.05); } 100% { transform: translate3d(0,0,0) scale(1.02); } }
@keyframes vsmPanelL { from { opacity: 1; transform: translateX(-105%); } to { opacity: 1; transform: translateX(0); } }
@keyframes vsmPanelR { from { opacity: 1; transform: translateX( 105%); } to { opacity: 1; transform: translateX(0); } }
@keyframes vsmPanelT { from { opacity: 1; transform: translateY(-105%); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmPanelB { from { opacity: 1; transform: translateY( 105%); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmRiseUp { from { opacity: 0; transform: translateY(43.2px); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmRiseC { from { opacity: 0; transform: translate(-50%, 43.2px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsmDropIn { from { opacity: 0; transform: translate(-50%, -32.4px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsmSlam {
0% { opacity: 0; transform: translate(-50%,-52%) scale(3.4) rotate(-6deg); }
60% { opacity: 1; transform: translate(-50%,-52%) scale(.92) rotate(1deg); }
80% { transform: translate(-50%,-52%) scale(1.06); }
100% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); }
}
/* ══ EXPLODE — the inverse of the implode entrance. Each piece flies
OUT in the direction opposite to where it came from, then the
whole mini fades to zero opacity while the video takes the stage. ══ */
@keyframes vsmFadeOut { to { opacity: 0; } }
@keyframes vsmExplodeL { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX(-140%) scale(1.05); } }
@keyframes vsmExplodeR { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX( 140%) scale(1.05); } }
@keyframes vsmExplodeInfoL { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate(-70%, 40%) scale(.85); } }
@keyframes vsmExplodeInfoR { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate( 70%, 40%) scale(.85); } }
@keyframes vsmExplodeTop { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%,-120%) scale(.9); } }
@keyframes vsmExplodeBot { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%, 120%) scale(.9); } }
@keyframes vsmExplodeVS {
0% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); filter: blur(0); }
40% { opacity: 1; transform: translate(-50%,-52%) scale(1.3) rotate(-2deg); filter: blur(1px); }
100% { opacity: 0; transform: translate(-50%,-52%) scale(4) rotate(6deg); filter: blur(8px); }
}
@keyframes vsmDividerOut { to { opacity: 0; transform: rotate(10.15deg) scaleY(0); } }
</style>
<script>
(function () {
if (window._vsMiniBound) return;
window._vsMiniBound = true;
// Fit the 1920×1080 (or 1080×1920) stage to whatever thumb size the
// card resolves to. One ResizeObserver watches every current + future
// thumb — cheap because the callback is a couple of arithmetic ops.
function fit(el) {
const host = el.parentElement;
if (!host) return;
const w = host.clientWidth, h = host.clientHeight;
if (!w || !h) return;
const portrait = h > w * 1.05;
el.classList.toggle('vs-mini-portrait', portrait);
const sw = portrait ? 1080 : 1920;
const sh = portrait ? 1920 : 1080;
el.style.setProperty('--vs-mini-scale', Math.min(w / sw, h / sh));
// Reveal the stage — hidden by default so the CSS's fallback
// scale never paints a pre-fit oversized/clipped stage.
el.classList.add('vs-mini-fit');
}
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
for (const e of entries) {
const el = e.target.querySelector(':scope > .vs-mini');
if (el) fit(el);
}
}) : null;
function register(el) {
fit(el);
if (ro) ro.observe(el.parentElement);
}
// Register everything already in the DOM.
document.querySelectorAll('.vs-mini').forEach(register);
// Register anything added later (SPA nav, infinite-scroll loads).
new MutationObserver(muts => {
for (const m of muts) {
for (const n of m.addedNodes) {
if (!(n instanceof Element)) continue;
if (n.matches?.('.vs-mini')) register(n);
n.querySelectorAll?.('.vs-mini').forEach(register);
}
}
}).observe(document.body, { childList: true, subtree: true });
// Restart entrance animations whenever the mouse leaves a card thumb
// that has a VS mini in it. Only bind on real hover devices — on touch,
// Chrome synthesizes mouseleave on tap-lift and during scroll, which
// kept restarting animations and leaving cards frozen mid-intro
// (elements at opacity:0 during the 0.5–1.3s animation-delay window).
const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
if (hasHover) {
document.addEventListener('mouseleave', (e) => {
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
if (!thumb) return;
const vsm = thumb.querySelector(':scope > .vs-mini');
if (!vsm) return;
vsm.classList.remove('vs-mini-run');
void vsm.offsetWidth;
vsm.classList.add('vs-mini-run');
}, true);
}
})();
</script>
@endonce
@@ -306,7 +306,7 @@
@if($embedAudioInput) @if($embedAudioInput)
<input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}" <input type="file" id="edit-{{ $p }}-audio-input" name="{{ $audioInputName }}"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;" 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…'"> onchange="editHandleTrackAudio(this, '{{ $p }}', '{{ $titleId }}')">
@endif @endif
</div> </div>
</div> </div>
@@ -71,10 +71,14 @@
color: var(--brand-red) !important; color: var(--brand-red) !important;
} }
/* Single Action dropdown for every viewport — the individual
.desktop-action buttons are collapsed into this one menu. */
.mobile-action-dropdown { .mobile-action-dropdown {
display: none; display: inline-block;
position: relative; position: relative;
margin-left: auto;
} }
.video-actions > .desktop-action { display: none !important; }
.mobile-action-dropdown .dropdown-menu { .mobile-action-dropdown .dropdown-menu {
right: 0; right: 0;
@@ -154,25 +158,44 @@
</button> </button>
@endif @endif
@else @else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action"> {{-- Guest Subscribe: showToast first so the user understands what's
<i class="bi bi-bell"></i><span>Subscribe</span> happening, then redirect to the login page with intended=
preserving the current URL so after login they land back on
this video. 600 ms gives the toast a moment to be seen. --}}
<button type="button" class="action-btn desktop-action"
onclick="(function(){ if (typeof showToast==='function') showToast('Sign in to subscribe to {{ addslashes($video->user->name ?? 'this channel') }}', 'info'); setTimeout(function(){ window.location.href='{{ route('login') }}?intended=' + encodeURIComponent(window.location.pathname + window.location.search); }, 600); })();">
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
</button> </button>
@endauth @endauth
@auth @auth
{{-- Like + Dislike pair. Mutually exclusive on the server; clicking
one clears the other via the controllers' detach() call. --}}
<button type="button" <button type="button"
class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}" class="action-btn desktop-action like-toggle-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
data-video-id="{{ $video->id }}" data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleLike', $video) }}" data-toggle-url="{{ route('videos.toggleLike', $video) }}"
data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"> data-liked="{{ $video->isLikedBy(Auth::user()) ? 'true' : 'false' }}"
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i> aria-label="{{ $video->isLikedBy(Auth::user()) ? 'Remove like' : 'Like' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}" aria-hidden="true"></i>
<span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span> <span class="like-count-label">{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button> </button>
<button type="button"
class="action-btn desktop-action dislike-toggle-btn {{ $video->isDislikedBy(Auth::user()) ? 'disliked' : '' }}"
data-video-id="{{ $video->id }}"
data-toggle-url="{{ route('videos.toggleDislike', $video) }}"
data-disliked="{{ $video->isDislikedBy(Auth::user()) ? 'true' : 'false' }}"
aria-label="{{ $video->isDislikedBy(Auth::user()) ? 'Remove dislike' : 'Dislike' }}">
<i class="bi {{ $video->isDislikedBy(Auth::user()) ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down' }}" aria-hidden="true"></i>
</button>
@else @else
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action"> <button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
<i class="bi bi-hand-thumbs-up"></i> <i class="bi bi-hand-thumbs-up" aria-hidden="true"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }} {{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button> </button>
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action" aria-label="Dislike">
<i class="bi bi-hand-thumbs-down" aria-hidden="true"></i>
</button>
@endauth @endauth
@if ($video->isShareable()) @if ($video->isShareable())
@@ -447,6 +470,46 @@
if (typeof toast === 'function') toast('Could not update like. Try again.', 'error'); if (typeof toast === 'function') toast('Could not update like. Try again.', 'error');
}); });
}); });
// ── Dislike button ─────────────────────────────────────────────
// Dislike is mutually exclusive with like on the server side; whenever
// the server returns a new state, we reconcile BOTH buttons in one
// sweep so the pair always reflects the authoritative truth.
document.addEventListener('click', function (e) {
var btn = e.target.closest('.dislike-toggle-btn');
if (!btn) return;
var url = btn.dataset.toggleUrl;
var videoId = btn.dataset.videoId;
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
})
.then(function (r) { return r.json(); })
.then(function (data) {
// Reconcile dislike pair.
document.querySelectorAll('.dislike-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
var icon = b.querySelector('i');
b.dataset.disliked = data.disliked ? 'true' : 'false';
b.classList.toggle('disliked', data.disliked);
if (icon) icon.className = 'bi ' + (data.disliked ? 'bi-hand-thumbs-down-fill' : 'bi-hand-thumbs-down');
b.setAttribute('aria-label', data.disliked ? 'Remove dislike' : 'Dislike');
});
// Reconcile like pair in parallel — may have flipped off.
document.querySelectorAll('.like-toggle-btn[data-video-id="' + videoId + '"]').forEach(function (b) {
var icon = b.querySelector('i');
var label = b.querySelector('.like-count-label');
b.dataset.liked = data.liked ? 'true' : 'false';
b.classList.toggle('liked', data.liked);
if (icon) icon.className = 'bi ' + (data.liked ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up');
if (label) label.textContent = data.like_count > 0 ? data.like_count.toLocaleString() : 'Like';
});
})
.catch(function () {
if (typeof toast === 'function') toast('Could not update dislike. Try again.', 'error');
});
});
})(); })();
// ── Slideshow video generation progress ────────────────────────────── // ── Slideshow video generation progress ──────────────────────────────
+94 -272
View File
@@ -1,4 +1,4 @@
@props(['video' => null, 'size' => 'medium']) @props(['video' => null, 'size' => 'medium', 'hideChannelInfo' => false])
@php @php
use App\Data\Languages; use App\Data\Languages;
@@ -41,23 +41,74 @@ $langFlag = $forceTrackFlag ?: ($video ? Languages::flag($video->language) : nul
$showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#'; $showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#';
// Composed display title for match videos — matches the header on the
// match page: "{match title} – {blue flag+name} vs {red flag+name}
// ({weight})", with blue/red corner colours and inline flags. Falls
// back to plain $video->title for non-match cards.
$displayTitle = $video?->title;
$displayTitleHtml = null;
if ($video && $video->type === 'match' && $video->sportsMatch) {
$hdCard = $video->sportsMatch->headerData();
$blueN = $hdCard['blue']['name'] ?? null;
$redN = $hdCard['red']['name'] ?? null;
$blueF = $hdCard['blue']['flag'] ?? null;
$redF = $hdCard['red']['flag'] ?? null;
if ($blueN || $redN) {
$flagStyle = 'width:16px;height:12px;border-radius:2px;display:inline-block;vertical-align:middle;margin-right:4px;';
$mkSide = function ($name, $flag, $color) use ($flagStyle) {
if (!$name) return '';
$flagHtml = $flag ? '<span class="fi fi-'.e($flag).'" style="'.$flagStyle.'"></span>' : '';
return '<span style="color:'.$color.';font-weight:600;">'.$flagHtml.e($name).'</span>';
};
$blueHtml = $mkSide($blueN, $blueF, '#2563eb');
$redHtml = $mkSide($redN, $redF, '#ef4444');
$parts = [];
if (!empty($video->title)) $parts[] = e(strtoupper($video->title)) . ' –';
if ($blueHtml && $redHtml) $parts[] = $blueHtml . ' <span>vs</span> ' . $redHtml;
elseif ($blueHtml) $parts[] = $blueHtml;
elseif ($redHtml) $parts[] = $redHtml;
if (!empty($hdCard['weight_category'])) $parts[] = '(' . e($hdCard['weight_category']) . ')';
$displayTitleHtml = implode(' ', $parts);
}
}
// Size classes // Size classes
$sizeClasses = match($size) { $sizeClasses = match($size) {
'small' => 'yt-video-card-sm', 'small' => 'yt-video-card-sm',
default => '', default => '',
}; };
@endphp @endphp
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}"> <div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
<a href="{{ $showUrl }}"> <a href="{{ $showUrl }}">
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)" <div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}"> data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')"> {{-- width/height let the browser reserve the card's thumb box
BEFORE the image decodes, so a grid of 60 cards doesn't shift
as each thumbnail finishes loading. The ratio is honored by
.yt-video-thumb's aspect-ratio: 16/9 regardless of actual px. --}}
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}"
width="320" height="180"
loading="lazy" decoding="async"
onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
@if($videoUrl) @if($videoUrl)
<video preload="none"> {{-- Match cards preload metadata so the first frame is ready the
instant the user hovers (otherwise the browser fetches, buffers
then paints — showing a black frame behind the fade). Other
card types keep preload="none" to save bandwidth. --}}
<video preload="{{ $video && $video->type === 'match' ? 'metadata' : 'none' }}" playsinline>
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}"> <source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video> </video>
@endif @endif
{{-- Match cards used to inline the full VS portrait + the live-scoring
scorebar as thumbnail overlays. Those partials leaked fighter
names, "AKA / AO", "ROUND 1", "0:00" and club text into the
card DOM — showing stale 0-0 for finished matches, breaking
the title layout at narrow widths, and inflating card HTML.
The regular <img> above is the thumbnail now; the VS intro
and live scorebar live on the full match player page only. --}}
{{-- Equalizer overlay shown when audio-only track is previewing --}} {{-- Equalizer overlay shown when audio-only track is previewing --}}
<div class="audio-preview-overlay"> <div class="audio-preview-overlay">
<div class="audio-eq"> <div class="audio-eq">
@@ -89,23 +140,29 @@ $sizeClasses = match($size) {
@endif @endif
</div> </div>
</a> </a>
<div class="yt-video-info"> <div class="yt-video-info {{ $hideChannelInfo ? 'yt-video-info-no-channel' : '' }}">
@unless($hideChannelInfo)
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}" <a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
class="yt-channel-icon" onclick="event.stopPropagation();"> class="yt-channel-icon" onclick="event.stopPropagation();">
@if($video && $video->user && $video->user->avatar_url) @if($video && $video->user && $video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;"> <img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
@endif @endif
</a> </a>
@endunless
<div class="yt-video-details"> <div class="yt-video-details">
<h3 class="yt-video-title"> <h3 class="yt-video-title">
<a href="{{ $showUrl }}"> <a href="{{ $showUrl }}">
@if($langFlag) @if($langFlag)
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span> <span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
@endif @endif
{{ $video->title ?? 'Untitled Video' }} @if($displayTitleHtml)
{!! $displayTitleHtml !!}
@else
{{ $displayTitle ?? 'Untitled Video' }}
@endif
</a> </a>
</h3> </h3>
@if($video && $video->user) @if($video && $video->user && ! $hideChannelInfo)
<a href="{{ route('channel', $video->user->channel) }}" <a href="{{ route('channel', $video->user->channel) }}"
class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a> class="yt-channel-name" onclick="event.stopPropagation();">{{ $video->user->name }}</a>
@endif @endif
@@ -124,15 +181,15 @@ $sizeClasses = match($size) {
</div> </div>
@if($video) @if($video)
<div class="position-relative"> <div class="position-relative">
<button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false"> <button class="yt-more-btn" type="button" data-bs-toggle="dropdown" data-bs-auto-close="true" aria-expanded="false" aria-label="More actions">
<i class="bi bi-three-dots-vertical"></i> <i class="bi bi-three-dots-vertical" aria-hidden="true"></i>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark"> <ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
@if($isOwner) @if($isOwner)
<li> <li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')"> <button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
</a> </button>
</li> </li>
<li> <li>
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})"> <button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
@@ -142,17 +199,17 @@ $sizeClasses = match($size) {
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@endif @endif
<li> <li>
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')"> <button type="button" class="dropdown-item" onclick="addToQueue('{{ $video->getRouteKey() }}')">
<i class="bi bi-list-nested"></i> Add to queue <i class="bi bi-list-nested"></i> Add to queue
</a> </button>
</li> </li>
<li> <li>
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')"> <button type="button" class="dropdown-item" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
<i class="bi bi-clock"></i> Save to Watch later <i class="bi bi-clock"></i> Save to Watch later
</a> </button>
</li> </li>
<li> <li>
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item"> <x-save-to-playlist-button :video="$video" tag="button" class="dropdown-item">
<i class="bi bi-bookmark"></i> Save to playlist <i class="bi bi-bookmark"></i> Save to playlist
</x-save-to-playlist-button> </x-save-to-playlist-button>
</li> </li>
@@ -165,133 +222,45 @@ $sizeClasses = match($size) {
@endif @endif
@if($video->isShareable()) @if($video->isShareable())
<li> <li>
<x-share-button :video="$video" tag="a" class="dropdown-item"> <x-share-button :video="$video" tag="button" class="dropdown-item">
<i class="bi bi-share"></i> Share <i class="bi bi-share"></i> Share
</x-share-button> </x-share-button>
</li> </li>
@endif @endif
{{-- "Not interested", "Don't recommend channel" and "Report"
only make sense for someone else's video. On your own, the
Edit/Delete items above are what you want instead. --}}
@unless($isOwner)
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li> <li>
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')"> <button type="button" class="dropdown-item" onclick="notInterested('{{ $video->getRouteKey() }}')">
<i class="bi bi-dash-circle"></i> Not interested <i class="bi bi-dash-circle"></i> Not interested
</a> </button>
</li> </li>
<li> <li>
<a class="dropdown-item" href="javascript:void(0)" onclick="dontRecommendChannel('{{ $video->user_id }}')"> <button type="button" class="dropdown-item" onclick="dontRecommendChannel('{{ $video->user_id }}')">
<i class="bi bi-x-circle"></i> Don't recommend channel <i class="bi bi-x-circle"></i> Don't recommend channel
</a> </button>
</li> </li>
<li> <li>
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')"> <button type="button" class="dropdown-item" onclick="reportVideo('{{ $video->getRouteKey() }}')">
<i class="bi bi-flag"></i> Report <i class="bi bi-flag"></i> Report
</a> </button>
</li> </li>
@endunless
</ul> </ul>
</div> </div>
@endif @endif
</div> </div>
</div> </div>
@php $vk = $video->getRouteKey() ?? ''; @endphp {{--
<!-- Cute Edit Video Modal --> The per-card edit modal that used to live here (97 lines of markup, 100+ lines of JS)
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true"> was dead code — the singleton at resources/views/layouts/partials/edit-video-modal.blade.php
<div class="modal-dialog modal-dialog-centered cute-edit-modal"> defines a global window.openEditVideoModal that always wins the last-definition race,
<div class="modal-content cute-edit-content"> so clicking "Edit" on a card has always been going through the singleton. Removing the
<div class="cute-edit-header"> per-card copy drops ~6000 lines of HTML from a 60-card feed page.
<span class="cute-edit-icon">✏️</span> --}}
<h5>Edit Video</h5>
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
</div>
<div class="cute-edit-body">
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<!-- Title -->
<div class="cute-form-group">
<label><i class="bi bi-card-heading"></i> Title</label>
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
</div>
<!-- Description -->
<div class="cute-form-group">
<label><i class="bi bi-text-paragraph"></i> Description</label>
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
</div>
<!-- Video Type -->
<div class="cute-form-group">
<label><i class="bi bi-collection-play"></i> Type</label>
<div class="cute-type-options">
<label class="cute-type-option active" data-type="generic">
<input type="radio" name="type" value="generic" checked>
<span>🎬 Generic</span>
</label>
<label class="cute-type-option" data-type="music">
<input type="radio" name="type" value="music">
<span>🎵 Music</span>
</label>
<label class="cute-type-option" data-type="match">
<input type="radio" name="type" value="match">
<span>🏆 Match</span>
</label>
</div>
</div>
<!-- Shorts Toggle -->
<div class="cute-form-group">
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
<label class="cute-shorts-toggle">
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
<span class="cute-shorts-slider"></span>
<span class="cute-shorts-label">Mark as Short</span>
</label>
</div>
<!-- Thumbnail -->
<div class="cute-form-group">
<label><i class="bi bi-image"></i> Thumbnail</label>
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
<i class="bi bi-camera"></i>
<span>Click to change</span>
</div>
</div>
</div>
<!-- Privacy -->
<div class="cute-form-group">
<label><i class="bi bi-shield-lock"></i> Privacy</label>
<div class="cute-privacy-options">
<label class="cute-privacy-option active" data-privacy="public">
<input type="radio" name="visibility" value="public" checked>
<span>🌐 Public</span>
</label>
<label class="cute-privacy-option" data-privacy="unlisted">
<input type="radio" name="visibility" value="unlisted">
<span>🔗 Unlisted</span>
</label>
<label class="cute-privacy-option" data-privacy="private">
<input type="radio" name="visibility" value="private">
<span>🔒 Private</span>
</label>
</div>
</div>
<!-- Status -->
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
<!-- Buttons -->
<div class="cute-edit-actions">
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
<button type="submit" class="cute-btn-save">Save</button>
</div>
</form>
</div>
</div>
</div>
</div>
@include('components.partials.card-styles') @include('components.partials.card-styles')
@@ -346,155 +315,8 @@ function stopVideo(element) {
element.classList.remove('audio-playing'); element.classList.remove('audio-playing');
} }
// Edit Modal Functions // Edit-modal open/close + its form handlers live in the singleton at
let currentEditVideoId = null; // layouts/partials/edit-video-modal.blade.php (not here) — see the
// removed-markup note above the dropdown for why.
function openEditVideoModal(videoId) {
currentEditVideoId = videoId;
const modalId = 'editVideoModal' + (videoId || '');
const modal = new bootstrap.Modal(document.getElementById(modalId));
modal.show();
// Fetch video data
fetch(`/videos/${videoId}/edit`, {
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const video = data.video;
document.getElementById('edit-title-' + videoId).value = video.title || '';
document.getElementById('edit-description-' + videoId).value = video.description || '';
// Set type
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
typeOptions.forEach(opt => {
opt.classList.remove('active');
if (opt.dataset.type === (video.type || 'generic')) {
opt.classList.add('active');
opt.querySelector('input').checked = true;
}
});
// Set privacy
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
privacyOptions.forEach(opt => {
opt.classList.remove('active');
if (opt.dataset.privacy === (video.visibility || 'public')) {
opt.classList.add('active');
opt.querySelector('input').checked = true;
}
});
// Set shorts toggle
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
if (shortsCheckbox) {
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
}
// Clear status
const statusEl = document.getElementById('edit-status-' + videoId);
statusEl.className = 'cute-status';
statusEl.textContent = '';
}
})
.catch(error => {
console.error('Error:', error);
});
}
function closeEditVideoModal(videoId) {
const modalId = 'editVideoModal' + (videoId || '');
const modalEl = document.getElementById(modalId);
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) {
modal.hide();
}
}
// SPA navigation re-executes this once-protected script on every page swap.
// Guard the document-level listeners so they don't stack up across navs.
if (!window._videoCardListenersBound) {
window._videoCardListenersBound = true;
// Type option click handlers
document.addEventListener('click', function(e) {
if (e.target.closest('.cute-type-option')) {
const option = e.target.closest('.cute-type-option');
const parent = option.parentElement;
parent.querySelectorAll('.cute-type-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
option.querySelector('input').checked = true;
}
if (e.target.closest('.cute-privacy-option')) {
const option = e.target.closest('.cute-privacy-option');
const parent = option.parentElement;
parent.querySelectorAll('.cute-privacy-option').forEach(opt => opt.classList.remove('active'));
option.classList.add('active');
option.querySelector('input').checked = true;
}
});
// Thumbnail preview
document.addEventListener('change', function(e) {
if (e.target.id && e.target.id.startsWith('edit-thumbnail-')) {
const file = e.target.files[0];
if (file) {
const videoId = e.target.id.replace('edit-thumbnail-', '');
const preview = document.getElementById('thumbnail-preview-' + videoId);
preview.innerHTML = `<span>${file.name}</span>`;
}
}
});
// Form submission
document.addEventListener('submit', function(e) {
const form = e.target;
if (form.id && form.id.startsWith('edit-video-form-')) {
e.preventDefault();
const videoId = form.id.replace('edit-video-form-', '');
const formData = new FormData(form);
const statusEl = document.getElementById('edit-status-' + videoId);
const submitBtn = form.querySelector('.cute-btn-save');
submitBtn.disabled = true;
submitBtn.textContent = 'Saving...';
fetch(`/videos/${videoId}`, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusEl.className = 'cute-status success';
statusEl.textContent = '✓ Saved successfully!';
setTimeout(() => {
closeEditVideoModal(videoId);
window.location.reload();
}, 1000);
} else {
throw new Error(data.message || 'Update failed');
}
})
.catch(error => {
statusEl.className = 'cute-status error';
statusEl.textContent = '✗ ' + error.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Save';
});
}
});
} // end _videoCardListenersBound guard
</script> </script>
@endonce @endonce
@@ -4,8 +4,31 @@
use App\Models\CommentLike; use App\Models\CommentLike;
$currentUserId = Auth::id() ?? 0; $currentUserId = Auth::id() ?? 0;
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get(); $llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
// Flag per commenter: most-frequent access country, falling back to the
// user's nationality. Batched so the comments list stays a single query.
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
// Pinned comments bubble to the top of the list regardless of recency,
// then everything else sorts newest-first. reorder() wipes the
// ->latest() default the comments() relation already applied, so
// pinned DESC becomes the PRIMARY sort instead of a tiebreaker.
$topComments = $video->comments()
->whereNull('parent_id')
->with('user', 'replies.user')
->reorder()
->orderByDesc('pinned')
->latest()
->get();
$topLevelCount = $topComments->count();
$replyCount = $topComments->sum(fn($c) => $c->replies->count());
// Permalink: ?lc=<comment-id> scrolls to and highlights a specific
// comment or reply so a shared link lands on the exact message. The
// id is validated against the current video's comment ids so the
// highlight only applies when the target really belongs here.
$permalinkId = (int) request('lc', 0);
$allCommentIds = $topComments->pluck('id') $allCommentIds = $topComments->pluck('id')
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id'))) ->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
@@ -40,7 +63,17 @@
{{-- ── Header ── --}} {{-- ── Header ── --}}
<div class="ytc-header"> <div class="ytc-header">
<span class="ytc-count" id="ytcCount">{{ number_format($commentCount) }} Comments</span> {{-- Top-level count on the header; reply count surfaces inside each
thread's "N replies" expander. "1 Comment" singular when there's
exactly one — never "1 Comments". --}}
<span class="ytc-count" id="ytcCount">
{{ number_format($topLevelCount) }} {{ Str::plural('Comment', $topLevelCount) }}
@if($replyCount > 0)
<span class="ytc-count-replies" aria-label="reply count">
· {{ number_format($replyCount) }} {{ Str::plural('reply', $replyCount) }}
</span>
@endif
</span>
<div class="ytc-sort-wrap" id="ytcSortWrap"> <div class="ytc-sort-wrap" id="ytcSortWrap">
<button class="ytc-sort-btn" id="ytcSortBtn"> <button class="ytc-sort-btn" id="ytcSortBtn">
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg> <svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
@@ -82,18 +115,41 @@
{{-- ── Comments list ── --}} {{-- ── Comments list ── --}}
<div id="ytcList"> <div id="ytcList">
@forelse($topComments as $comment) @forelse($topComments as $comment)
@php $isOwn = $comment->user_id === $currentUserId; @endphp @php
<div class="ytc-comment" id="comment-{{ $comment->id }}"> $isOwn = $comment->user_id === $currentUserId;
$isVideoOwner = $currentUserId === $video->user_id;
@endphp
<div class="ytc-comment{{ $comment->pinned ? ' ytc-pinned' : '' }}" id="comment-{{ $comment->id }}">
@if($comment->pinned)
<div class="ytc-pinned-badge" aria-label="Pinned by the creator">
<i class="bi bi-pin-fill" aria-hidden="true"></i> Pinned by {{ $video->user->name ?? 'the creator' }}
</div>
@endif
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link"> <a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}"> <img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
</a> </a>
<div class="ytc-body-wrap"> <div class="ytc-body-wrap">
<div class="ytc-meta"> <div class="ytc-meta">
@php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp
@if($cFlag)<span class="ytc-flag fi fi-{{ strtolower($cFlag) }}" title="{{ $cFlag }}"></span>@endif
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-author">{{ $comment->user->name ?? 'User' }}</a> <a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-author">{{ $comment->user->name ?? 'User' }}</a>
<span class="ytc-time">{{ ytcTime($comment->created_at) }}</span> <a class="ytc-time ytc-permalink"
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
title="Copy link to this comment"
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
@if($comment->hearted)
<span class="ytc-heart-badge"
aria-label="Hearted by the creator"
title="Hearted by {{ $video->user->name ?? 'the creator' }}">
<i class="bi bi-heart-fill" aria-hidden="true"></i>
</span>
@endif
</div> </div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div> <div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
@if($llmEnabled)
<div class="ytc-translation" id="translation-{{ $comment->id }}" style="display:none"></div>
@endif
{{-- Edit form --}} {{-- Edit form --}}
@if($isOwn) @if($isOwn)
@@ -118,8 +174,31 @@
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg> <svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button> </button>
@auth @auth
{{-- Creator-only heart + pin toggles. The server
rejects anyone else, but hiding the buttons
keeps the UI uncluttered for regular viewers. --}}
@if($isVideoOwner)
<button class="ytc-creator-btn ytc-heart-toggle{{ $comment->hearted ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->hearted ? 'true' : 'false' }}"
title="{{ $comment->hearted ? 'Remove heart' : 'Heart this comment' }}">
<i class="bi {{ $comment->hearted ? 'bi-heart-fill' : 'bi-heart' }}" aria-hidden="true"></i>
</button>
<button class="ytc-creator-btn ytc-pin-toggle{{ $comment->pinned ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->pinned ? 'true' : 'false' }}"
title="{{ $comment->pinned ? 'Unpin' : 'Pin to top' }}">
<i class="bi {{ $comment->pinned ? 'bi-pin-fill' : 'bi-pin' }}" aria-hidden="true"></i>
</button>
@endif
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button> <button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
@endauth @endauth
@if($llmEnabled)
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $comment->id }}" title="Translate this comment">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="ytc-translate-label">Translate</span>
</button>
@endif
@if($isOwn) @if($isOwn)
<div class="ytc-more-wrap"> <div class="ytc-more-wrap">
<button class="ytc-more-btn" title="More actions"> <button class="ytc-more-btn" title="More actions">
@@ -139,20 +218,12 @@
@endif @endif
</div> </div>
{{-- Reply form --}} {{-- Reply form is lazy-cloned from #ytc-reply-form-tpl on
first click; the ~13-line markup used to render for
every top-level comment (so a 50-comment video shipped
~650 lines of hidden form markup to every viewer). --}}
@auth @auth
<div class="ytc-reply-form" id="replyForm{{ $comment->id }}"> <div class="ytc-reply-slot" data-comment-id="{{ $comment->id }}" data-video-id="{{ $video->getRouteKey() }}"></div>
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
<div class="ytc-input-wrap">
<textarea class="ytc-textarea ytc-reply-textarea" id="replyBody{{ $comment->id }}"
placeholder="Add a reply..." rows="1"></textarea>
<div class="ytc-form-actions">
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger" data-comment-id="{{ $comment->id }}">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger"
data-video-id="{{ $video->getRouteKey() }}" data-parent-id="{{ $comment->id }}">Reply</button>
</div>
</div>
</div>
@endauth @endauth
{{-- Replies --}} {{-- Replies --}}
@@ -172,10 +243,18 @@
</a> </a>
<div class="ytc-body-wrap"> <div class="ytc-body-wrap">
<div class="ytc-meta"> <div class="ytc-meta">
@php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp
@if($rFlag)<span class="ytc-flag fi fi-{{ strtolower($rFlag) }}" title="{{ $rFlag }}"></span>@endif
<a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-author">{{ $reply->user->name ?? 'User' }}</a> <a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-author">{{ $reply->user->name ?? 'User' }}</a>
<span class="ytc-time">{{ ytcTime($reply->created_at) }}</span> <a class="ytc-time ytc-permalink"
href="?lc={{ $reply->id }}#comment-{{ $reply->id }}"
title="Copy link to this reply"
aria-label="Permalink">{{ ytcTime($reply->created_at) }}</a>
</div> </div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div> <div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div>
@if($llmEnabled)
<div class="ytc-translation" id="translation-{{ $reply->id }}" style="display:none"></div>
@endif
@if($replyIsOwn) @if($replyIsOwn)
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none"> <div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
@@ -197,6 +276,21 @@
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike"> <button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg> <svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button> </button>
@auth
{{-- Reply to a reply: opens the parent comment's reply form and
prefills "@AuthorName " so the responder's reader knows which
reply they're answering. The DB still stores it as a one-level
thread (parent_id = top-level), matching the existing model. --}}
<button class="ytc-reply-btn _comment-reply-nested-trigger"
data-parent-id="{{ $comment->id }}"
data-handle="{{ $reply->user->username ?? $reply->user->name ?? '' }}">Reply</button>
@endauth
@if($llmEnabled)
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="ytc-translate-label">Translate</span>
</button>
@endif
@if($replyIsOwn) @if($replyIsOwn)
<div class="ytc-more-wrap"> <div class="ytc-more-wrap">
<button class="ytc-more-btn"> <button class="ytc-more-btn">
@@ -270,6 +364,66 @@
font-weight: 400; font-weight: 400;
color: var(--text-primary); color: var(--text-primary);
} }
.ytc-count-replies {
font-size: 13px;
color: var(--text-secondary);
margin-left: 4px;
}
/* ── Creator engagement (heart + pin) ── */
.ytc-pinned-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.ytc-pinned-badge i { color: #ef4444; }
.ytc-comment.ytc-pinned {
background: rgba(239, 68, 68, 0.03);
border-left: 2px solid rgba(239, 68, 68, 0.5);
padding-left: 10px;
margin-left: -12px;
}
.ytc-heart-badge {
display: inline-flex;
align-items: center;
margin-left: 4px;
color: #ef4444;
font-size: 13px;
}
.ytc-creator-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 15px;
cursor: pointer;
padding: 4px;
border-radius: 50%;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background .15s, color .15s;
}
.ytc-creator-btn:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
.ytc-creator-btn.active { color: #ef4444; }
.ytc-creator-btn.active:hover { background: rgba(239, 68, 68, 0.15); }
/* Comment permalink highlight — brief glow on the targeted row when a
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
.ytc-comment.ytc-highlight,
.ytc-reply.ytc-highlight {
animation: ytc-highlight-pulse 2.8s ease-out;
}
@keyframes ytc-highlight-pulse {
0% { background-color: rgba(239, 68, 68, 0.18); }
40% { background-color: rgba(239, 68, 68, 0.18); }
100% { background-color: transparent; }
}
.ytc-sort-wrap { position: relative; } .ytc-sort-wrap { position: relative; }
.ytc-sort-btn { .ytc-sort-btn {
display: flex; display: flex;
@@ -440,7 +594,11 @@
.ytc-time { .ytc-time {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none;
} }
/* The time stamp is now an <a> permalink. Underline on hover so it's
discoverable but doesn't scream "link" at rest. */
.ytc-time.ytc-permalink:hover { text-decoration: underline; cursor: pointer; }
/* ── Comment text ── */ /* ── Comment text ── */
.ytc-text { .ytc-text {
@@ -451,6 +609,31 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
/* ── Read more clamp ──
Long comments collapse to ~4 lines by default; the "Read more" button
under the body expands them. The clamp uses line-clamp (not a fixed
max-height) so the first few lines are always complete, never cut
mid-word, and the fade-mask eases into the trailing ellipsis.
*/
.ytc-text.ytc-clamp {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
}
.ytc-read-more {
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 4px 0 0;
margin-top: 2px;
}
.ytc-read-more:hover { color: var(--text-primary); }
/* ── Actions ── */ /* ── Actions ── */
.ytc-actions { .ytc-actions {
display: flex; display: flex;
@@ -498,6 +681,57 @@
} }
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); } .ytc-reply-btn:hover { background: rgba(255,255,255,.1); }
/* ── Translate ── */
.ytc-translate-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 6px 10px;
border-radius: 18px;
transition: background .15s, color .15s;
font-family: inherit;
}
.ytc-translate-btn:hover { background: rgba(255,255,255,.1); color: var(--text-primary); }
.ytc-translate-btn svg { width: 16px; height: 16px; fill: currentColor; }
.ytc-translate-btn.loading { opacity: .6; cursor: wait; }
.ytc-translate-btn.active { color: #3ea6ff; }
.ytc-flag {
display: inline-block;
width: 18px;
height: 13px;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(255,255,255,.08);
vertical-align: middle;
transform: translateY(-1px);
flex-shrink: 0;
}
.ytc-translation {
margin-top: 6px;
padding: 10px 12px;
background: rgba(62,166,255,.08);
border-left: 2px solid #3ea6ff;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.ytc-translation-meta {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: .4px;
}
/* ── Three-dot menu ── */ /* ── Three-dot menu ── */
.ytc-more-wrap { .ytc-more-wrap {
position: relative; position: relative;
@@ -695,9 +929,16 @@ const YTC = {
videoId: '{{ $video->getRouteKey() }}', videoId: '{{ $video->getRouteKey() }}',
csrf: '{{ csrf_token() }}', csrf: '{{ csrf_token() }}',
userId: {{ $currentUserId }}, userId: {{ $currentUserId }},
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
deleteId: null, deleteId: null,
toastTimer: null, toastTimer: null,
}; };
const YTC_TRANSLATE_SVG = '<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>';
function targetLang() {
const l = (navigator.language || 'en').toLowerCase().split('-')[0];
return l || 'en';
}
// ── Helpers ────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────
function esc(s) { function esc(s) {
@@ -877,6 +1118,26 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
const rt = e.target.closest('._comment-reply-trigger'); const rt = e.target.closest('._comment-reply-trigger');
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; } if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
// Replying to a reply — open the parent-level reply form and
// prefill it with the reply author's @handle so the thread stays
// readable even though the DB only stores one-level nesting.
const rtn = e.target.closest('._comment-reply-nested-trigger');
if (rtn) {
e.stopPropagation();
const parentId = rtn.dataset.parentId;
const handle = (rtn.dataset.handle || '').replace(/\s+/g, '');
toggleReplyForm(parentId);
const ta = document.getElementById('replyBody' + parentId);
if (ta && handle) {
const mention = '@' + handle + ' ';
if (!ta.value.startsWith(mention)) ta.value = mention + ta.value;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
autoResize(ta);
}
return;
}
// Edit trigger // Edit trigger
const et = e.target.closest('._comment-edit-trigger'); const et = e.target.closest('._comment-edit-trigger');
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; } if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
@@ -904,12 +1165,148 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
// Dislike btn // Dislike btn
const db = e.target.closest('.ytc-dislike-btn'); const db = e.target.closest('.ytc-dislike-btn');
if (db) { e.stopPropagation(); toggleDislike(db); return; } if (db) { e.stopPropagation(); toggleDislike(db); return; }
// Creator heart — owner-only, server rejects anyone else
const hb = e.target.closest('.ytc-heart-toggle');
if (hb) {
e.stopPropagation();
const id = hb.dataset.commentId;
fetch('/comments/' + id + '/heart', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
}).then(r => r.json()).then(d => {
if (d.hearted !== undefined) {
hb.classList.toggle('active', d.hearted);
hb.setAttribute('aria-pressed', d.hearted ? 'true' : 'false');
hb.querySelector('i').className = 'bi ' + (d.hearted ? 'bi-heart-fill' : 'bi-heart');
// Also toggle the visible heart badge in the comment meta.
const meta = hb.closest('.ytc-comment').querySelector('.ytc-meta');
let badge = meta?.querySelector('.ytc-heart-badge');
if (d.hearted && !badge && meta) {
badge = document.createElement('span');
badge.className = 'ytc-heart-badge';
badge.innerHTML = '<i class="bi bi-heart-fill" aria-hidden="true"></i>';
meta.appendChild(badge);
} else if (!d.hearted && badge) {
badge.remove();
}
}
}).catch(() => {});
return;
}
// Creator pin — owner-only, pins top-level comments to the list head
const pb = e.target.closest('.ytc-pin-toggle');
if (pb) {
e.stopPropagation();
const id = pb.dataset.commentId;
fetch('/comments/' + id + '/pin', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
}).then(r => r.json()).then(d => {
if (d.pinned !== undefined) {
// Reload to re-sort — pin changes the order of top-level
// comments and the DOM move is cleaner server-side than
// trying to shuffle the live list.
location.reload();
}
}).catch(() => {});
return;
}
}); });
// ── Reply form ──────────────────────────────────────── // Translate — bound at `document` level once, dispatches to the LATEST
// IIFE's handler via window.__ytcToggleTranslate. SPA video-to-video swaps
// replace #ytcSection and re-run this script; the new IIFE overwrites
// window.__ytcToggleTranslate so it keeps using fresh state (YTC.csrf etc.).
window.__ytcToggleTranslate = function (btn) { toggleTranslate(btn); };
if (!window.__ytcTranslateDelegated) {
window.__ytcTranslateDelegated = true;
document.addEventListener('click', e => {
const tb = e.target.closest('._comment-translate-trigger');
if (!tb) return;
e.stopPropagation();
if (typeof window.__ytcToggleTranslate === 'function') window.__ytcToggleTranslate(tb);
});
}
// ── Translate ─────────────────────────────────────────
async function toggleTranslate(btn) {
const id = btn.dataset.commentId;
const panel = document.getElementById('translation-' + id);
const label = btn.querySelector('.ytc-translate-label');
if (!panel) return;
// Toggle off if already shown.
if (panel.style.display !== 'none' && panel.dataset.loaded === '1') {
panel.style.display = 'none';
btn.classList.remove('active');
if (label) label.textContent = 'Translate';
return;
}
// Already loaded — just reveal.
if (panel.dataset.loaded === '1') {
panel.style.display = 'block';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
return;
}
btn.classList.add('loading');
if (label) label.textContent = 'Translating…';
try {
const r = await fetch('/comments/' + id + '/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
body: JSON.stringify({ target: targetLang() })
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
panel.innerHTML = '';
const meta = document.createElement('span');
meta.className = 'ytc-translation-meta';
meta.textContent = 'Translated → ' + (d.target || targetLang()).toUpperCase();
const body = document.createElement('div');
body.textContent = d.translated;
panel.appendChild(meta);
panel.appendChild(body);
panel.dataset.loaded = '1';
panel.style.display = 'block';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
} catch (err) {
toast(err.message || 'Translation failed', 'error');
if (label) label.textContent = 'Translate';
} finally {
btn.classList.remove('loading');
}
}
// ── Reply form (lazy-rendered from a <template>) ──────
// Replaces the per-comment inline markup that used to live in the DOM
// pre-hidden. First click on "Reply" clones the template into the comment's
// .ytc-reply-slot and wires its IDs. Subsequent toggles just show/hide.
function toggleReplyForm(commentId) { function toggleReplyForm(commentId) {
const form = document.getElementById('replyForm' + commentId); let form = document.getElementById('replyForm' + commentId);
if (!form) return; if (!form) {
const slot = document.querySelector('.ytc-reply-slot[data-comment-id="' + commentId + '"]');
const tpl = document.getElementById('ytc-reply-form-tpl');
if (!slot || !tpl) return;
const frag = tpl.content.cloneNode(true);
const root = frag.querySelector('.ytc-reply-form');
const ta = frag.querySelector('.ytc-reply-textarea');
const cancel = frag.querySelector('._comment-cancel-reply-trigger');
const submit = frag.querySelector('._comment-submit-reply-trigger');
root.id = 'replyForm' + commentId;
ta.id = 'replyBody' + commentId;
cancel.dataset.commentId = commentId;
submit.dataset.parentId = commentId;
submit.dataset.videoId = slot.dataset.videoId || YTC.videoId;
slot.appendChild(frag);
form = root;
}
const isOpen = form.classList.contains('open'); const isOpen = form.classList.contains('open');
document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open')); document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open'));
if (!isOpen) { if (!isOpen) {
@@ -970,16 +1367,26 @@ async function saveEdit(commentId) {
// ── Post comment ────────────────────────────────────── // ── Post comment ──────────────────────────────────────
async function postComment() { async function postComment() {
if (postComment._busy) return; // guard against double-submit (fast dbl-click, Enter+click, duplicate listeners after SPA swap)
const ta = document.getElementById('ytcTextarea'); const ta = document.getElementById('ytcTextarea');
if (!ta) return; if (!ta) return;
const text = ta.value.trim(); const text = ta.value.trim();
if (!text) { toast('Write something first', 'error'); return; } if (!text) { toast('Write something first', 'error'); return; }
postComment._busy = true;
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Posting…'; submitBtn.textContent = 'Posting…';
try { try {
// Idempotency key: random per attempt. On retry the client reuses the
// same key so the server returns the original comment instead of
// writing a second one.
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
const r = await fetch('/videos/' + YTC.videoId + '/comments', { const r = await fetch('/videos/' + YTC.videoId + '/comments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf }, headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': YTC.csrf,
'X-Idempotency-Key': idemKey,
},
body: JSON.stringify({ body: text }) body: JSON.stringify({ body: text })
}); });
if (!(r.headers.get('content-type') || '').includes('application/json')) { if (!(r.headers.get('content-type') || '').includes('application/json')) {
@@ -1000,6 +1407,7 @@ async function postComment() {
} catch (err) { } catch (err) {
toast(err.message, 'error'); toast(err.message, 'error');
} finally { } finally {
postComment._busy = false;
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = 'Comment'; submitBtn.textContent = 'Comment';
} }
@@ -1012,11 +1420,21 @@ async function postReply(vid, parentId) {
const text = input.value.trim(); const text = input.value.trim();
if (!text) { toast('Write something first', 'error'); return; } if (!text) { toast('Write something first', 'error'); return; }
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger'); const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger');
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; } if (btn) {
if (btn.dataset.busy === '1') return; // guard against double-submit
btn.dataset.busy = '1';
btn.disabled = true;
btn.textContent = 'Posting…';
}
try { try {
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
const r = await fetch('/videos/' + vid + '/comments', { const r = await fetch('/videos/' + vid + '/comments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf }, headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': YTC.csrf,
'X-Idempotency-Key': idemKey,
},
body: JSON.stringify({ body: text, parent_id: parentId }) body: JSON.stringify({ body: text, parent_id: parentId })
}); });
if (!(r.headers.get('content-type') || '').includes('application/json')) { if (!(r.headers.get('content-type') || '').includes('application/json')) {
@@ -1035,7 +1453,7 @@ async function postReply(vid, parentId) {
} catch (err) { } catch (err) {
toast(err.message, 'error'); toast(err.message, 'error');
} finally { } finally {
if (btn) { btn.disabled = false; btn.textContent = 'Reply'; } if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
} }
} }
@@ -1133,6 +1551,12 @@ function buildCommentEl(c, isReply) {
</div>` : ''; </div>` : '';
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : ''; const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : '';
const translateBtn = YTC.llmEnabled ? `
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="${id}" title="Translate this comment">
${YTC_TRANSLATE_SVG}<span class="ytc-translate-label">Translate</span>
</button>` : '';
const translationPanel = YTC.llmEnabled ? `<div class="ytc-translation" id="translation-${id}" style="display:none"></div>` : '';
const flagEl = YTC.meCountry ? `<span class="ytc-flag fi fi-${YTC.meCountry}"></span>` : '';
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : ''); div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
@@ -1143,10 +1567,12 @@ function buildCommentEl(c, isReply) {
</a> </a>
<div class="ytc-body-wrap"> <div class="ytc-body-wrap">
<div class="ytc-meta"> <div class="ytc-meta">
${flagEl}
<span class="ytc-author">${name}</span> <span class="ytc-author">${name}</span>
<span class="ytc-time">${time}</span> <span class="ytc-time">${time}</span>
</div> </div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div> <div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
${translationPanel}
${editForm} ${editForm}
<div class="ytc-actions"> <div class="ytc-actions">
<button class="ytc-like-btn" data-id="${id}" title="Like"> <button class="ytc-like-btn" data-id="${id}" title="Like">
@@ -1157,6 +1583,7 @@ function buildCommentEl(c, isReply) {
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg> <svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button> </button>
${replyBtn} ${replyBtn}
${translateBtn}
${moreMenu} ${moreMenu}
</div> </div>
${replyForm} ${replyForm}
@@ -1226,6 +1653,32 @@ function toggleDislike(btn) {
function initLikeStates() {} // server-rendered state is already in the HTML function initLikeStates() {} // server-rendered state is already in the HTML
// ── Timestamp & @mention parsing ───────────────────── // ── Timestamp & @mention parsing ─────────────────────
// ── Read-more clamp ──
// Flags long bodies (>4 lines once rendered) so a "Read more" button
// below the body expands them. Measured in computed line-height × 4.
function maybeClampBody(el) {
// Deferred one microtask so el.scrollHeight is correct even if the
// enclosing comment card just got inserted into the DOM.
requestAnimationFrame(() => {
const lh = parseFloat(getComputedStyle(el).lineHeight) || 22;
const limit = lh * 4;
if (el.scrollHeight <= limit + 1) return;
el.classList.add('ytc-clamp');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ytc-read-more';
btn.textContent = 'Read more';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const expanded = el.classList.toggle('ytc-clamp') === false;
btn.textContent = expanded ? 'Show less' : 'Read more';
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
el.insertAdjacentElement('afterend', btn);
});
}
function enhanceBody(el) { function enhanceBody(el) {
if (!el || el.dataset._commentEnhanced === '1') return; if (!el || el.dataset._commentEnhanced === '1') return;
let text = el.textContent || ''; let text = el.textContent || '';
@@ -1255,6 +1708,9 @@ function enhanceBody(el) {
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null); playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
}); });
}); });
// Add the Read more affordance after enhancing (so links and timestamps
// are rendered first and the measured height is final).
maybeClampBody(el);
} }
function enhanceAll() { function enhanceAll() {
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody); document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
@@ -1309,16 +1765,39 @@ const listObserver = new MutationObserver(() => {
const list = document.getElementById('ytcList'); const list = document.getElementById('ytcList');
if (list) listObserver.observe(list, { childList: true, subtree: true }); if (list) listObserver.observe(list, { childList: true, subtree: true });
// ── Init ────────────────────────────────────────────── // ── Comment permalink (?lc=<id>) ─────────────────────
document.addEventListener('DOMContentLoaded', () => { // Scroll to #comment-<id> and briefly highlight the row so a shared link
enhanceAll(); // lands on the exact message. The reply row is nested inside a .ytc-replies
initLikeStates(); // expander by default — we auto-open the parent thread first so the target
}); // is visible before we scroll.
if (document.readyState !== 'loading') { function openCommentPermalink(id) {
enhanceAll(); if (!id) return;
initLikeStates(); const target = document.getElementById('comment-' + id);
if (!target) return;
// Find and expand the parent replies section, if any.
const repliesSection = target.closest('.ytc-replies-section');
if (repliesSection) {
const toggle = repliesSection.querySelector('.ytc-replies-toggle');
if (toggle && toggle.dataset.open !== '1') toggle.click();
}
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('ytc-highlight');
setTimeout(() => target.classList.remove('ytc-highlight'), 2800);
});
} }
// ── Init ──────────────────────────────────────────────
function _ytcInit() {
enhanceAll();
initLikeStates();
@if($permalinkId)
openCommentPermalink({{ (int) $permalinkId }});
@endif
}
document.addEventListener('DOMContentLoaded', _ytcInit);
if (document.readyState !== 'loading') _ytcInit();
// ── Public API (for backward compat) ───────────────── // ── Public API (for backward compat) ─────────────────
window._comment = { window._comment = {
deleteComment: openDeleteModal, deleteComment: openDeleteModal,
@@ -1327,3 +1806,22 @@ window._comment = {
})(); })();
</script> </script>
@auth
{{-- Reply-form template. Cloned into .ytc-reply-slot the first time a user
clicks Reply on a given comment. Keeping it here (not in the layout)
means the markup travels with the comments component — the only place
that uses it. --}}
<template id="ytc-reply-form-tpl">
<div class="ytc-reply-form">
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
<div class="ytc-input-wrap">
<textarea class="ytc-textarea ytc-reply-textarea" placeholder="Add a reply..." rows="1"></textarea>
<div class="ytc-form-actions">
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger">Reply</button>
</div>
</div>
</div>
</template>
@endauth
+239 -19
View File
@@ -9,7 +9,7 @@
@php @php
// Force 16:9 for every video — orientation classes intentionally disabled // Force 16:9 for every video — orientation classes intentionally disabled
$orientationClass = ''; $orientationClass = '';
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null; $hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'playlist.m3u8']) : null;
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp; $mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null; $nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
$prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null; $prevUrl = $previousVideo && $playlist ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
@@ -205,12 +205,99 @@
</div>{{-- /chrome-controls --}} </div>{{-- /chrome-controls --}}
</div>{{-- /chrome-bottom --}} </div>{{-- /chrome-bottom --}}
{{-- End screen: shown when the video reaches its 'ended' event. Carries
a thumbnail + title for the next recommended video and a radial
countdown ring; auto-advances when the ring completes, cancelled on
any click inside the player. --}}
<div class="ytp-end-screen" id="ytpEndScreen" aria-hidden="true">
<div class="ytp-end-inner">
<div class="ytp-end-label">Up next</div>
<a class="ytp-end-card" id="ytpEndCard" href="#">
<div class="ytp-end-thumb">
<img id="ytpEndThumb" src="" alt="" loading="lazy">
<div class="ytp-end-countdown">
<svg viewBox="0 0 36 36" aria-hidden="true">
<circle class="ytp-end-track" cx="18" cy="18" r="16"></circle>
<circle class="ytp-end-fill" cx="18" cy="18" r="16" id="ytpEndRing"></circle>
</svg>
<span class="ytp-end-timer" id="ytpEndTimer">5</span>
</div>
</div>
<div class="ytp-end-title" id="ytpEndTitle">Loading…</div>
</a>
<div class="ytp-end-actions">
<button type="button" class="ytp-end-btn ytp-end-cancel" id="ytpEndCancel">Cancel</button>
<button type="button" class="ytp-end-btn ytp-end-play" id="ytpEndPlay">Play now</button>
</div>
</div>
</div>
</div>{{-- /ytp --}} </div>{{-- /ytp --}}
</div>{{-- /ytp-wrap --}} </div>{{-- /ytp-wrap --}}
@once @once
<style> <style>
/* ── End screen (autoplay countdown) ─────────────────────── */
.ytp-end-screen {
position: absolute; inset: 0;
display: none;
align-items: center; justify-content: center;
background: rgba(0,0,0,.78);
backdrop-filter: blur(6px);
z-index: 20;
color: #fff;
}
.ytp-end-screen.visible { display: flex; }
.ytp-end-inner {
display: flex; flex-direction: column; align-items: center;
gap: 16px; max-width: 560px; padding: 24px; text-align: center;
}
.ytp-end-label {
font-size: 12px; letter-spacing: .22em; text-transform: uppercase;
color: #aaa;
}
.ytp-end-card {
display: flex; flex-direction: column; gap: 10px;
text-decoration: none; color: inherit; cursor: pointer;
border-radius: 12px; padding: 12px; max-width: 420px;
transition: background .15s;
}
.ytp-end-card:hover { background: rgba(255,255,255,.06); color: #fff; }
.ytp-end-thumb {
position: relative; width: 100%; aspect-ratio: 16/9;
border-radius: 10px; overflow: hidden; background: #1a1a1a;
}
.ytp-end-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ytp-end-countdown {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.4);
}
.ytp-end-countdown svg { width: 72px; height: 72px; transform: rotate(-90deg); }
.ytp-end-track { fill: none; stroke: rgba(255,255,255,.25); stroke-width: 3; }
.ytp-end-fill { fill: none; stroke: #fff; stroke-width: 3;
stroke-dasharray: 100.5; stroke-dashoffset: 100.5;
transition: stroke-dashoffset .1s linear; }
.ytp-end-timer {
position: absolute; font-size: 22px; font-weight: 700;
color: #fff; text-shadow: 0 1px 4px rgba(0,0,0,.6);
}
.ytp-end-title {
font-size: 16px; font-weight: 500;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.ytp-end-actions { display: flex; gap: 10px; }
.ytp-end-btn {
padding: 8px 18px; border-radius: 20px; font-size: 13px; font-weight: 600;
border: 1px solid rgba(255,255,255,.3); background: transparent; color: #fff;
cursor: pointer; transition: background .15s;
}
.ytp-end-btn:hover { background: rgba(255,255,255,.12); }
.ytp-end-play { background: #ef4444; border-color: #ef4444; }
.ytp-end-play:hover { background: #dc2626; }
/* ══════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════
YTP PLAYER — YouTube-identical custom player YTP PLAYER — YouTube-identical custom player
══════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════ */
@@ -220,19 +307,31 @@
background: #000; background: #000;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
/* default aspect ratio; overridden per orientation */ /* Always 16:9 — no max-height so the shape is never distorted. */
aspect-ratio: 16/9; aspect-ratio: 16/9;
max-height: 70vh; /* Establish a size container so descendants can size themselves relative
to the player width (used by the coach-note overlay to scale). */
container-type: inline-size;
container-name: ytpwrap;
}
/* Orientation classes intentionally forced to 16:9 too, so the frame
shape never changes with the underlying media orientation. The video
element itself uses object-fit: contain and will letterbox as needed. */
.ytp-wrap.portrait,
.ytp-wrap.square,
.ytp-wrap.ultrawide {
aspect-ratio: 16/9;
width: 100%;
max-width: none;
max-height: none;
margin: 0;
} }
.ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
.ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: 65vh; }
/* Theater mode */ /* Theater mode — keep 16:9 shape, just remove rounded corners. */
.ytp-wrap.theater { .ytp-wrap.theater {
max-height: 80vh; aspect-ratio: 16/9;
aspect-ratio: unset; height: auto;
height: 80vh; max-height: none;
border-radius: 0; border-radius: 0;
} }
@@ -530,10 +629,23 @@
background: rgba(28,28,28,.95); background: rgba(28,28,28,.95);
border-radius: 12px; border-radius: 12px;
min-width: 200px; min-width: 200px;
overflow: hidden; /* Cap height so the panel stays inside the player and scrolls when the
item list (mini/speed/autoplay/shuffle + injected Scoreboard toggles)
grows taller than the available space above the control bar. */
max-height: calc(100vh - 120px);
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
box-shadow: 0 4px 24px rgba(0,0,0,.6); box-shadow: 0 4px 24px rgba(0,0,0,.6);
z-index: 100; z-index: 100;
} }
/* Slim, on-brand scrollbar so it doesn't clash with the dark panel. */
.ytp-settings-panel::-webkit-scrollbar { width: 6px; }
.ytp-settings-panel::-webkit-scrollbar-thumb {
background: rgba(255,255,255,.22); border-radius: 3px;
}
.ytp-settings-panel::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.35); }
.ytp-settings-panel::-webkit-scrollbar-track { background: transparent; }
.ytp-settings-panel.open { display: block; } .ytp-settings-panel.open { display: block; }
.ytp-settings-item { .ytp-settings-item {
display: flex; display: flex;
@@ -587,10 +699,10 @@
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 768px) { @media (max-width: 768px) {
.ytp-wrap { border-radius: 0; max-height: 56vw; margin: 0; width: 100%; } .ytp-wrap { border-radius: 0; margin: 0; width: 100%; aspect-ratio: 16/9; max-height: none; }
.ytp-wrap.portrait { max-height: 75vh; width: auto; max-width: 100%; } .ytp-wrap.portrait,
.ytp-wrap.square { max-height: 85vw; max-width: 85vw; } .ytp-wrap.square,
.ytp-wrap.ultrawide { max-height: 50vw; } .ytp-wrap.ultrawide { aspect-ratio: 16/9; width: 100%; max-width: none; max-height: none; }
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; } .video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
} }
@@ -1279,17 +1391,96 @@ function navigatePrev() {
} }
window._ytpNav = { next: navigateNext, prev: navigatePrev }; window._ytpNav = { next: navigateNext, prev: navigatePrev };
// ── End screen + autoplay countdown ─────────────────────
// Picks the first "Up Next" sidebar card as the next target and shows a
// 5-second countdown overlay. If the viewer doesn't cancel, we hand off to
// the existing _plOnVideoEnd / navigateNext flow.
const endScreen = document.getElementById('ytpEndScreen');
const endRing = document.getElementById('ytpEndRing');
const endTimer = document.getElementById('ytpEndTimer');
const endCard = document.getElementById('ytpEndCard');
const endThumb = document.getElementById('ytpEndThumb');
const endTitle = document.getElementById('ytpEndTitle');
const endCancel = document.getElementById('ytpEndCancel');
const endPlay = document.getElementById('ytpEndPlay');
let endInterval = null;
let endTargetUrl = null;
function closeEndScreen() {
if (endScreen) endScreen.classList.remove('visible');
if (endInterval) { clearInterval(endInterval); endInterval = null; }
}
function _findNextRecCard() {
// Prefer the first unwatched recommendation; fall back to the first card.
const list = document.querySelector('#recList');
if (!list) return null;
return list.querySelector('.sidebar-video-card[data-rec-url]');
}
function openEndScreen() {
if (!endScreen) {
// No end-screen markup (e.g. shorts). Fall back to the original behavior.
if (window._plOnVideoEnd) window._plOnVideoEnd();
else if (autoplayOn || isLooping) navigateNext();
return;
}
const card = _findNextRecCard();
if (!card) {
// Nothing to count down to — restore the old no-UI behavior.
if (window._plOnVideoEnd) window._plOnVideoEnd();
else if (autoplayOn || isLooping) navigateNext();
return;
}
endTargetUrl = card.dataset.recUrl;
endCard.href = endTargetUrl;
const img = card.querySelector('img');
const title = card.querySelector('.sidebar-title');
if (img && endThumb) endThumb.src = img.src;
if (title && endTitle) endTitle.textContent = title.textContent.trim();
endScreen.classList.add('visible');
let left = 5;
endTimer.textContent = left;
endRing.style.strokeDashoffset = '100.5';
const start = Date.now();
const total = 5000;
endInterval = setInterval(() => {
const elapsed = Date.now() - start;
const remaining = Math.max(0, total - elapsed);
left = Math.ceil(remaining / 1000);
endTimer.textContent = left;
endRing.style.strokeDashoffset = String(100.5 * (remaining / total));
if (remaining <= 0) {
closeEndScreen();
if (window._plOnVideoEnd) window._plOnVideoEnd();
else navigateNext();
}
}, 100);
}
endCancel?.addEventListener('click', closeEndScreen);
endPlay?.addEventListener('click', () => {
closeEndScreen();
if (window._plOnVideoEnd) window._plOnVideoEnd();
else navigateNext();
});
video.addEventListener('ended', () => { video.addEventListener('ended', () => {
updatePlayIcon(); updatePlayIcon();
largePlay.classList.add('visible'); largePlay.classList.add('visible');
clearTimeout(hideTimer); clearTimeout(hideTimer);
releaseWakeLock(); releaseWakeLock();
if (window._plOnVideoEnd) { if (isLooping) { navigateNext(); return; }
window._plOnVideoEnd(); if (autoplayOn || window._plOnVideoEnd) {
} else if (autoplayOn || isLooping) { openEndScreen();
navigateNext();
} }
}); });
// Any interaction inside the player cancels the countdown so the viewer
// can scrub back, replay, or just stop.
video.addEventListener('play', closeEndScreen);
video.addEventListener('seeking', closeEndScreen);
video.addEventListener('volumechange', updateVolumeIcon); video.addEventListener('volumechange', updateVolumeIcon);
// ── Keyboard shortcuts ──────────────────────────────── // ── Keyboard shortcuts ────────────────────────────────
@@ -1314,10 +1505,39 @@ document.addEventListener('keydown', e => {
e.preventDefault(); theaterBtn && theaterBtn.click(); break; e.preventDefault(); theaterBtn && theaterBtn.click(); break;
case 'i': case 'I': case 'i': case 'I':
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break; e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
case 'c': case 'C':
// Captions / lyrics toggle — click the lyrics button when the
// page has one (music type). On a video that has no lyrics
// the key is a no-op; we don't throw up a toast because the
// viewer may just be hitting letters next to K / L / M.
{
const lyr = document.getElementById('ytpLyricsBtn');
if (lyr && lyr.offsetParent !== null) { e.preventDefault(); lyr.click(); showControls(); }
}
break;
case 'j': case 'J': case 'j': case 'J':
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break; e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
case 'l': case 'L': case 'l': case 'L':
e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break; e.preventDefault(); seekRelative(10); flashDbl(dblRight); showControls(); break;
case '>':
case '.':
// YouTube parity: shift+. (which produces ">") steps playback
// rate up in 0.25 jumps. "." alone also works for keyboards
// where ">" needs a modifier the browser doesn't surface.
if (e.shiftKey || e.key === '>') {
e.preventDefault();
video.playbackRate = Math.min(2, Math.round((video.playbackRate + 0.25) * 100) / 100);
showControls();
}
break;
case '<':
case ',':
if (e.shiftKey || e.key === '<') {
e.preventDefault();
video.playbackRate = Math.max(0.25, Math.round((video.playbackRate - 0.25) * 100) / 100);
showControls();
}
break;
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); seekRelative(-5); showControls(); break; e.preventDefault(); seekRelative(-5); showControls(); break;
case 'ArrowRight': case 'ArrowRight':
+74
View File
@@ -0,0 +1,74 @@
@extends('layouts.app')
@section('title', 'Page not found | ' . config('app.name'))
@section('content')
<style>
.err-404 {
max-width: 560px;
margin: 80px auto;
padding: 48px 32px;
text-align: center;
}
.err-404-emoji {
font-size: 72px;
line-height: 1;
margin-bottom: 12px;
}
.err-404 h1 {
font-size: 28px;
font-weight: 700;
margin: 0 0 10px;
color: var(--text-primary);
}
.err-404 p {
font-size: 15px;
color: var(--text-secondary);
margin: 0 0 24px;
line-height: 1.6;
}
.err-404-code {
font-size: 11px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 4px;
}
.err-404-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
margin-top: 24px;
}
@media (max-width: 480px) {
.err-404 { margin: 32px 12px; padding: 32px 16px; }
.err-404-emoji { font-size: 56px; }
.err-404 h1 { font-size: 22px; }
}
</style>
<div class="err-404">
<div class="err-404-emoji" aria-hidden="true">🎞️</div>
<div class="err-404-code">Error 404</div>
<h1>Can't find that one</h1>
<p>
The page or video you were looking for isn't here. It might have
been removed, renamed, or the link might have a typo.
</p>
<div class="err-404-actions">
<a href="{{ route('home') }}" class="action-btn action-btn-primary">
<i class="bi bi-house-door-fill" aria-hidden="true"></i>
<span>Back to Home</span>
</a>
<a href="{{ route('videos.index') }}" class="action-btn">
<i class="bi bi-grid-fill" aria-hidden="true"></i>
<span>Browse Videos</span>
</a>
<a href="{{ route('videos.shorts') }}" class="action-btn">
<i class="bi bi-lightning-charge-fill" aria-hidden="true"></i>
<span>Watch Shorts</span>
</a>
</div>
</div>
@endsection
+81 -14
View File
@@ -435,6 +435,14 @@
.action-btn.icon-only { padding: 8px; } .action-btn.icon-only { padding: 8px; }
/* Filter chip bar */ /* Filter chip bar */
.yt-filter-bar-wrap {
position: sticky;
top: 56px;
z-index: 90;
margin: -24px -24px 20px;
background: var(--bg-dark);
border-bottom: 1px solid var(--border-color);
}
.yt-filter-bar { .yt-filter-bar {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -442,15 +450,35 @@
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
padding: 12px 24px; padding: 12px 24px;
margin: -24px -24px 20px; scroll-behavior: smooth;
background: var(--bg-dark);
position: sticky;
top: 56px;
z-index: 90;
border-bottom: 1px solid var(--border-color);
} }
.yt-filter-bar::-webkit-scrollbar { display: none; } .yt-filter-bar::-webkit-scrollbar { display: none; }
/* Left / right scroll buttons — visible only when the bar overflows */
.yt-filter-nav {
position: absolute; top: 0; bottom: 0;
display: none; align-items: center;
width: 48px;
background: transparent;
border: none;
color: var(--text-primary);
cursor: pointer;
z-index: 2;
padding: 0;
font-size: 20px;
justify-content: center;
}
.yt-filter-nav.visible { display: flex; }
.yt-filter-nav.left { left: 0; background: linear-gradient(to right, var(--bg-dark) 40%, transparent); justify-content: flex-start; padding-left: 6px; }
.yt-filter-nav.right { right: 0; background: linear-gradient(to left, var(--bg-dark) 40%, transparent); justify-content: flex-end; padding-right: 6px; }
.yt-filter-nav i {
width: 32px; height: 32px; border-radius: 50%;
background: var(--bg-secondary);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
}
.yt-filter-nav:hover i { background: #555; }
.yt-chip { .yt-chip {
flex-shrink: 0; flex-shrink: 0;
background: #3f3f3f; background: #3f3f3f;
@@ -1170,12 +1198,15 @@
} }
body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; } body.has-impersonate-bar .yt-main { top: calc(56px + 40px) !important; }
.yt-main.video-view-page { padding: 0 !important; } .yt-main.video-view-page { padding: 0 !important; }
.yt-filter-bar { .yt-filter-bar-wrap {
position: relative !important; position: relative !important;
top: auto !important; top: auto !important;
z-index: auto !important; z-index: auto !important;
margin: -16px -16px 16px !important; margin: -16px -16px 16px !important;
} }
.yt-filter-bar {
padding: 12px 16px !important;
}
} }
</style> </style>
@@ -1187,6 +1218,20 @@
<script src="{{ asset('fp.js') }}" async></script> <script src="{{ asset('fp.js') }}" async></script>
</head> </head>
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}"> <body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
{{-- Skip link: keyboard users land here first and can jump straight to
the main content region, past the header + sidebar nav. Hidden
off-screen until focused. --}}
<a href="#main" class="skip-to-content">Skip to content</a>
<style>
.skip-to-content {
position: absolute; top: -40px; left: 8px;
padding: 8px 14px; z-index: 10000;
background: #ef4444; color: #fff; border-radius: 0 0 8px 8px;
text-decoration: none; font-weight: 600;
transition: top .15s ease;
}
.skip-to-content:focus { top: 0; outline: 2px solid #fff; outline-offset: 2px; }
</style>
<!-- Header --> <!-- Header -->
@include('layouts.partials.header') @include('layouts.partials.header')
@@ -1206,10 +1251,11 @@
<!-- Mobile Search Overlay --> <!-- Mobile Search Overlay -->
<div class="mobile-search-overlay" id="mobileSearchOverlay"> <div class="mobile-search-overlay" id="mobileSearchOverlay">
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form"> <form action="{{ route('search') }}" method="GET" class="mobile-search-form" role="search">
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}"> <label for="mobile-search-input" class="visually-hidden">Search</label>
<button type="submit" class="mobile-search-submit"> <input type="search" id="mobile-search-input" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
<i class="bi bi-search"></i> <button type="submit" class="mobile-search-submit" aria-label="Submit search">
<i class="bi bi-search" aria-hidden="true"></i>
</button> </button>
</form> </form>
</div> </div>
@@ -1331,10 +1377,18 @@
<i class="bi bi-fire"></i> <i class="bi bi-fire"></i>
<span>Trending</span> <span>Trending</span>
</a> </a>
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}"> @auth
<a href="#" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}"
onclick="event.preventDefault(); openUploadChooser();">
<i class="bi bi-plus-circle-fill"></i> <i class="bi bi-plus-circle-fill"></i>
<span>Upload</span> <span>Upload</span>
</a> </a>
@else
<a href="{{ route('login') }}" class="yt-bottom-nav-item">
<i class="bi bi-plus-circle-fill"></i>
<span>Upload</span>
</a>
@endauth
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}"> <a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
<i class="bi bi-collection-play-fill"></i> <i class="bi bi-collection-play-fill"></i>
<span>History</span> <span>History</span>
@@ -1658,11 +1712,24 @@
}; };
function loadNotifications() { function loadNotifications() {
// Switch the pre-rendered "No notifications" placeholder to a
// spinner while the fetch is in flight, then let the response
// handler replace it with the real list, an empty state, or
// an error message. Previously the panel just said "Loading…"
// forever if the fetch threw synchronously or returned non-JSON.
list.innerHTML = '<div class="yt-notif-empty"><i class="bi bi-arrow-repeat spin"></i> Loading…</div>';
fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }) fetch(fetchUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); }) .then(function (r) {
if (!r.ok) throw new Error('http ' + r.status);
return r.json();
})
.then(function (data) { loaded = true; renderNotifications(data); }) .then(function (data) { loaded = true; renderNotifications(data); })
.catch(function () { list.innerHTML = '<div class="yt-notif-empty">Could not load notifications.</div>'; }); .catch(function () {
list.innerHTML = '<div class="yt-notif-empty">Could not load notifications. <button type="button" class="yt-notif-retry" onclick="loadNotifications()">Retry</button></div>';
});
} }
// Expose for the inline Retry button above.
window.loadNotifications = loadNotifications;
window.toggleNotifPanel = function () { window.toggleNotifPanel = function () {
var btn = document.getElementById('notifBtn'); var btn = document.getElementById('notifBtn');
@@ -108,6 +108,7 @@
<div class="um-track-card" id="edit-tc-t1-card"> <div class="um-track-card" id="edit-tc-t1-card">
<div class="um-tc-body"> <div class="um-tc-body">
<div class="um-tc-left"> <div class="um-tc-left">
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
<div class="um-tc-num">1</div> <div class="um-tc-num">1</div>
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span> <span class="fi fi-xx um-tc-flag" id="edit-tc-flag-t1"></span>
<div class="um-tc-info"> <div class="um-tc-info">
@@ -116,12 +117,6 @@
</div> </div>
</div> </div>
<div class="um-tc-right"> <div class="um-tc-right">
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('t1-card','up')" title="Move up" style="display:none;">
<i class="bi bi-arrow-up"></i>
</button>
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('t1-card','down')" title="Move down" style="display:none;">
<i class="bi bi-arrow-down"></i>
</button>
<button type="button" class="action-btn" onclick="editOpenTrackPopup('t1')"> <button type="button" class="action-btn" onclick="editOpenTrackPopup('t1')">
<i class="bi bi-pencil"></i> <span>Edit</span> <i class="bi bi-pencil"></i> <span>Edit</span>
</button> </button>
@@ -183,26 +178,31 @@
</div> </div>
</div> </div>
{{-- ── Secondary track form template (cloned by JS for each extra track) ──── --}} {{-- Secondary tracks in this modal are cloned from the SHARED
<div id="edit-extra-track-tpl" style="display:none;" aria-hidden="true"> #um-track-form-template defined in layouts.partials.upload-modal. One
<x-track-editor-form template → identical UX in both the create (upload) modal and this modal. --}}
prefix="__TPL__"
:is-primary="false"
language-name="__LANGNAME__"
language-id="csd_v___TPL__"
title-name="__TITLENAME__"
title-id="edit-__TPL__-title"
desc-name="__DESCNAME__"
desc-id="edit-__TPL__-desc"
:embed-audio-input="true"
audio-input-name="__AUDIONAME__"
video-file-input-id=""
/>
</div>
<style> <style>
#editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; } #editVideoModal .modal-dialog { opacity: 0; transition: opacity .25s ease; }
#editVideoModal.show .modal-dialog { opacity: 1; } #editVideoModal.show .modal-dialog { opacity: 1; }
/* ── Track drag-and-drop reorder ─────────────────────────────── */
#edit-tc-list .um-track-card { position: relative; }
#edit-tc-list .um-tc-drag {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; margin-right: 2px;
color: #666; cursor: grab; user-select: none;
border-radius: 6px; transition: color .15s, background .15s;
flex-shrink: 0;
}
#edit-tc-list .um-tc-drag:hover { color: #e5e5e5; background: rgba(255,255,255,.05); }
#edit-tc-list .um-tc-drag:active { cursor: grabbing; }
#edit-tc-list .um-tc-drag i { font-size: 14px; line-height: 1; }
#edit-tc-list .um-track-card.dragging {
opacity: .4; outline: 2px dashed #e61e1e; outline-offset: -2px;
}
#edit-tc-list .um-track-card.drop-before { box-shadow: 0 -3px 0 0 #e61e1e inset; }
#edit-tc-list .um-track-card.drop-after { box-shadow: 0 3px 0 0 #e61e1e inset; }
</style> </style>
<script> <script>
@@ -220,12 +220,8 @@ window._editCurrentVideoId = null;
// ── Modal open / close ──────────────────────────────────────────────────────── // ── Modal open / close ────────────────────────────────────────────────────────
function openEditVideoModal(videoId) { function openEditVideoModal(videoId) {
if (window.innerWidth < 992) { // Always resolve type first so a match video opens the sports-match sheet
window.location.href = `/videos/${videoId}/edit`; // instead of the generic video-edit modal — same for mobile.
return;
}
window._editCurrentVideoId = videoId;
fetch(`/videos/${videoId}/edit`, { fetch(`/videos/${videoId}/edit`, {
headers: { headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}', 'X-CSRF-TOKEN': '{{ csrf_token() }}',
@@ -238,6 +234,25 @@ function openEditVideoModal(videoId) {
if (!data.success) { showToast('Failed to load video data', 'error'); return; } if (!data.success) { showToast('Failed to load video data', 'error'); return; }
const v = data.video; const v = data.video;
// Match videos always route to the sports-match sheet — edit the existing
// match if one is linked, otherwise open create-mode with the video already
// attached so the user can add the match details.
if (v.type === 'match' && typeof window.openSportsMatchModal === 'function') {
if (v.sports_match_id) {
window.openSportsMatchModal(v.sports_match_id);
} else {
window.openSportsMatchModal({ videoId: v.id, videoTitle: v.title });
}
return;
}
// Mobile fallback for non-match videos: full-page edit view
if (window.innerWidth < 992) {
window.location.href = `/videos/${videoId}/edit`;
return;
}
window._editCurrentVideoId = videoId;
document.getElementById('edit-form').action = `/videos/${videoId}`; document.getElementById('edit-form').action = `/videos/${videoId}`;
// Header // Header
@@ -288,8 +303,9 @@ function openEditVideoModal(videoId) {
_editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url })); _editSlidesData['t1'] = (v.slides || []).map(s => ({ id: s.id, url: s.url }));
_editRenderSlides('t1'); _editRenderSlides('t1');
// Extra tracks // Extra tracks — show whenever the row is declared music, even if the
if (v.is_audio && v.audio_tracks) { // primary file is still a video (mid-conversion state).
if ((v.type === 'music' || v.is_audio) && v.audio_tracks) {
v.audio_tracks.forEach(track => _editAddExistingTrack(track)); v.audio_tracks.forEach(track => _editAddExistingTrack(track));
} }
@@ -446,33 +462,16 @@ function _editFmtSize(bytes) {
return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
} }
// ── Track ordering ──────────────────────────────────────────────────────────── // ── Track ordering (drag-and-drop) ────────────────────────────────────────────
function editMoveTrack(cardSuffix, dir) {
const card = document.getElementById('edit-tc-' + cardSuffix);
if (!card) return;
const list = document.getElementById('edit-tc-list');
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const idx = cards.indexOf(card);
console.log('%c[EditTrack] Reorder:', 'color:#3b82f6', { card: card.id, trackId: card.dataset.trackId || 'primary', direction: dir, fromPos: idx + 1 });
if (dir === 'up' && idx > 0) list.insertBefore(card, cards[idx - 1]);
if (dir === 'down' && idx < cards.length - 1) list.insertBefore(cards[idx + 1], card);
_editUpdateTrackPositions();
}
function _editUpdateTrackPositions() { function _editUpdateTrackPositions() {
const list = document.getElementById('edit-tc-list'); const list = document.getElementById('edit-tc-list');
if (!list) return; if (!list) return;
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card')); const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const total = cards.length;
cards.forEach((card, i) => { cards.forEach((card, i) => {
const numEl = card.querySelector('.um-tc-num'); const numEl = card.querySelector('.um-tc-num');
if (numEl) numEl.textContent = i + 1; if (numEl) numEl.textContent = i + 1;
const badge = card.querySelector('.um-tc-primary'); const badge = card.querySelector('.um-tc-primary');
if (badge) badge.style.display = i === 0 ? '' : 'none'; if (badge) badge.style.display = i === 0 ? '' : 'none';
const up = card.querySelector('.edit-tc-arrow-up');
const down = card.querySelector('.edit-tc-arrow-down');
if (up) up.style.display = (i === 0) ? 'none' : '';
if (down) down.style.display = (i === total - 1) ? 'none' : '';
}); });
// Set promote_track_id: non-empty only when a secondary is at position 1 // Set promote_track_id: non-empty only when a secondary is at position 1
const first = cards[0]; const first = cards[0];
@@ -485,6 +484,93 @@ function _editUpdateTrackPositions() {
} }
} }
// HTML5 drag-and-drop: bind once on the shared parent so newly appended cards
// pick it up without extra wiring. The card starts non-draggable so text
// selection / button clicks work; grabbing the grip handle flips draggable=on
// for the duration of the drag.
(function () {
const list = document.getElementById('edit-tc-list');
if (!list || list.dataset.dndWired) return;
list.dataset.dndWired = '1';
// Reset any draggable="true" set by the initial HTML — we only enable it
// on demand via the grip handle.
list.querySelectorAll('.um-track-card').forEach(c => c.removeAttribute('draggable'));
let dragged = null;
list.addEventListener('mousedown', function (e) {
const handle = e.target.closest('.um-tc-drag');
if (!handle) return;
const card = handle.closest('.um-track-card');
if (!card || card.parentElement !== list) return;
card.setAttribute('draggable', 'true');
});
list.addEventListener('mouseup', function () {
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
});
list.addEventListener('dragstart', function (e) {
const card = e.target.closest('.um-track-card');
if (!card || card.parentElement !== list || card.getAttribute('draggable') !== 'true') {
e.preventDefault();
return;
}
dragged = card;
card.classList.add('dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', card.id); } catch (_) {}
});
list.addEventListener('dragend', function () {
if (dragged) dragged.classList.remove('dragging');
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
list.querySelectorAll('.um-track-card[draggable="true"]').forEach(c => c.removeAttribute('draggable'));
dragged = null;
});
// Always accept drops anywhere inside the list so the browser doesn't
// veto the drop when the cursor slips into gaps between cards.
list.addEventListener('dragover', function (e) {
if (!dragged) return;
e.preventDefault();
try { e.dataTransfer.dropEffect = 'move'; } catch (_) {}
const over = e.target.closest('.um-track-card');
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
if (!over || over === dragged || over.parentElement !== list) return;
const r = over.getBoundingClientRect();
const after = (e.clientY - r.top) > (r.height / 2);
over.classList.add(after ? 'drop-after' : 'drop-before');
});
list.addEventListener('drop', function (e) {
if (!dragged) return;
e.preventDefault();
const cards = Array.from(list.querySelectorAll(':scope > .um-track-card'));
const over = e.target.closest('.um-track-card');
let refNode;
if (over && over !== dragged && over.parentElement === list) {
const r = over.getBoundingClientRect();
const after = (e.clientY - r.top) > (r.height / 2);
refNode = after ? over.nextSibling : over;
} else {
// Cursor fell into a gap or outside a card — figure out the closest slot
// by comparing cursor Y against each card's midpoint.
let insertBefore = null;
for (const c of cards) {
if (c === dragged) continue;
const r = c.getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { insertBefore = c; break; }
}
refNode = insertBefore; // null → append to end
}
if (refNode === dragged) refNode = dragged.nextSibling;
list.insertBefore(dragged, refNode);
list.querySelectorAll('.drop-before, .drop-after').forEach(c => c.classList.remove('drop-before', 'drop-after'));
console.log('%c[EditTrack] Drag-reordered:', 'color:#3b82f6', { moved: dragged.id, refNode: refNode?.id || '(end)' });
_editUpdateTrackPositions();
});
})();
function _editSetLangCsd(wrap, code) { function _editSetLangCsd(wrap, code) {
if (!wrap || !code) return; if (!wrap || !code) return;
const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`); const opt = wrap.querySelector(`.csd-opt[data-v="${code}"]`);
@@ -621,6 +707,8 @@ function editSlidesZoneDrop(e, tid) { tid=tid||'t1'; e.preventDefault(); doc
function editHandleSlides(fileList, tid) { function editHandleSlides(fileList, tid) {
tid = tid || 't1'; tid = tid || 't1';
_editSlidesCropStart(fileList, tid, _editRenderSlides); _editSlidesCropStart(fileList, tid, _editRenderSlides);
const inp = document.getElementById('edit-' + tid + '-slides-input');
if (inp) inp.value = '';
} }
// ── Slides crop queue: every added image is cropped before it enters the strip ── // ── Slides crop queue: every added image is cropped before it enters the strip ──
@@ -641,8 +729,12 @@ function _editSlidesCropLoadNext() {
if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; } if (!_editSlidesCropQueue.length) { window.closeCropperModal('slides_edit'); return; }
const f = _editSlidesCropQueue.shift(); const f = _editSlidesCropQueue.shift();
if (typeof window.tcPreload_slides_edit === 'function') { if (typeof window.tcPreload_slides_edit === 'function') {
window.tcPreload_slides_edit(f); // Open modal first so the cropper canvas has a real layout box when
// initCropper measures. Preloading first (prior order) caused the
// second batch to open with stale zero-sized measurements and a Save
// button that silently did nothing.
window.openCropperModal_slides_edit(); window.openCropperModal_slides_edit();
requestAnimationFrame(() => window.tcPreload_slides_edit(f));
} }
} }
@@ -728,6 +820,8 @@ function editSlidesZoneDropE(e, tid) { e.preventDefault(); document.getEleme
function editHandleSlidesForTrack(tid, fileList) { function editHandleSlidesForTrack(tid, fileList) {
_editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack); _editSlidesCropStart(fileList, tid, _editRenderSlidesForTrack);
const inp = document.getElementById('edit-slides-input-' + tid);
if (inp) inp.value = '';
} }
function editClearSlidesForTrack(e, tid) { function editClearSlidesForTrack(e, tid) {
@@ -786,7 +880,9 @@ function _editRenderSlidesForTrack(tid) {
// ── Track popup ─────────────────────────────────────────────────────────────── // ── Track popup ───────────────────────────────────────────────────────────────
function editOpenTrackPopup(trackId) { function editOpenTrackPopup(trackId) {
document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none'); document.querySelectorAll('#edit-track-popup .um-track-form').forEach(f => f.style.display = 'none');
const form = document.getElementById('edit-tf-' + trackId); // Primary track form uses the legacy id (edit-tf-t1); extra tracks are
// cloned from the SHARED #um-track-form-template and use id um-tf-{id}.
const form = document.getElementById('edit-tf-' + trackId) || document.getElementById('um-tf-' + trackId);
if (form) form.style.display = 'block'; if (form) form.style.display = 'block';
const popup = document.getElementById('edit-track-popup'); const popup = document.getElementById('edit-track-popup');
popup.style.opacity = '0'; popup.style.opacity = '0';
@@ -803,6 +899,10 @@ function editCloseTrackPopup() {
} }
} }
// For t1 the inputs are the primary-track Blade ids. For extra tracks (both
// newly-added and existing-track rows) the inputs come from the SHARED
// #um-track-form-template, so they use the shared ids track-lang-{id} /
// track-title-{id}. One function reads whichever is present.
function editUpdateTrackCard(trackId) { function editUpdateTrackCard(trackId) {
let langCode = '', title = ''; let langCode = '', title = '';
if (trackId === 't1') { if (trackId === 't1') {
@@ -811,9 +911,8 @@ function editUpdateTrackCard(trackId) {
langCode = li ? li.value : ''; langCode = li ? li.value : '';
title = ti ? ti.value.trim() : ''; title = ti ? ti.value.trim() : '';
} else { } else {
const n = trackId.replace('e',''); const li = document.getElementById('track-lang-' + trackId);
const li = document.getElementById('csd_v_e' + n); const ti = document.getElementById('track-title-' + trackId);
const ti = document.getElementById('edit-e' + n + '-title');
langCode = li ? li.value : ''; langCode = li ? li.value : '';
title = ti ? ti.value.trim() : ''; title = ti ? ti.value.trim() : '';
} }
@@ -833,24 +932,26 @@ function editUpdateTrackCard(trackId) {
// ── Extra track management ──────────────────────────────────────────────────── // ── Extra track management ────────────────────────────────────────────────────
const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage()); const LANG_OPTIONS_EDIT_MODAL = @json(\App\Data\Languages::forLanguage());
// Clone the SHARED upload-modal track-form partial into the edit popup. Both
// "add new track" and "load existing track" go through here, so there is one
// source of truth for the form markup. The caller picks the submission names:
// - new track → extra_track_*[]
// - existing track → track_*_updates[trackId]
function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) { function _editCloneTrackForm(prefix, langName, titleName, descName, audioName) {
const tpl = document.getElementById('edit-extra-track-tpl'); const frag = window.cloneTrackFormTemplate({
if (!tpl) return null; key: prefix, langName, titleName, descName, audioName,
const html = tpl.innerHTML });
.replaceAll('__TPL__', prefix) if (!frag) return null;
.replaceAll('__LANGNAME__', langName) const host = document.getElementById('edit-tf-extra');
.replaceAll('__TITLENAME__', titleName) host.appendChild(frag);
.replaceAll('__DESCNAME__', descName) // _slidesData is the shared state used by the shared slides-zone handlers.
.replaceAll('__AUDIONAME__', audioName); // Mirror the handle under _editSlidesData so the edit submit code stays
const tmp = document.createElement('div'); // oblivious to the migration (one array, two aliases).
tmp.innerHTML = html; window._slidesData = window._slidesData || {};
const formEl = tmp.querySelector('.um-track-form'); _slidesData[prefix] = [];
if (!formEl) return null; _editSlidesData[prefix] = _slidesData[prefix];
formEl.style.display = 'none'; window.initTrackFormNode(prefix, pfx => editUpdateTrackCard(pfx));
document.getElementById('edit-tf-extra').appendChild(formEl); return document.getElementById('um-tf-' + prefix);
_editSlidesData[prefix] = [];
if (window.CSD) new CSD('csd_' + prefix);
return formEl;
} }
function _editTrackCard(n, trackId, isExisting) { function _editTrackCard(n, trackId, isExisting) {
@@ -863,6 +964,7 @@ function _editTrackCard(n, trackId, isExisting) {
card.innerHTML = ` card.innerHTML = `
<div class="um-tc-body"> <div class="um-tc-body">
<div class="um-tc-left"> <div class="um-tc-left">
<span class="um-tc-drag" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
<div class="um-tc-num">${n + 1}</div> <div class="um-tc-num">${n + 1}</div>
<span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span> <span class="fi fi-xx um-tc-flag" id="edit-tc-flag-e${n}"></span>
<div class="um-tc-info"> <div class="um-tc-info">
@@ -871,8 +973,6 @@ function _editTrackCard(n, trackId, isExisting) {
</div> </div>
</div> </div>
<div class="um-tc-right"> <div class="um-tc-right">
<button type="button" class="action-btn icon-only edit-tc-arrow-up" onclick="editMoveTrack('e${n}','up')" title="Move up" style="display:none;"><i class="bi bi-arrow-up"></i></button>
<button type="button" class="action-btn icon-only edit-tc-arrow-down" onclick="editMoveTrack('e${n}','down')" title="Move down" style="display:none;"><i class="bi bi-arrow-down"></i></button>
<button type="button" class="action-btn" onclick="editOpenTrackPopup('e${n}')"><i class="bi bi-pencil"></i> <span>Edit</span></button> <button type="button" class="action-btn" onclick="editOpenTrackPopup('e${n}')"><i class="bi bi-pencil"></i> <span>Edit</span></button>
${deleteBtn} ${deleteBtn}
</div> </div>
@@ -892,34 +992,47 @@ function _editAddExistingTrack(track) {
'track_description_updates[' + track.id + ']', 'track_description_updates[' + track.id + ']',
'track_file_updates[' + track.id + ']' 'track_file_updates[' + track.id + ']'
); );
const titleEl = document.getElementById('edit-' + pfx + '-title'); // Shared partial field ids: track-title-{pfx} / track-desc-{pfx} / track-lang-{pfx}
const titleEl = document.getElementById('track-title-' + pfx);
if (titleEl) titleEl.value = track.title || ''; if (titleEl) titleEl.value = track.title || '';
const descEl = document.getElementById('edit-' + pfx + '-desc'); const descEl = document.getElementById('track-desc-' + pfx);
if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); } if (descEl) { descEl.value = track.description || ''; descEl.dispatchEvent(new Event('rte:refresh')); }
if (track.language && window.CSD) { if (track.language && window.CSD) {
const hi = document.getElementById('csd_v_' + pfx); const hi = document.getElementById('track-lang-' + pfx);
const wrap = document.getElementById('csd_' + pfx); const wrap = document.getElementById('lsd_track_' + pfx);
if (hi) hi.value = track.language; if (hi) hi.value = track.language;
if (wrap) _editSetLangCsd(wrap, track.language); if (wrap) _editSetLangCsd(wrap, track.language);
} }
const hi = document.getElementById('csd_v_' + pfx);
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
editUpdateTrackCard(pfx); editUpdateTrackCard(pfx);
_editUpdateTrackPositions(); _editUpdateTrackPositions();
} }
function editDeleteTrack(trackId, n) { function _editRemoveTrackForms(n) {
_editDeleteTrackIds.push(trackId); const pfx = 'e' + n;
document.getElementById('edit-tc-e'+n)?.remove(); document.getElementById('edit-tc-' + pfx)?.remove();
document.getElementById('edit-tf-e'+n)?.remove(); // Form id depends on which cloner built it: shared template → um-tf-{pfx},
// legacy edit template → edit-tf-{pfx}. Clean up whichever exists.
document.getElementById('um-tf-' + pfx)?.remove();
document.getElementById('edit-tf-' + pfx)?.remove();
delete _editSlidesData[pfx];
delete (window._slidesData || {})[pfx];
const popup = document.getElementById('edit-track-popup'); const popup = document.getElementById('edit-track-popup');
if (popup.style.display !== 'none') { if (popup.style.display !== 'none') {
const vis = popup.querySelector('.um-track-form:not([style*="none"])'); const vis = popup.querySelector('.um-track-form:not([style*="none"])');
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none'; if (!vis || vis.id === 'edit-tf-' + pfx || vis.id === 'um-tf-' + pfx) popup.style.display = 'none';
} }
}
function editDeleteTrack(trackId, n) {
_editDeleteTrackIds.push(trackId);
_editRemoveTrackForms(n);
_editUpdateTrackPositions(); _editUpdateTrackPositions();
} }
function editRemoveNewTrack(n) {
_editRemoveTrackForms(n);
}
function editAddExtraTrack() { function editAddExtraTrack() {
const n = ++_editExtraCount; const n = ++_editExtraCount;
const pfx = 'e' + n; const pfx = 'e' + n;
@@ -931,23 +1044,10 @@ function editAddExtraTrack() {
'extra_track_descriptions[]', 'extra_track_descriptions[]',
'extra_track_files[]' 'extra_track_files[]'
); );
const hi = document.getElementById('csd_v_' + pfx);
if (hi) hi.addEventListener('change', () => editUpdateTrackCard(pfx));
_editUpdateTrackPositions(); _editUpdateTrackPositions();
editOpenTrackPopup(pfx); editOpenTrackPopup(pfx);
} }
function editRemoveNewTrack(n) {
document.getElementById('edit-tc-e'+n)?.remove();
document.getElementById('edit-tf-e'+n)?.remove();
delete _editSlidesData['e'+n];
const popup = document.getElementById('edit-track-popup');
if (popup.style.display !== 'none') {
const vis = popup.querySelector('.um-track-form:not([style*="none"])');
if (!vis || vis.id === 'edit-tf-e'+n) popup.style.display = 'none';
}
}
// Wire primary language → update card // Wire primary language → update card
;(function() { ;(function() {
const li = document.getElementById('edit_primary_language'); const li = document.getElementById('edit_primary_language');
@@ -1,28 +1,32 @@
<!-- Header --> <!-- Header -->
<header class="yt-header"> <header class="yt-header">
<div class="yt-header-left"> <div class="yt-header-left">
<button class="yt-menu-btn" onclick="toggleSidebar()"> <button class="yt-menu-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar menu" aria-controls="sidebar">
<i class="bi bi-list fs-5"></i> <i class="bi bi-list fs-5" aria-hidden="true"></i>
</button> </button>
<a href="{{ route('home') }}" class="yt-logo"> <a href="{{ route('home') }}" class="yt-logo">
{{-- Two responsive logos. The visible one at a given viewport
carries the app name; its sibling is display:none and gets
alt="" so a screen reader doesn't announce "Play Play". --}}
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;"> <img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height:30px;">
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height:30px;"> <img src="{{ asset('storage/images/fullLogo.png') }}" alt="" class="d-none d-md-block" style="height:30px;">
</a> </a>
</div> </div>
<div class="yt-header-center d-none d-md-flex"> <div class="yt-header-center d-none d-md-flex">
<div class="yt-search"> <div class="yt-search">
<form action="{{ route('videos.search') }}" method="GET" class="yt-search-form"> <form action="{{ route('search') }}" method="GET" class="yt-search-form" role="search">
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}"> <label for="header-search-input" class="visually-hidden">Search</label>
<button type="submit" class="yt-search-btn"><i class="bi bi-search"></i></button> <input type="search" id="header-search-input" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}" aria-label="Search videos, channels and playlists">
<button type="submit" class="yt-search-btn" aria-label="Submit search"><i class="bi bi-search" aria-hidden="true"></i></button>
</form> </form>
</div> </div>
</div> </div>
<div class="yt-header-right"> <div class="yt-header-right">
<!-- Mobile search --> <!-- Mobile search -->
<button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()"> <button type="button" class="yt-icon-btn d-md-none" onclick="toggleMobileSearch()" aria-label="Open search">
<i class="bi bi-search"></i> <i class="bi bi-search" aria-hidden="true"></i>
</button> </button>
@auth @auth
@@ -48,7 +52,7 @@
onclick="markAllRead()" style="display:none;">Mark all read</button> onclick="markAllRead()" style="display:none;">Mark all read</button>
</div> </div>
<div class="yt-notif-list" id="notifList"> <div class="yt-notif-list" id="notifList">
<div class="yt-notif-empty">Loading…</div> <div class="yt-notif-empty" id="notifInitialState">No notifications</div>
</div> </div>
</div> </div>
</div> </div>
@@ -56,9 +60,10 @@
<!-- User dropdown --> <!-- User dropdown -->
<div class="yt-user-dropdown" id="headerUserDropdown"> <div class="yt-user-dropdown" id="headerUserDropdown">
<button class="yt-icon-btn" id="headerUserBtn" <button class="yt-icon-btn" id="headerUserBtn"
onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true"> onclick="toggleHeaderDropdown()" aria-expanded="false" aria-haspopup="true"
aria-label="Account menu for {{ Auth::user()->name }}">
<img src="{{ Auth::user()->avatar_url }}" <img src="{{ Auth::user()->avatar_url }}"
class="yt-user-avatar" alt="{{ Auth::user()->name }}"> class="yt-user-avatar" alt="">
</button> </button>
<div class="yt-user-panel" id="headerUserPanel" role="menu"> <div class="yt-user-panel" id="headerUserPanel" role="menu">
@@ -16,7 +16,8 @@
<span>Trending</span> <span>Trending</span>
</a> </a>
@auth @auth
<a href="#" class="yt-sidebar-link"> <a href="{{ route('subscriptions.feed') }}"
class="yt-sidebar-link {{ request()->routeIs('subscriptions.feed') ? 'active' : '' }}">
<i class="bi bi-collection-play-fill"></i> <i class="bi bi-collection-play-fill"></i>
<span>Subscriptions</span> <span>Subscriptions</span>
</a> </a>
@@ -56,6 +57,10 @@
<i class="bi bi-hand-thumbs-up-fill"></i> <i class="bi bi-hand-thumbs-up-fill"></i>
<span>Liked Videos</span> <span>Liked Videos</span>
</a> </a>
<a href="{{ route('studio') }}" class="yt-sidebar-link {{ request()->is('studio*') ? 'active' : '' }}">
<i class="bi bi-sliders"></i>
<span>Studio</span>
</a>
</div> </div>
@endauth @endauth
@@ -417,8 +417,10 @@
{{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ── {{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ──
Six form-mode croppers write the cropped file straight onto each hidden input; Six form-mode croppers write the cropped file straight onto each hidden input;
one callback-mode cropper serves all dynamic official rows. --}} one callback-mode cropper serves all dynamic official rows. --}}
<x-image-cropper id="smc_media_participant1_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" /> {{-- Fighter photos: 3:4 portrait crop to match the VS card frame (228×304).
<x-image-cropper id="smc_media_participant2_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" /> Club logos + referee: 1:1 square. --}}
<x-image-cropper id="smc_media_participant1_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
<x-image-cropper id="smc_media_participant2_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
<x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" /> <x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" />
<x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" /> <x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" />
<x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" /> <x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" />
@@ -524,6 +526,72 @@
.sm-img-avatar .sm-img-ph { min-height: 74px; gap: 2px; font-size: 10px; } .sm-img-avatar .sm-img-ph { min-height: 74px; gap: 2px; font-size: 10px; }
.sm-img-avatar .sm-img-ph i { font-size: 16px; } .sm-img-avatar .sm-img-ph i { font-size: 16px; }
.sm-img-avatar .sm-img-preview { width: 74px; height: 74px; max-height: 74px; } .sm-img-avatar .sm-img-preview { width: 74px; height: 74px; max-height: 74px; }
/* ── Mobile: render as a bottom sheet ─────────────────────────── */
@media (max-width: 768px) {
#sportsMatchModal .modal-dialog {
margin: 0;
max-width: 100%;
width: 100%;
min-height: 100%;
display: flex;
align-items: flex-end;
}
#sportsMatchModal .sm-content {
border-radius: 20px 20px 0 0;
border-left: none;
border-right: none;
border-bottom: none;
width: 100%;
max-height: 92vh;
display: flex;
flex-direction: column;
box-shadow: 0 -12px 40px rgba(0,0,0,.6);
transform: translateY(100%);
transition: transform .3s cubic-bezier(.22,1,.36,1);
}
#sportsMatchModal.show .sm-content { transform: translateY(0); }
/* Grabber handle at the top */
#sportsMatchModal .sm-header {
position: relative;
padding-top: 18px;
}
#sportsMatchModal .sm-header::before {
content: '';
position: absolute;
top: 6px; left: 50%;
transform: translateX(-50%);
width: 40px; height: 4px;
background: #333; border-radius: 2px;
}
#sportsMatchModal .modal-body {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
flex: 1 1 auto;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
#sportsMatchModal .sm-footer {
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
gap: 6px;
flex-wrap: wrap;
}
#sportsMatchModal .sm-footer .btn {
flex: 1 1 auto;
font-size: 13px;
padding: 10px 12px;
}
/* Slide-up animation on the modal fade class as well */
#sportsMatchModal.fade .modal-dialog { transform: none; }
}
@media (max-width: 480px) {
#sportsMatchModal .sm-header h5 { font-size: 15px; }
#sportsMatchModal .sm-header-icon { width: 34px; height: 34px; font-size: 16px; }
}
</style> </style>
<script> <script>
@@ -555,12 +623,28 @@
function token() { return form.querySelector('[name="_token"]').value; } function token() { return form.querySelector('[name="_token"]').value; }
// ── Public entry point ─────────────────────────────────────────────── // ── Public entry point ───────────────────────────────────────────────
window.openSportsMatchModal = function (matchId) { // - openSportsMatchModal(matchId) → edit existing match
// - openSportsMatchModal({videoId, videoTitle}) → create against an existing video
// - openSportsMatchModal() → fresh create (upload a new video)
window.openSportsMatchModal = function (arg) {
resetForm(); resetForm();
if (matchId) loadMatch(matchId); if (typeof arg === 'number' || typeof arg === 'string') {
loadMatch(arg);
} else if (arg && typeof arg === 'object' && arg.videoId) {
attachExistingVideo(arg.videoId, arg.videoTitle || '');
}
getModal().show(); getModal().show();
}; };
function attachExistingVideo(videoId, videoTitle) {
videoIdInp.value = videoId;
document.getElementById('sm-video-create').classList.add('d-none');
document.getElementById('sm-video-attached').classList.remove('d-none');
document.getElementById('sm-video-attached-title').textContent = videoTitle || ('Video #' + videoId);
// Advanced fields are also useful when the video already exists.
document.getElementById('sm-advanced').classList.remove('d-none');
}
function resetForm() { function resetForm() {
form.reset(); form.reset();
idInput.value = ''; idInput.value = '';
@@ -795,8 +879,12 @@
clearErrors(); clearErrors();
lockButtons(true); lockButtons(true);
// Edit mode → just update the record. Create mode → upload the video first. // Skip the video-upload step when:
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst(); // - editing an existing match (matchId present), OR
// - creating a match against an already-attached video (videoId present via
// attachExistingVideo, e.g. clicking "Edit" on a match-type video that has
// no SportsMatch record yet — the video is real, we're just adding match data).
const chain = (idInput.value || videoIdInp.value) ? Promise.resolve() : uploadVideoFirst();
chain chain
.then(() => postMatch(intent)) .then(() => postMatch(intent))
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); }) .catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
@@ -338,9 +338,31 @@
</div> </div>
</div>{{-- /um-tf-t1 --}} </div>{{-- /um-tf-t1 --}}
{{-- Extra track forms appended by JS --}} {{-- Extra track forms appended by JS (cloned from the template below) --}}
<div id="um-tf-extra"></div> <div id="um-tf-extra"></div>
{{-- SHARED track form template.
This is the single source of truth for the "Add Language Track"
dialog used by BOTH the create (upload) modal AND the edit modal.
Any CSS/markup/behaviour change here automatically applies to both.
Cloners replace the placeholders at clone time:
__N__ → track key (e.g. "e1" for create, "editE1" for edit)
__LANGNAME__ → form field name for the language select
__TITLENAME__ → form field name for the title input
__DESCNAME__ → form field name for the description textarea
__AUDIONAME__ → form field name for the audio file input --}}
<template id="um-track-form-template">
@include('layouts.partials.upload-modal.track-form', [
'key' => '__N__',
'showPrimaryNote' => false,
'langName' => '__LANGNAME__',
'titleName' => '__TITLENAME__',
'descName' => '__DESCNAME__',
'audioName' => '__AUDIONAME__',
'langRequired' => true,
])
</template>
</div>{{-- /um-tp-body --}} </div>{{-- /um-tp-body --}}
<div class="um-tp-footer"> <div class="um-tp-footer">
<button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()"> <button type="button" class="action-btn action-btn-primary" onclick="closeTrackPopup()">
@@ -711,6 +733,55 @@
} }
.utc-card:hover .utc-card-arr { opacity: 1; transform: translateX(0); } .utc-card:hover .utc-card-arr { opacity: 1; transform: translateX(0); }
/* ── Mobile: render the chooser as a bottom sheet ───────────── */
@media (max-width: 768px) {
.utc-overlay {
align-items: flex-end; justify-content: stretch;
padding: 0; background: rgba(0,0,0,.55);
backdrop-filter: blur(4px);
}
.utc-box {
max-width: none; width: 100%;
border-radius: 20px 20px 0 0;
border-left: none; border-right: none; border-bottom: none;
padding: 10px 18px calc(22px + env(safe-area-inset-bottom));
transform: translateY(100%); transition: transform .28s cubic-bezier(.22,1,.36,1);
box-shadow: 0 -12px 40px rgba(0,0,0,.6);
max-height: 88vh; overflow-y: auto;
}
.utc-overlay.show .utc-box { transform: translateY(0); }
/* Grabber handle at the top */
.utc-box::before {
content: ''; display: block; width: 40px; height: 4px;
background: #333; border-radius: 2px; margin: 4px auto 14px;
}
.utc-close { top: 12px; right: 12px; }
.utc-head { margin-bottom: 16px; }
.utc-head-icon { width: 44px; height: 44px; font-size: 20px; margin-bottom: 10px; }
.utc-title { font-size: 17px; }
.utc-sub { font-size: 12px; }
/* Row of compact tiles instead of full stacked cards */
.utc-grid { grid-template-columns: repeat(3, 1fr); gap: 10px; }
.utc-card {
padding: 16px 8px 14px; border-radius: 14px; border-width: 1px;
}
.utc-card-ico {
width: 44px; height: 44px; margin-bottom: 8px;
font-size: 20px; border-radius: 12px;
}
.utc-card-title { font-size: 13px; }
.utc-card-desc { display: none; }
.utc-card-arr { display: none; }
.utc-card:active {
transform: scale(.96);
background: color-mix(in srgb, var(--accent) 10%, #111);
border-color: var(--accent);
}
}
/* ════════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════════
Unified control system — one consistent look for every field, Unified control system — one consistent look for every field,
dropdown and picker in the upload modal. Scoped to #uploadModal dropdown and picker in the upload modal. Scoped to #uploadModal
@@ -829,19 +900,28 @@ const _UTC_META = {
}; };
function openUploadChooser() { function openUploadChooser() {
// Mobile keeps the existing full-page create flow (with its own type picker) // Chooser is shown on every viewport — on mobile chooseUploadType() routes
if (window.innerWidth < 992) { // straight to the standalone create page with the picked ?type= preserved.
window.location.href = '{{ route("videos.create") }}';
return;
}
document.getElementById('upload-type-chooser').classList.add('show'); document.getElementById('upload-type-chooser').classList.add('show');
} }
// Called from the desktop chooser cards on mobile — carries the picked type
// through to the standalone create page via ?type=.
function chooseUploadTypeMobile(type) {
const t = ['generic', 'music', 'match'].includes(type) ? type : 'generic';
window.location.href = '{{ route("videos.create") }}?type=' + encodeURIComponent(t);
}
function closeUploadChooser() { function closeUploadChooser() {
document.getElementById('upload-type-chooser').classList.remove('show'); document.getElementById('upload-type-chooser').classList.remove('show');
} }
function chooseUploadType(type) { function chooseUploadType(type) {
// Mobile has no modal — route straight to the create page with the type preserved.
if (window.innerWidth < 992) {
chooseUploadTypeMobile(type);
return;
}
const meta = _UTC_META[type] || _UTC_META.generic; const meta = _UTC_META[type] || _UTC_META.generic;
closeUploadChooser(); closeUploadChooser();
openUploadModal(); openUploadModal();
@@ -1314,6 +1394,10 @@ function slidesZoneDrop(e, tid) {
function handleSlidesForTrack(tid, fileList) { function handleSlidesForTrack(tid, fileList) {
_slidesCropStart(fileList, tid); _slidesCropStart(fileList, tid);
// Reset the file input so picking the SAME filename a second time still
// fires change (browsers suppress change when FileList is identical).
const inp = document.getElementById('slides-input-' + tid);
if (inp) inp.value = '';
} }
// ── Slides crop queue: every added image is cropped before it enters the strip ── // ── Slides crop queue: every added image is cropped before it enters the strip ──
@@ -1332,8 +1416,12 @@ function _slidesCropLoadNext() {
if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; } if (!_slidesCropQueue.length) { window.closeCropperModal('slides_upload'); return; }
const f = _slidesCropQueue.shift(); const f = _slidesCropQueue.shift();
if (typeof window.tcPreload_slides_upload === 'function') { if (typeof window.tcPreload_slides_upload === 'function') {
window.tcPreload_slides_upload(f); // Open the modal FIRST so the canvas has a non-zero layout box when
// initCropper measures clientWidth/clientHeight. Preloading first
// (the previous order) caused the second batch to open with stale
// zero-sized measurements and a Save button that silently did nothing.
window.openCropperModal_slides_upload(); window.openCropperModal_slides_upload();
requestAnimationFrame(() => window.tcPreload_slides_upload(f));
} }
} }
@@ -1342,7 +1430,13 @@ function uploadSlidesCropDone(file) {
if (tid && file) { if (tid && file) {
if (!_slidesData[tid]) _slidesData[tid] = []; if (!_slidesData[tid]) _slidesData[tid] = [];
if (_slidesData[tid].length < 10) { if (_slidesData[tid].length < 10) {
_slidesData[tid].push(file); // Cache a per-file object URL once so renderSlidesStrip can reuse it
// on every reorder without spawning a fresh FileReader per slide.
// The old approach (new FileReader per file per render) ran dozens
// of readers after a reorder and silently dropped some under memory
// pressure, leaving the strip with broken thumbnails.
const url = URL.createObjectURL(file);
_slidesData[tid].push({ file, url });
renderSlidesStrip(tid); renderSlidesStrip(tid);
} }
} }
@@ -1364,7 +1458,10 @@ function renderSlidesStrip(tid) {
if (zone) zone.style.borderColor = ''; if (zone) zone.style.borderColor = '';
return; return;
} }
files.forEach((f, index) => { files.forEach((item, index) => {
// Entries are {file, url}; cached url is stable across reorders.
const url = item.url || (item.file ? URL.createObjectURL(item.file) : '');
if (item.file && !item.url) item.url = url;
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;'; wrap.style.cssText = 'position:relative;flex-shrink:0;cursor:grab;';
wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid; wrap.draggable = true; wrap.dataset.index = index; wrap.dataset.trackId = tid;
@@ -1383,10 +1480,15 @@ function renderSlidesStrip(tid) {
const rb = document.createElement('button'); const rb = document.createElement('button');
rb.type='button'; rb.style.cssText='position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:#e61e1e;border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;'; rb.type='button'; rb.style.cssText='position:absolute;top:-4px;right:-4px;width:16px;height:16px;background:#e61e1e;border:none;border-radius:50%;color:#fff;font-size:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;z-index:1;';
rb.innerHTML='<i class="bi bi-x"></i>'; rb.innerHTML='<i class="bi bi-x"></i>';
rb.addEventListener('click', function(ev) { ev.stopPropagation(); _slidesData[tid].splice(index,1); renderSlidesStrip(tid); }); rb.addEventListener('click', function(ev) {
ev.stopPropagation();
const removed = _slidesData[tid].splice(index,1)[0];
if (removed && removed.url) URL.revokeObjectURL(removed.url);
renderSlidesStrip(tid);
});
const img = document.createElement('img'); const img = document.createElement('img');
img.style.cssText='width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;'; img.alt=''; img.style.cssText='width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid rgba(255,255,255,.12);display:block;'; img.alt='';
const reader = new FileReader(); reader.onload = ev2 => { img.src=ev2.target.result; }; reader.readAsDataURL(f); img.src = url;
wrap.appendChild(img); wrap.appendChild(rb); strip.appendChild(wrap); wrap.appendChild(img); wrap.appendChild(rb); strip.appendChild(wrap);
}); });
if (cnt) cnt.textContent = files.length===1 ? '1 image — static cover' : files.length+' images — will crossfade during playback'; if (cnt) cnt.textContent = files.length===1 ? '1 image — static cover' : files.length+' images — will crossfade during playback';
@@ -1397,6 +1499,7 @@ function renderSlidesStrip(tid) {
function clearSlidesForTrack(e, tid) { function clearSlidesForTrack(e, tid) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
(_slidesData[tid] || []).forEach(s => { if (s && s.url) URL.revokeObjectURL(s.url); });
_slidesData[tid] = []; _slidesData[tid] = [];
const input = document.getElementById('slides-input-' + tid); const input = document.getElementById('slides-input-' + tid);
if (input) { const fresh=input.cloneNode(false); fresh.addEventListener('change',function(){handleSlidesForTrack(tid,this.files);}); input.parentNode.replaceChild(fresh,input); } if (input) { const fresh=input.cloneNode(false); fresh.addEventListener('change',function(){handleSlidesForTrack(tid,this.files);}); input.parentNode.replaceChild(fresh,input); }
@@ -1436,10 +1539,8 @@ function updateTrackCard(trackId) {
langCode = li ? li.value : ''; langCode = li ? li.value : '';
title = ti ? ti.value.trim() : ''; title = ti ? ti.value.trim() : '';
} else { } else {
const n = trackId.replace('e', ''); const li = document.getElementById('track-lang-' + trackId);
const uid = 'ltme_' + n; const ti = document.getElementById('track-title-' + trackId);
const li = document.getElementById('csd_v_' + uid);
const ti = document.getElementById('um-tf-title-' + trackId);
langCode = li ? li.value : ''; langCode = li ? li.value : '';
title = ti ? ti.value.trim() : ''; title = ti ? ti.value.trim() : '';
} }
@@ -1454,49 +1555,111 @@ function updateTrackCard(trackId) {
if (card) card.style.display = hasContent ? '' : 'none'; if (card) card.style.display = hasContent ? '' : 'none';
} }
const flagEl = document.getElementById('um-tc-flag-' + trackId); // Update WHICHEVER card id convention exists — the create modal uses
const titleEl = document.getElementById('um-tc-title-' + trackId); // `um-tc-*-${id}` and the edit modal uses `edit-tc-*-${id}`. Writing to
if (flagEl) flagEl.className = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag'; // both keeps this helper a single source of truth for any shared template.
if (titleEl) { const flagEls = [
if (lang && title) titleEl.textContent = title + ' — ' + lang.label; document.getElementById('um-tc-flag-' + trackId),
else if (lang) titleEl.textContent = lang.label; document.getElementById('edit-tc-flag-' + trackId),
else if (title) titleEl.textContent = title; ];
else titleEl.textContent = ''; const titleEls = [
} document.getElementById('um-tc-title-' + trackId),
document.getElementById('edit-tc-title-' + trackId),
];
const flagClass = 'fi fi-' + (lang ? lang.flag : 'xx') + ' um-tc-flag';
let display;
if (lang && title) display = title + ' — ' + lang.label;
else if (lang) display = lang.label;
else if (title) display = title;
else display = '';
flagEls.forEach(el => { if (el) el.className = flagClass; });
titleEls.forEach(el => { if (el) el.textContent = display; });
} }
// ── Extra track management ──────────────────────────────────── // ── Extra track management ────────────────────────────────────
const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage()); const LANG_OPTIONS_MODAL = @json(\App\Data\Languages::forLanguage());
function _ltBuildOptsHtml() { // Audio picker handler shared by every track form (primary + extras) cloned
return LANG_OPTIONS_MODAL.map(o => // from the <template id="um-track-form-template"> blade partial.
`<li class="csd-opt" role="option" tabindex="-1" data-v="${o.value}" data-s="${o.search}" aria-selected="false"> function handleTrackAudio(key, input) {
<span class="csd-opt-ico"><span class="fi fi-${o.flag} lsd-flag"></span></span> const file = input.files && input.files[0];
<span class="csd-opt-main">${o.label}</span> const fnameEl = document.getElementById('track-audio-fname-' + key);
<span class="csd-opt-sub">${o.native}</span> const box = document.getElementById('track-audio-box-' + key);
</li>` if (fnameEl) fnameEl.textContent = file ? file.name : 'Click to choose file…';
).join(''); if (box) box.style.borderColor = file ? '#22c55e' : '';
// Auto-fill the track title from the filename (same behavior as primary track)
if (file) {
const titleEl = document.getElementById('track-title-' + key);
if (titleEl && !titleEl.value) {
titleEl.value = file.name.replace(/\.[^/.]+$/, '').replace(/[-_]/g, ' ');
updateTrackCard(key);
}
}
} }
// Shared cloner for #um-track-form-template. Both the create modal and the
// edit modal call this so there is ONE source for the track-form markup.
// Returns a DocumentFragment with every placeholder (__N__, __LANGNAME__, …)
// substituted. The caller is responsible for appending it + initialising the
// language picker + RTE on the new nodes.
window.cloneTrackFormTemplate = window.cloneTrackFormTemplate || function (opts) {
const tpl = document.getElementById('um-track-form-template');
if (!tpl) { console.error('[track-form] missing #um-track-form-template'); return null; }
const frag = tpl.content.cloneNode(true);
const subs = {
'__N__': opts.key,
'__LANGNAME__': opts.langName,
'__TITLENAME__': opts.titleName,
'__DESCNAME__': opts.descName,
'__AUDIONAME__': opts.audioName,
};
const walker = document.createTreeWalker(frag, NodeFilter.SHOW_ELEMENT, null);
const nodes = [];
for (let el = walker.nextNode(); el; el = walker.nextNode()) nodes.push(el);
nodes.forEach(node => {
for (const attr of Array.from(node.attributes)) {
let v = attr.value;
for (const k in subs) if (v.indexOf(k) !== -1) v = v.split(k).join(subs[k]);
if (v !== attr.value) node.setAttribute(attr.name, v);
}
});
return frag;
};
// Shared post-clone wiring: CSD language picker + RTE on fresh markup, plus
// change listeners that keep whichever card is on-screen in sync with the form.
window.initTrackFormNode = window.initTrackFormNode || function (key, onChange) {
if (window.CSD) new CSD('lsd_track_' + key);
const rteWrap = document.querySelector('#um-tf-' + key + ' .rte-wrap:not([data-rte-ready])');
if (rteWrap && window.RTE && typeof window.RTE.init === 'function') window.RTE.init(rteWrap);
const langInput = document.getElementById('track-lang-' + key);
const titleInput = document.getElementById('track-title-' + key);
const cb = onChange || (() => updateTrackCard(key));
if (langInput) langInput.addEventListener('change', () => cb(key));
if (titleInput) titleInput.addEventListener('input', () => cb(key));
};
function addExtraTrackModal() { function addExtraTrackModal() {
const n = ++_umExtraCount; const n = ++_umExtraCount;
const trackNum = n + 1; const trackNum = n + 1;
const uid = 'ltme_' + n; const key = 'e' + n;
// Track card (shown in the list on the main modal body)
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'um-track-card'; card.className = 'um-track-card';
card.id = 'um-tc-e' + n; card.id = 'um-tc-' + key;
card.innerHTML = ` card.innerHTML = `
<div class="um-tc-body"> <div class="um-tc-body">
<div class="um-tc-left"> <div class="um-tc-left">
<div class="um-tc-num">${trackNum}</div> <div class="um-tc-num">${trackNum}</div>
<span class="fi fi-xx um-tc-flag" id="um-tc-flag-e${n}"></span> <span class="fi fi-xx um-tc-flag" id="um-tc-flag-${key}"></span>
<div class="um-tc-info"> <div class="um-tc-info">
<span class="um-tc-title" id="um-tc-title-e${n}">Track — select language</span> <span class="um-tc-title" id="um-tc-title-${key}">Track — select language</span>
</div> </div>
</div> </div>
<div class="um-tc-right"> <div class="um-tc-right">
<button type="button" class="action-btn" onclick="openTrackPopup('e${n}')"> <button type="button" class="action-btn" onclick="openTrackPopup('${key}')">
<i class="bi bi-pencil"></i> <span>Edit</span> <i class="bi bi-pencil"></i> <span>Edit</span>
</button> </button>
<button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track"> <button type="button" class="action-btn action-btn-danger icon-only" onclick="removeExtraTrackModal(${n})" title="Remove track">
@@ -1506,100 +1669,24 @@ function addExtraTrackModal() {
</div>`; </div>`;
document.getElementById('um-tc-extra').appendChild(card); document.getElementById('um-tc-extra').appendChild(card);
const formEl = document.createElement('div'); // Clone the SHARED track-form template and substitute placeholders. The same
formEl.className = 'um-track-form'; // template is cloned by the edit modal (editAddExtraTrack / existing-track
formEl.id = 'um-tf-e' + n; // loader), so a change here automatically shows up in both flows.
formEl.style.display = 'none'; const frag = window.cloneTrackFormTemplate({
formEl.innerHTML = ` key: key,
<div class="um-tf-row2" style="margin-bottom:14px;"> langName: 'extra_track_languages[]',
<div> titleName: 'extra_track_titles[]',
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">Language <span class="um-req">*</span></label> descName: 'extra_track_descriptions[]',
<div class="csd-wrap" id="${uid}"> audioName: 'extra_track_files[]',
<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> if (!frag) return;
<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">${_ltBuildOptsHtml()}</ul>
<p class="csd-empty" hidden>No results</p>
</div>
<input type="hidden" name="extra_track_languages[]" id="csd_v_${uid}">
</div>
</div>
<div>
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Title <span class="um-lbl-hint">optional</span>
</label>
<input type="text" name="extra_track_titles[]" id="um-tf-title-e${n}" class="um-input"
style="font-size:13px;padding:9px 12px;" placeholder="Title in this language…">
</div>
</div>
<div style="margin-bottom:14px;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Description <span class="um-lbl-hint">optional</span>
</label>
<div class="rte-wrap" data-min-height="100px">
<textarea class="rte-source" name="extra_track_descriptions[]" id="um-tf-desc-e${n}"
placeholder="Description in this language…"></textarea>
</div>
</div>
<div style="margin-bottom:14px;">
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
<i class="bi bi-music-note-beamed"></i> Audio File
</label>
<label class="lta-file-label"
onmouseenter="this.style.borderColor='#e61e1e'"
onmouseleave="this.style.borderColor='#222'">
<i class="bi bi-music-note-beamed" style="color:#e61e1e;font-size:15px;flex-shrink:0;"></i>
<span class="lta-file-name" id="um-tf-fname-e${n}">Choose audio file…</span>
<input type="file" name="extra_track_files[]"
accept="audio/*,.mp3,.m4a,.aac,.flac,.wav" style="display:none;"
onchange="document.getElementById('um-tf-fname-e${n}').textContent=this.files[0]?.name||'Choose audio file…'">
</label>
</div>
<div>
<label class="um-lbl" style="font-size:10px;margin-bottom:6px;">
Cover Slides <span class="um-lbl-hint">optional · drag to reorder</span>
</label>
<div id="slides-zone-e${n}" class="um-slides-zone"
onclick="slidesZoneClick(event,'e${n}')"
ondragover="slidesZoneDragover(event,'e${n}')"
ondragleave="slidesZoneDragleave(event,'e${n}')"
ondrop="slidesZoneDrop(event,'e${n}')">
<input type="file" accept="image/*" multiple style="display:none" id="slides-input-e${n}"
onchange="handleSlidesForTrack('e${n}',this.files)">
<div class="um-slides-ph" id="slides-ph-e${n}">
<i class="bi bi-images"></i>
<span>Click or drag to add cover images</span>
</div>
<div id="slides-preview-e${n}" style="display:none;padding:10px 12px;width:100%;box-sizing:border-box;">
<div id="slides-strip-e${n}" class="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="slides-count-e${n}" style="font-size:12px;color:var(--text-secondary);"></span>
<button type="button" onclick="clearSlidesForTrack(event,'e${n}')"
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
</button>
</div>
</div>
</div>
</div>`;
document.getElementById('um-tf-extra').appendChild(formEl); const host = document.getElementById('um-tf-extra');
_slidesData['e' + n] = []; host.appendChild(frag);
_slidesData[key] = [];
if (window.CSD) new CSD(uid); window.initTrackFormNode(key);
openTrackPopup(key);
const hiddenInput = document.getElementById('csd_v_' + uid);
if (hiddenInput) {
hiddenInput.addEventListener('change', function() { updateTrackCard('e' + n); });
}
openTrackPopup('e' + n);
} }
function removeExtraTrackModal(n) { function removeExtraTrackModal(n) {
@@ -1628,13 +1715,15 @@ function removeExtraTrackModal(n) {
// ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─ // ── Chunked upload helper (bypasses Cloudflare's 100 MB per-request cap) ─
async function _chunkedUploadVideo(file, onProgress) { async function _chunkedUploadVideo(file, onProgress) {
const CHUNK = 8 * 1024 * 1024; // 8 MB — well under CF 100 MB limit const CHUNK = 2 * 1024 * 1024; // 2 MB per chunk
const CONCURRENCY = 3; // parallel in-flight chunks
const total = Math.max(1, Math.ceil(file.size / CHUNK)); const total = Math.max(1, Math.ceil(file.size / CHUNK));
const uploadId = (window.crypto && crypto.randomUUID) const uploadId = (window.crypto && crypto.randomUUID)
? crypto.randomUUID() ? crypto.randomUUID()
: (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)); : (Date.now() + '-' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2));
for (let i = 0; i < total; i++) { let uploadedBytes = 0;
const uploadOne = async (i) => {
const start = i * CHUNK; const start = i * CHUNK;
const end = Math.min(start + CHUNK, file.size); const end = Math.min(start + CHUNK, file.size);
const blob = file.slice(start, end); const blob = file.slice(start, end);
@@ -1642,6 +1731,7 @@ async function _chunkedUploadVideo(file, onProgress) {
const fd = new FormData(); const fd = new FormData();
fd.append('upload_id', uploadId); fd.append('upload_id', uploadId);
fd.append('chunk_index', i); fd.append('chunk_index', i);
fd.append('chunk_offset', start);
fd.append('total_chunks', total); fd.append('total_chunks', total);
fd.append('filename', file.name); fd.append('filename', file.name);
fd.append('chunk', blob, 'chunk'); fd.append('chunk', blob, 'chunk');
@@ -1657,8 +1747,20 @@ async function _chunkedUploadVideo(file, onProgress) {
try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {} try { const j = await res.json(); if (j && j.message) msg = j.message; } catch (_) {}
throw new Error(msg); throw new Error(msg);
} }
if (onProgress) onProgress({ loaded: end, total: file.size }); uploadedBytes += (end - start);
if (onProgress) onProgress({ loaded: Math.min(uploadedBytes, file.size), total: file.size });
};
let next = 0;
const workers = Array.from({ length: Math.min(CONCURRENCY, total) }, async () => {
while (true) {
const i = next++;
if (i >= total) return;
await uploadOne(i);
} }
});
await Promise.all(workers);
return { uploadId: uploadId, filename: file.name }; return { uploadId: uploadId, filename: file.name };
} }
@@ -1695,8 +1797,9 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
} }
const formData = new FormData(this); const formData = new FormData(this);
const _slideFile = s => (s && s.file) ? s.file : s; // tolerate legacy raw-File entries
if (_isMusicMode) { if (_isMusicMode) {
(_slidesData['t1'] || []).forEach(f => formData.append('slides[]', f)); (_slidesData['t1'] || []).forEach(s => formData.append('slides[]', _slideFile(s)));
// Walk extra-track form sections in DOM order so the slide index lines up // Walk extra-track form sections in DOM order so the slide index lines up
// with the positional `extra_track_files[]` the form already submits. // with the positional `extra_track_files[]` the form already submits.
var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form'); var extraForms = document.querySelectorAll('#um-tf-extra .um-track-form');
@@ -1705,8 +1808,8 @@ document.getElementById('upload-form-modal').addEventListener('submit', function
if (!m) return; if (!m) return;
var tid = m[1]; var tid = m[1];
var files = _slidesData[tid] || []; var files = _slidesData[tid] || [];
files.forEach(function (f) { files.forEach(function (s) {
formData.append('extra_track_slides[' + domIdx + '][]', f); formData.append('extra_track_slides[' + domIdx + '][]', _slideFile(s));
}); });
}); });
} }
+446
View File
@@ -0,0 +1,446 @@
@extends('layouts.app')
@section('title', 'Go Live — '.$stream->title)
@section('extra_styles')
<style>
.lv-wrap { max-width: 1100px; margin: 0 auto; padding: 16px; }
/* The preview owns the screen on a phone. Everything else floats on it. */
.lv-stage {
position: relative; background: #000; border-radius: 16px; overflow: hidden;
aspect-ratio: 16 / 9; box-shadow: 0 10px 40px rgba(0,0,0,.45);
}
.lv-stage video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
/* Mirror only the selfie camera — a mirrored rear camera looks broken. */
.lv-stage video.mirrored { transform: scaleX(-1); }
.lv-badge {
position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center; gap: 7px;
padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em;
background: rgba(0,0,0,.6); color: #fff; backdrop-filter: blur(8px);
}
.lv-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #6b7280; }
.lv-badge.is-live { background: rgba(239,68,68,.92); }
.lv-badge.is-live .dot { background: #fff; animation: lvPulse 1.4s ease-in-out infinite; }
@keyframes lvPulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.75); } }
.lv-stats {
position: absolute; top: 12px; right: 12px; display: flex; flex-direction: column; gap: 4px;
padding: 8px 12px; border-radius: 12px; background: rgba(0,0,0,.55); backdrop-filter: blur(8px);
color: #e5e7eb; font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; min-width: 120px;
}
.lv-stats b { color: #fff; font-weight: 600; }
.lv-stats .warn { color: #fbbf24; }
.lv-stats .bad { color: #f87171; }
.lv-controls {
position: absolute; left: 0; right: 0; bottom: 0; padding: 16px;
display: flex; align-items: center; justify-content: center; gap: 12px;
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
}
.lv-btn {
width: 52px; height: 52px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
background: rgba(255,255,255,.12); color: #fff; font-size: 20px; cursor: pointer;
display: grid; place-items: center; backdrop-filter: blur(8px); transition: transform .12s ease, background .15s ease;
}
.lv-btn:hover { background: rgba(255,255,255,.22); }
.lv-btn:active { transform: scale(.92); }
.lv-btn.off { background: rgba(239,68,68,.85); border-color: transparent; }
.lv-btn:disabled { opacity: .4; cursor: not-allowed; }
.lv-golive {
min-width: 150px; height: 52px; padding: 0 26px; border-radius: 999px; border: 0;
background: #ef4444; color: #fff; font-weight: 700; font-size: 15px; cursor: pointer;
display: inline-flex; align-items: center; gap: 9px; transition: transform .12s ease, background .15s ease;
}
.lv-golive:hover { background: #dc2626; }
.lv-golive:active { transform: scale(.97); }
.lv-golive:disabled { opacity: .55; cursor: not-allowed; }
.lv-golive.is-live { background: #111827; border: 1px solid rgba(255,255,255,.25); }
.lv-meta { margin-top: 18px; display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 860px) { .lv-meta { grid-template-columns: 1.4fr 1fr; } }
.lv-card { background: var(--yt-bg-secondary, #181818); border: 1px solid rgba(255,255,255,.08); border-radius: 14px; padding: 16px; }
.lv-card h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; opacity: .6; margin: 0 0 10px; }
.lv-share { display: flex; gap: 8px; }
.lv-share input {
flex: 1; min-width: 0; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12);
border-radius: 10px; padding: 10px 12px; color: inherit; font-size: 13px;
}
.lv-note { font-size: 12px; opacity: .6; line-height: 1.6; margin-top: 10px; }
.lv-msg { margin-top: 14px; padding: 12px 14px; border-radius: 12px; font-size: 14px; display: none; }
.lv-msg.err { display: block; background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.35); color: #fca5a5; }
.lv-msg.ok { display: block; background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.35); color: #86efac; }
</style>
@endsection
@section('content')
<div class="lv-wrap">
<div class="lv-stage">
<video id="lvPreview" autoplay playsinline muted></video>
<div class="lv-badge" id="lvBadge"><span class="dot"></span><span id="lvBadgeText">READY</span></div>
<div class="lv-stats" id="lvStats" style="display:none;">
<div><b id="lvRes">—</b> · <b id="lvFps">—</b> fps</div>
<div><b id="lvBitrate">—</b></div>
<div id="lvHealth">—</div>
<div><b id="lvTimer">00:00</b></div>
</div>
<div class="lv-controls">
<button class="lv-btn" id="lvMic" title="Mute microphone"><i class="bi bi-mic-fill"></i></button>
<button class="lv-golive" id="lvGoLive"><i class="bi bi-broadcast"></i><span id="lvGoLiveText">Go Live</span></button>
<button class="lv-btn" id="lvFlip" title="Switch camera"><i class="bi bi-arrow-repeat"></i></button>
</div>
</div>
<div class="lv-msg" id="lvMsg"></div>
<div class="lv-meta">
<div class="lv-card">
<h3>{{ $stream->title }}</h3>
@if ($stream->description)
<p style="margin:0 0 10px;font-size:14px;opacity:.8;">{{ $stream->description }}</p>
@endif
<div class="lv-share">
<input type="text" id="lvWatchUrl" value="{{ $watchUrl }}" readonly onclick="this.select()">
<button class="action-btn" type="button" id="lvCopy"><i class="bi bi-clipboard"></i> <span>Copy</span></button>
</div>
<p class="lv-note">
Anyone with this link can watch while you are live.
{{ $stream->visibility === 'public' ? 'This stream is also listed on the Live page.' : 'This stream is unlisted — only people with the link can find it.' }}
</p>
</div>
<div class="lv-card">
<h3>Quality</h3>
<p class="lv-note" style="margin-top:0;">
Targeting {{ config('live.video_width') }}×{{ config('live.video_height') }} at {{ config('live.video_fps') }} fps.
If your upload speed drops, the picture softens instead of freezing — that is the connection adapting, not a fault.
Wi-Fi or 5G will hold 1080p most reliably.
</p>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
(function () {
'use strict';
var CFG = {
whipUrl: @json($whipUrl),
tokenUrl: @json(route('live.token', $stream)),
stopUrl: @json(route('live.stop', $stream)),
csrf: @json(csrf_token()),
iceServers: @json($iceServers),
width: {{ (int) config('live.video_width') }},
height: {{ (int) config('live.video_height') }},
fps: {{ (int) config('live.video_fps') }},
maxBitrate: {{ (int) config('live.max_bitrate') }}
};
var el = function (id) { return document.getElementById(id); };
var preview = el('lvPreview'), badge = el('lvBadge'), badgeText = el('lvBadgeText');
var goLive = el('lvGoLive'), goLiveText = el('lvGoLiveText');
var msg = el('lvMsg'), stats = el('lvStats');
var stream = null, pc = null, statsTimer = null, clockTimer = null;
var facing = 'environment', live = false, startedAt = 0;
function say(text, kind) {
msg.textContent = text;
msg.className = 'lv-msg ' + (kind || 'err');
}
function clearSay() { msg.className = 'lv-msg'; msg.textContent = ''; }
/* ---------------- Camera ---------------- */
// Ask for 1080p as an IDEAL, never `exact`: a phone whose camera cannot do
// it should hand back its best effort, not refuse to open at all.
function constraints() {
return {
video: {
width: { ideal: CFG.width },
height: { ideal: CFG.height },
frameRate: { ideal: CFG.fps },
facingMode: { ideal: facing }
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
};
}
async function openCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
say('This browser cannot open a camera. On iPhone use Safari; on Android use Chrome.');
goLive.disabled = true;
return false;
}
// getUserMedia only exists in a secure context. Saying so plainly beats
// a bare "Permission denied" the user cannot act on.
if (!window.isSecureContext) {
say('The camera needs a secure (HTTPS) connection. Open this page over https.');
goLive.disabled = true;
return false;
}
try {
if (stream) { stream.getTracks().forEach(function (t) { t.stop(); }); }
stream = await navigator.mediaDevices.getUserMedia(constraints());
} catch (e) {
if (e && (e.name === 'NotAllowedError' || e.name === 'SecurityError')) {
say('Camera and microphone access was blocked. Allow it in your browser settings, then reload.');
} else if (e && e.name === 'NotFoundError') {
say('No camera was found on this device.');
} else if (e && e.name === 'NotReadableError') {
say('The camera is already in use by another app. Close it and try again.');
} else {
say('Could not start the camera: ' + (e && e.message ? e.message : e));
}
goLive.disabled = true;
return false;
}
preview.srcObject = stream;
preview.classList.toggle('mirrored', facing === 'user');
goLive.disabled = false;
clearSay();
return true;
}
/* ---------------- Publish (WHIP) ---------------- */
async function startBroadcast() {
goLive.disabled = true;
goLiveText.textContent = 'Starting…';
clearSay();
try {
// The publish credential is minted now, not at page load, so its
// short life starts when the user actually goes live.
var tokenRes = await fetch(CFG.tokenUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CFG.csrf, 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!tokenRes.ok) { throw new Error('Could not get permission to publish (' + tokenRes.status + ').'); }
var auth = await tokenRes.json();
pc = new RTCPeerConnection({
iceServers: auth.ice_servers && auth.ice_servers.length ? auth.ice_servers : CFG.iceServers,
bundlePolicy: 'max-bundle'
});
pc.addEventListener('connectionstatechange', function () {
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
say('The connection to the server dropped. Check your signal and go live again.');
stopBroadcast(true);
}
});
stream.getTracks().forEach(function (track) {
// maintain-resolution keeps 1920x1080 and sheds frame rate when
// the uplink tightens. For a match or a performance, a sharp
// 20fps reads better than a soft, blocky 30.
if (track.kind === 'video') { track.contentHint = 'detail'; }
pc.addTrack(track, stream);
});
var videoSender = pc.getSenders().find(function (s) { return s.track && s.track.kind === 'video'; });
if (videoSender) {
var params = videoSender.getParameters();
params.encodings = params.encodings && params.encodings.length ? params.encodings : [{}];
params.encodings[0].maxBitrate = CFG.maxBitrate;
params.encodings[0].maxFramerate = CFG.fps;
params.degradationPreference = 'maintain-resolution';
try { await videoSender.setParameters(params); } catch (e) { /* older browsers ignore this */ }
}
var offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await iceGatheringComplete(pc);
var res = await fetch(auth.whip_url || CFG.whipUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
// Basic auth keeps the credential out of the URL, and so
// out of proxy and access logs.
'Authorization': 'Basic ' + btoa('publisher:' + auth.token)
},
body: pc.localDescription.sdp
});
if (res.status === 401 || res.status === 403) { throw new Error('The server refused the broadcast. Reload the page and try again.'); }
if (!res.ok) { throw new Error('The server rejected the stream (' + res.status + ').'); }
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
live = true;
startedAt = Date.now();
badge.classList.add('is-live');
badgeText.textContent = 'LIVE';
goLive.classList.add('is-live');
goLiveText.textContent = 'End Stream';
goLive.disabled = false;
stats.style.display = 'flex';
say('You are live. Share the link below.', 'ok');
statsTimer = setInterval(sampleStats, 1000);
clockTimer = setInterval(tickClock, 1000);
} catch (e) {
say(e && e.message ? e.message : String(e));
await stopBroadcast(true);
}
}
// Wait for ICE to finish rather than trickling: WHIP is a single
// offer/answer exchange, so the offer must already carry the candidates.
function iceGatheringComplete(pc) {
if (pc.iceGatheringState === 'complete') { return Promise.resolve(); }
return new Promise(function (resolve) {
var done = false;
function finish() { if (!done) { done = true; resolve(); } }
pc.addEventListener('icegatheringstatechange', function () {
if (pc.iceGatheringState === 'complete') { finish(); }
});
// Never hang on a candidate that will not arrive.
setTimeout(finish, 3000);
});
}
async function stopBroadcast(silent) {
clearInterval(statsTimer); statsTimer = null;
clearInterval(clockTimer); clockTimer = null;
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
if (live) {
try {
await fetch(CFG.stopUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': CFG.csrf, 'Accept': 'application/json' },
credentials: 'same-origin'
});
} catch (e) { /* the server also reaps on unpublish */ }
}
live = false;
badge.classList.remove('is-live');
badgeText.textContent = 'READY';
goLive.classList.remove('is-live');
goLiveText.textContent = 'Go Live';
goLive.disabled = false;
stats.style.display = 'none';
if (!silent) { say('Stream ended.', 'ok'); }
}
/* ---------------- Telemetry ---------------- */
var lastBytes = 0, lastAt = 0;
async function sampleStats() {
if (!pc) { return; }
var report = await pc.getStats();
var kbps = null, fps = null, w = null, h = null, lossPct = null;
report.forEach(function (s) {
if (s.type === 'outbound-rtp' && s.kind === 'video') {
var now = s.timestamp;
if (lastAt && s.bytesSent > lastBytes) {
kbps = Math.round(((s.bytesSent - lastBytes) * 8) / ((now - lastAt) / 1000) / 1000);
}
lastBytes = s.bytesSent; lastAt = now;
if (s.framesPerSecond) { fps = Math.round(s.framesPerSecond); }
if (s.frameWidth) { w = s.frameWidth; h = s.frameHeight; }
}
if (s.type === 'remote-inbound-rtp' && s.kind === 'video' && s.packetsLost != null && s.packetsReceived) {
lossPct = (s.packetsLost / (s.packetsLost + s.packetsReceived)) * 100;
}
});
if (w && h) { el('lvRes').textContent = w + '×' + h; }
if (fps !== null) { el('lvFps').textContent = fps; }
if (kbps !== null) { el('lvBitrate').textContent = (kbps / 1000).toFixed(1) + ' Mbps'; }
// Report the connection honestly — a broadcaster who can see the uplink
// struggling can move rather than wonder why the picture went soft.
var health = el('lvHealth');
if (lossPct === null) { health.textContent = 'connection ok'; health.className = ''; }
else if (lossPct < 2) { health.textContent = 'connection good'; health.className = ''; }
else if (lossPct < 8) { health.textContent = 'connection weak'; health.className = 'warn'; }
else { health.textContent = 'connection poor'; health.className = 'bad'; }
}
function tickClock() {
var s = Math.floor((Date.now() - startedAt) / 1000);
var m = Math.floor(s / 60), sec = s % 60;
el('lvTimer').textContent = (m < 10 ? '0' : '') + m + ':' + (sec < 10 ? '0' : '') + sec;
}
/* ---------------- Controls ---------------- */
goLive.addEventListener('click', function () {
if (live) { stopBroadcast(false); } else { startBroadcast(); }
});
el('lvFlip').addEventListener('click', async function () {
facing = (facing === 'environment') ? 'user' : 'environment';
var wasLive = live;
var ok = await openCamera();
if (!ok) { return; }
// Swap the track inside the existing connection so flipping the camera
// mid-broadcast does not drop the stream.
if (wasLive && pc) {
var newTrack = stream.getVideoTracks()[0];
var sender = pc.getSenders().find(function (s) { return s.track && s.track.kind === 'video'; });
if (sender && newTrack) { try { await sender.replaceTrack(newTrack); } catch (e) {} }
}
});
el('lvMic').addEventListener('click', function () {
if (!stream) { return; }
var track = stream.getAudioTracks()[0];
if (!track) { return; }
track.enabled = !track.enabled;
this.classList.toggle('off', !track.enabled);
this.innerHTML = track.enabled ? '<i class="bi bi-mic-fill"></i>' : '<i class="bi bi-mic-mute-fill"></i>';
});
el('lvCopy').addEventListener('click', function () {
var input = el('lvWatchUrl');
input.select();
navigator.clipboard && navigator.clipboard.writeText(input.value);
var span = this.querySelector('span');
var original = span.textContent;
span.textContent = 'Copied';
setTimeout(function () { span.textContent = original; }, 1400);
});
// A broadcaster who closes the tab must not leave a stream stuck live.
window.addEventListener('pagehide', function () {
if (live && navigator.sendBeacon) {
var body = new FormData();
body.append('_token', CFG.csrf);
navigator.sendBeacon(CFG.stopUrl, body);
}
});
openCamera();
})();
</script>
@endsection
+89
View File
@@ -0,0 +1,89 @@
@extends('layouts.app')
@section('title', 'Go Live')
@section('extra_styles')
<style>
.lc-wrap { max-width: 560px; margin: 0 auto; padding: 24px 16px 60px; }
.lc-wrap h1 { font-size: 22px; font-weight: 700; margin: 0 0 6px; display: flex; align-items: center; gap: 10px; }
.lc-sub { font-size: 13px; opacity: .6; margin: 0 0 26px; line-height: 1.6; }
.lc-field { margin-bottom: 18px; }
.lc-field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 7px; }
.lc-field input[type=text], .lc-field textarea {
width: 100%; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.14);
border-radius: 11px; padding: 11px 13px; color: inherit; font-size: 14px; font-family: inherit;
}
.lc-field input:focus, .lc-field textarea:focus { outline: none; border-color: #ef4444; }
.lc-field textarea { min-height: 88px; resize: vertical; }
.lc-err { font-size: 12px; color: #f87171; margin-top: 6px; }
.lc-vis { display: grid; gap: 10px; }
.lc-vis label {
display: flex; gap: 12px; align-items: flex-start; padding: 13px 15px; cursor: pointer;
border: 1px solid rgba(255,255,255,.14); border-radius: 12px; transition: border-color .15s ease, background .15s ease;
}
.lc-vis label:hover { background: rgba(255,255,255,.04); }
.lc-vis input { margin-top: 3px; accent-color: #ef4444; }
.lc-vis .t { font-size: 14px; font-weight: 600; }
.lc-vis .d { font-size: 12px; opacity: .6; margin-top: 2px; line-height: 1.5; }
.lc-vis input:checked + div .t { color: #ef4444; }
</style>
@endsection
@section('content')
<div class="lc-wrap">
<h1><i class="bi bi-broadcast" style="color:#ef4444;"></i> Go Live</h1>
<p class="lc-sub">Stream straight from your phone's camera. You'll get a link to share the moment you start.</p>
<form method="POST" action="{{ route('live.store') }}">
@csrf
<div class="lc-field">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="{{ old('title') }}" maxlength="120" required
placeholder="What are you streaming?" autofocus>
@error('title')<div class="lc-err">{{ $message }}</div>@enderror
</div>
<div class="lc-field">
<label for="description">Description <span style="opacity:.5;font-weight:400;">(optional)</span></label>
<textarea id="description" name="description" maxlength="2000" placeholder="Tell viewers what to expect">{{ old('description') }}</textarea>
@error('description')<div class="lc-err">{{ $message }}</div>@enderror
</div>
<div class="lc-field">
<label>Who can find it</label>
<div class="lc-vis">
<label>
<input type="radio" name="visibility" value="public" {{ old('visibility', 'public') === 'public' ? 'checked' : '' }}>
<div>
<div class="t">Public</div>
<div class="d">Listed on the Live page — anyone can discover and watch it.</div>
</div>
</label>
<label>
<input type="radio" name="visibility" value="unlisted" {{ old('visibility') === 'unlisted' ? 'checked' : '' }}>
<div>
<div class="t">Unlisted</div>
<div class="d">Not listed anywhere. Only people you send the link to can watch.</div>
</div>
</label>
{{-- Mirror the upload-modal's Private wording: only the owner
sees it. Useful for test runs and private rehearsals. --}}
<label>
<input type="radio" name="visibility" value="private" {{ old('visibility') === 'private' ? 'checked' : '' }}>
<div>
<div class="t">Private</div>
<div class="d">Only you can see this stream. Perfect for testing without an audience.</div>
</div>
</label>
</div>
@error('visibility')<div class="lc-err">{{ $message }}</div>@enderror
</div>
<button type="submit" class="action-btn action-btn-primary" style="width:100%;justify-content:center;height:46px;">
<i class="bi bi-camera-video"></i> <span>Set up camera</span>
</button>
</form>
</div>
@endsection
+95
View File
@@ -0,0 +1,95 @@
@extends('layouts.app')
@section('title', 'Live')
@section('extra_styles')
<style>
.li-wrap { max-width: 1300px; margin: 0 auto; padding: 16px; }
.li-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.li-head h1 { font-size: 22px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 10px; }
.li-head h1 .dot { width: 9px; height: 9px; border-radius: 50%; background: #ef4444; animation: liPulse 1.4s ease-in-out infinite; }
@keyframes liPulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
.li-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.li-card { display: block; text-decoration: none; color: inherit; border-radius: 14px; overflow: hidden; background: var(--yt-bg-secondary, #181818); border: 1px solid rgba(255,255,255,.08); transition: transform .15s ease, border-color .15s ease; }
.li-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.2); }
.li-thumb { position: relative; aspect-ratio: 16/9; background: linear-gradient(135deg, #1f2937, #111827); display: grid; place-items: center; }
.li-thumb i { font-size: 32px; opacity: .3; }
.li-live { position: absolute; top: 8px; left: 8px; background: #ef4444; color: #fff; font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 4px; letter-spacing: .04em; }
.li-viewers { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,.75); color: #fff; font-size: 11px; padding: 3px 7px; border-radius: 4px; }
.li-body { padding: 12px 14px; }
.li-body h3 { font-size: 14px; font-weight: 600; margin: 0 0 5px; line-height: 1.35; }
.li-body p { font-size: 12px; opacity: .6; margin: 0; }
.li-empty { text-align: center; padding: 60px 20px; opacity: .55; }
.li-empty i { font-size: 40px; display: block; margin-bottom: 14px; opacity: .5; }
.li-section-title { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; opacity: .55; margin: 34px 0 14px; }
.li-status { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,.1); }
.li-status.live { background: #ef4444; color: #fff; }
</style>
@endsection
@section('content')
<div class="li-wrap">
<div class="li-head">
<h1><span class="dot"></span> Live now</h1>
@auth
<a class="action-btn action-btn-primary" href="{{ route('live.create') }}">
<i class="bi bi-broadcast"></i> <span>Go Live</span>
</a>
@endauth
</div>
@if ($live->isEmpty())
<div class="li-empty">
<i class="bi bi-broadcast"></i>
<div>Nobody is streaming right now.</div>
@auth<div style="margin-top:8px;font-size:13px;">Be the first — hit <strong>Go Live</strong>.</div>@endauth
</div>
@else
<div class="li-grid">
@foreach ($live as $s)
<a class="li-card" href="{{ route('live.watch', $s) }}">
<div class="li-thumb">
<i class="bi bi-broadcast"></i>
<span class="li-live">LIVE</span>
<span class="li-viewers"><i class="bi bi-eye"></i> {{ $s->current_viewers }}</span>
</div>
<div class="li-body">
<h3>{{ $s->title }}</h3>
<p>{{ $s->user->name ?? 'Unknown' }}</p>
</div>
</a>
@endforeach
</div>
@endif
@auth
@if ($mine->isNotEmpty())
<div class="li-section-title">Your streams</div>
<div class="li-grid">
@foreach ($mine as $s)
<a class="li-card" href="{{ $s->status === 'ended' ? route('live.watch', $s) : route('live.broadcast', $s) }}">
<div class="li-thumb">
<i class="bi bi-{{ $s->isLive() ? 'broadcast' : 'camera-video-off' }}"></i>
@if ($s->isLive())<span class="li-live">LIVE</span>@endif
</div>
<div class="li-body">
<h3>{{ $s->title }}</h3>
<p>
<span class="li-status {{ $s->isLive() ? 'live' : '' }}">{{ ucfirst($s->status) }}</span>
@if ($s->started_at)
· {{ $s->formatted_duration }}
@endif
</p>
</div>
</a>
@endforeach
</div>
@endif
@endauth
</div>
@endsection
+501
View File
@@ -0,0 +1,501 @@
@extends('layouts.app')
@section('title', $stream->title.' — Live')
@section('extra_styles')
<style>
.lw-wrap { max-width: 1200px; margin: 0 auto; padding: 16px; }
.lw-stage { position: relative; background: #000; border-radius: 16px; overflow: hidden; aspect-ratio: 16 / 9; }
.lw-stage video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }
.lw-badge {
position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center; gap: 7px;
padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .04em;
background: rgba(239,68,68,.92); color: #fff;
}
.lw-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: #fff; animation: lwPulse 1.4s ease-in-out infinite; }
.lw-badge.offline { background: rgba(0,0,0,.65); }
.lw-badge.offline .dot { background: #9ca3af; animation: none; }
@keyframes lwPulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
.lw-mode {
position: absolute; top: 12px; right: 12px; padding: 5px 10px; border-radius: 999px;
background: rgba(0,0,0,.55); color: #d1d5db; font-size: 11px; backdrop-filter: blur(8px);
}
.lw-overlay {
position: absolute; inset: 0; display: grid; place-items: center; text-align: center;
background: rgba(0,0,0,.72); color: #e5e7eb; padding: 24px; gap: 10px;
}
.lw-overlay i { font-size: 34px; opacity: .5; display: block; margin-bottom: 10px; }
.lw-overlay .sub { font-size: 13px; opacity: .65; margin-top: 6px; }
.lw-head { display: flex; gap: 14px; align-items: flex-start; margin-top: 18px; flex-wrap: wrap; }
.lw-head h1 { font-size: 20px; font-weight: 700; margin: 0 0 6px; }
.lw-meta { font-size: 13px; opacity: .65; display: flex; gap: 14px; flex-wrap: wrap; }
.lw-desc { margin-top: 14px; font-size: 14px; line-height: 1.65; opacity: .85; white-space: pre-wrap; }
.lw-spin { width: 28px; height: 28px; border: 3px solid rgba(255,255,255,.2); border-top-color: #fff; border-radius: 50%; animation: lwSpin .8s linear infinite; }
@keyframes lwSpin { to { transform: rotate(360deg); } }
</style>
@endsection
@section('content')
<div class="lw-wrap">
@php
$isEnded = $stream->status === \App\Models\LiveStream::STATUS_ENDED;
$isLive = $stream->status === \App\Models\LiveStream::STATUS_LIVE;
@endphp
<div class="lw-stage">
<video id="lwVideo" autoplay playsinline controls></video>
<div class="lw-badge" id="lwBadge"><span class="dot"></span><span id="lwBadgeText">LIVE</span></div>
<div class="lw-mode" id="lwMode" style="display:none;"></div>
{{-- Ended stream: show "Stream ended" + duration right from the
server; if a recording exists, surface a Watch Replay CTA. --}}
@if ($isEnded)
<div class="lw-overlay" id="lwOverlay" style="background: rgba(0,0,0,.86);">
<div>
<i class="bi bi-stop-circle" aria-hidden="true" style="opacity:.85;color:#f87171;"></i>
<div style="font-size:18px;font-weight:600;margin-bottom:4px;">Stream ended</div>
@if ($stream->started_at && $stream->ended_at)
<div class="sub">
{{ $stream->ended_at->diffForHumans($stream->started_at, ['parts' => 2, 'syntax' => \Carbon\CarbonInterface::DIFF_ABSOLUTE]) }}
· ended {{ $stream->ended_at->diffForHumans() }}
</div>
@endif
@if ($stream->video_id)
<a href="{{ route('videos.show', ['video' => $stream->video->getRouteKey()]) }}"
class="action-btn action-btn-primary"
style="margin-top:16px;">
<i class="bi bi-play-fill" aria-hidden="true"></i>
<span>Watch replay</span>
</a>
@endif
</div>
</div>
@else
<div class="lw-overlay" id="lwOverlay">
<div>
<div class="lw-spin" style="margin:0 auto 12px;"></div>
<div id="lwOverlayText">Connecting…</div>
<div class="sub" id="lwOverlaySub"></div>
</div>
</div>
@endif
</div>
<div class="lw-head">
<div style="flex:1;min-width:240px;">
<h1>{{ $stream->title }}</h1>
<div class="lw-meta">
{{-- Channel name is now a real link to the uploader's channel. --}}
@if ($stream->user)
<a href="{{ route('channel', $stream->user->channel) }}"
style="color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:4px;">
<i class="bi bi-person-circle" aria-hidden="true"></i>
<span style="color:var(--text-primary);">{{ $stream->user->name }}</span>
</a>
@endif
<span id="lwViewers"><i class="bi bi-eye" aria-hidden="true"></i> {{ $stream->current_viewers }} watching</span>
@if ($stream->started_at)
<span><i class="bi bi-clock" aria-hidden="true"></i> started {{ $stream->started_at->diffForHumans() }}</span>
@endif
</div>
@if ($stream->description)
<div class="lw-desc">{{ $stream->description }}</div>
@endif
</div>
@if ($isOwner)
<a class="action-btn" href="{{ route('live.broadcast', $stream) }}"><i class="bi bi-broadcast" aria-hidden="true"></i> <span>Broadcast controls</span></a>
@endif
</div>
{{-- Viewer actions row — mirrors the watch page's action bar on a smaller
canvas. Share works for everyone; Subscribe is auth-gated with the
Phase-7 ?intended= bounce; the owner never sees Subscribe. --}}
@if ($stream->user && (! Auth::check() || Auth::id() !== $stream->user_id))
<div class="lw-actions" style="display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;">
@auth
@php $isSub = Auth::user()->isSubscribedTo($stream->user); @endphp
<button type="button"
class="action-btn {{ $isSub ? '' : 'action-btn-primary' }}"
onclick="(function(){
const url = {{ Js::from(route('channel.subscribe', $stream->user_id)) }};
const csrf = document.querySelector('meta[name=\'csrf-token\']')?.content || '';
fetch(url, { method: 'POST', headers: { 'X-CSRF-TOKEN': csrf, 'Accept':'application/json' }})
.then(r=>r.json()).then(d=>{
if (d.subscribed !== undefined) {
event.target.closest('button').classList.toggle('action-btn-primary', !d.subscribed);
event.target.closest('button').querySelector('span').textContent = d.subscribed ? 'Subscribed' : 'Subscribe';
}
}).catch(()=>{});
})()">
<i class="bi {{ $isSub ? 'bi-bell-fill' : 'bi-bell' }}" aria-hidden="true"></i>
<span>{{ $isSub ? 'Subscribed' : 'Subscribe' }}</span>
</button>
@else
<button type="button" class="action-btn action-btn-primary"
onclick="if(typeof showToast==='function')showToast('Sign in to subscribe','info');setTimeout(()=>{location.href='{{ route('login') }}?intended='+encodeURIComponent(location.pathname+location.search);},600);">
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
</button>
@endauth
<button type="button" class="action-btn"
onclick="(function(){
const url = window.location.href;
if (navigator.share) { navigator.share({ title: {{ Js::from($stream->title) }}, url: url }).catch(()=>{}); return; }
navigator.clipboard?.writeText(url).then(()=>{ if(typeof showToast==='function')showToast('Link copied','success'); });
})()">
<i class="bi bi-share" aria-hidden="true"></i>
<span>Share</span>
</button>
</div>
@endif
{{-- ── Live chat panel ─────────────────────────────── --}}
@if ($isLive)
<div class="lw-chat" id="lwChat"
data-poll-url="{{ route('live.chat.poll', $stream) }}"
data-send-url="{{ route('live.chat.send', $stream) }}"
data-signed-in="{{ Auth::check() ? '1' : '0' }}"
data-login-url="{{ route('login') }}?intended={{ urlencode('/live/' . $stream->public_id) }}">
<div class="lw-chat-head">
<i class="bi bi-chat-dots-fill" aria-hidden="true" style="color:#ef4444;"></i>
<span>Live chat</span>
</div>
<div class="lw-chat-messages" id="lwChatMessages" aria-live="polite" aria-atomic="false">
<div style="color:var(--text-secondary);font-size:13px;text-align:center;padding:16px;">Say hi 👋</div>
</div>
<form class="lw-chat-form" id="lwChatForm" onsubmit="return lwChatSubmit(event);">
@csrf
<input type="text" id="lwChatInput" maxlength="400"
placeholder="{{ Auth::check() ? 'Say something…' : 'Sign in to chat' }}"
{{ Auth::check() ? '' : 'disabled' }}
aria-label="Chat message">
<button type="submit" aria-label="Send"
{{ Auth::check() ? '' : 'disabled' }}>
<i class="bi bi-send-fill" aria-hidden="true"></i>
</button>
</form>
</div>
<style>
.lw-chat {
margin-top: 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
display: flex; flex-direction: column;
max-height: 420px;
}
.lw-chat-head {
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
font-weight: 600; font-size: 14px;
display: flex; align-items: center; gap: 8px;
}
.lw-chat-messages {
flex: 1; overflow-y: auto; padding: 10px 14px;
display: flex; flex-direction: column; gap: 6px;
}
.lw-chat-msg {
font-size: 13px; line-height: 1.4;
display: flex; gap: 6px; align-items: flex-start;
padding: 4px 0;
}
.lw-chat-msg .n { font-weight: 600; color: #93c5fd; }
.lw-chat-msg .b { color: var(--text-primary); word-break: break-word; min-width: 0; }
.lw-chat-form {
display: flex; gap: 8px; padding: 10px 12px;
border-top: 1px solid var(--border-color);
}
.lw-chat-form input {
flex: 1; background: var(--bg-dark);
border: 1px solid var(--border-color); border-radius: 20px;
padding: 8px 14px; color: var(--text-primary); font-size: 13px;
}
.lw-chat-form input:focus { outline: none; border-color: #ef4444; }
.lw-chat-form button {
width: 36px; height: 36px; border-radius: 50%;
background: #ef4444; color: #fff; border: none;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; flex: 0 0 auto;
}
.lw-chat-form button:disabled {
background: var(--border-color); color: var(--text-secondary); cursor: not-allowed;
}
</style>
<script>
(function () {
const panel = document.getElementById('lwChat');
if (!panel) return;
const list = document.getElementById('lwChatMessages');
const input = document.getElementById('lwChatInput');
const form = document.getElementById('lwChatForm');
const pollU = panel.dataset.pollUrl;
const sendU = panel.dataset.sendUrl;
const loginU = panel.dataset.loginUrl;
const authed = panel.dataset.signedIn === '1';
let since = 0, timer = null, firstPaint = true;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function append(msg) {
const row = document.createElement('div');
row.className = 'lw-chat-msg';
row.innerHTML = '<span class="n">' + escapeHtml(msg.name) + ':</span>'
+ ' <span class="b">' + escapeHtml(msg.body) + '</span>';
list.appendChild(row);
list.scrollTop = list.scrollHeight;
}
async function poll() {
try {
const r = await fetch(pollU + '?since=' + since, { credentials: 'same-origin', headers: {'Accept':'application/json'} });
if (!r.ok) return;
const d = await r.json();
if (d.messages && d.messages.length) {
if (firstPaint) { list.innerHTML = ''; firstPaint = false; }
d.messages.forEach(m => { append(m); if (m.id > since) since = m.id; });
}
} catch (e) {}
}
window.lwChatSubmit = function (ev) {
ev.preventDefault();
if (!authed) { location.href = loginU; return false; }
const body = (input.value || '').trim();
if (!body) return false;
input.disabled = true;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch(sendU, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ body }),
}).then(r => r.json()).then(d => {
if (d.message) { append(d.message); since = Math.max(since, d.message.id); }
input.value = '';
}).catch(() => {
if (typeof showToast === 'function') showToast('Could not send message.', 'error');
}).finally(() => { input.disabled = false; input.focus(); });
return false;
};
poll();
timer = setInterval(poll, 3000);
window.addEventListener('beforeunload', () => clearInterval(timer));
})();
</script>
@endif
</div>
@endsection
@section('scripts')
@unless($isEnded)
<script>
(function () {
'use strict';
var CFG = {
whep: @json($whepUrl),
hls: @json($hlsUrl),
statusUrl: @json(route('live.status', $stream)),
iceServers: @json($iceServers),
isLive: @json($stream->isLive())
};
var video = document.getElementById('lwVideo');
var overlay = document.getElementById('lwOverlay');
var overlayText = document.getElementById('lwOverlayText');
var overlaySub = document.getElementById('lwOverlaySub');
var badge = document.getElementById('lwBadge');
var badgeText = document.getElementById('lwBadgeText');
var modeChip = document.getElementById('lwMode');
var pc = null, hls = null, started = false;
function showOverlay(text, sub, spinning) {
overlay.style.display = 'grid';
overlayText.textContent = text;
overlaySub.textContent = sub || '';
overlay.querySelector('.lw-spin').style.display = spinning === false ? 'none' : 'block';
}
function hideOverlay() { overlay.style.display = 'none'; }
function setMode(label) {
modeChip.textContent = label;
modeChip.style.display = 'block';
}
function setOffline() {
badge.classList.add('offline');
badgeText.textContent = 'OFFLINE';
showOverlay('This stream has ended', 'The recording may appear here shortly.', false);
teardown();
}
function teardown() {
if (pc) { try { pc.close(); } catch (e) {} pc = null; }
if (hls) { try { hls.destroy(); } catch (e) {} hls = null; }
}
/* ---------------- WHEP: sub-second path ---------------- */
// Tried first because it is the lowest latency. If anything about it fails
// — no UDP path, a restrictive network, an old browser — we fall through
// to LL-HLS rather than showing the viewer an error.
async function tryWhep() {
if (!window.RTCPeerConnection) { return false; }
try {
pc = new RTCPeerConnection({ iceServers: CFG.iceServers || [], bundlePolicy: 'max-bundle' });
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
var streamIn = new MediaStream();
pc.addEventListener('track', function (ev) {
streamIn.addTrack(ev.track);
video.srcObject = streamIn;
});
var offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await gathered(pc);
var res = await fetch(CFG.whep, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
});
if (!res.ok) { throw new Error('whep ' + res.status); }
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
return await new Promise(function (resolve) {
var settled = false;
function decide(ok) { if (!settled) { settled = true; resolve(ok); } }
pc.addEventListener('connectionstatechange', function () {
if (pc.connectionState === 'connected') { decide(true); }
if (pc.connectionState === 'failed') { decide(false); }
});
// Don't let a half-open connection stall the viewer forever.
setTimeout(function () { decide(pc && pc.connectionState === 'connected'); }, 6000);
});
} catch (e) {
return false;
}
}
function gathered(pc) {
if (pc.iceGatheringState === 'complete') { return Promise.resolve(); }
return new Promise(function (resolve) {
var done = false;
function finish() { if (!done) { done = true; resolve(); } }
pc.addEventListener('icegatheringstatechange', function () {
if (pc.iceGatheringState === 'complete') { finish(); }
});
setTimeout(finish, 3000);
});
}
/* ---------------- LL-HLS: the path that always works ---------------- */
function loadHlsLibrary() {
return new Promise(function (resolve, reject) {
if (window.Hls) { return resolve(); }
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
async function tryHls() {
// Safari plays HLS natively and does it better than hls.js does.
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = CFG.hls;
return true;
}
try { await loadHlsLibrary(); } catch (e) { return false; }
if (!window.Hls || !window.Hls.isSupported()) { return false; }
return await new Promise(function (resolve) {
hls = new Hls({ lowLatencyMode: true, backBufferLength: 30, liveSyncDurationCount: 2 });
var settled = false;
function decide(ok) { if (!settled) { settled = true; resolve(ok); } }
hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play().catch(function () {}); decide(true); });
hls.on(Hls.Events.ERROR, function (_e, data) {
if (data && data.fatal) { decide(false); }
});
hls.loadSource(CFG.hls);
hls.attachMedia(video);
setTimeout(function () { decide(false); }, 12000);
});
}
/* ---------------- Orchestration ---------------- */
async function connect() {
if (started) { return; }
started = true;
showOverlay('Connecting…', 'Finding the fastest route');
if (await tryWhep()) {
setMode('Ultra-low latency');
hideOverlay();
video.play().catch(function () {});
return;
}
teardown();
showOverlay('Connecting…', 'Using the standard route');
if (await tryHls()) {
setMode('Low latency');
hideOverlay();
return;
}
started = false;
showOverlay('Could not start playback', 'The stream may still be starting up. Retrying…', false);
setTimeout(function () { if (!started) { connect(); } }, 5000);
}
/* ---------------- Live state ---------------- */
// Polling is a deliberate, cheap fallback: this platform has no realtime
// channel of its own, and one small request every 10s per viewer is far
// less costly than a socket layer built only for this page.
async function pollStatus() {
try {
var res = await fetch(CFG.statusUrl, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } });
if (!res.ok) { return; }
var data = await res.json();
document.getElementById('lwViewers').innerHTML = '<i class="bi bi-eye"></i> ' + data.viewers + ' watching';
if (data.live && !started) { connect(); }
if (!data.live && data.status === 'ended') { setOffline(); }
} catch (e) { /* transient */ }
}
if (CFG.isLive) {
connect();
} else {
showOverlay('Waiting for the broadcast to start', 'This page will begin automatically.', true);
}
setInterval(pollStatus, 10000);
window.addEventListener('pagehide', teardown);
})();
</script>
@endunless
@endsection
+31 -9
View File
@@ -37,14 +37,25 @@ a.pag-item:hover {
@endonce @endonce
<nav class="pag-nav" aria-label="Pagination"> <nav class="pag-nav" aria-label="Pagination">
{{-- Prev --}} {{-- First --}}
@if ($paginator->onFirstPage()) @if ($paginator->onFirstPage())
<span class="pag-item disabled" aria-disabled="true"> <span class="pag-item disabled" aria-disabled="true" title="First page">
<i class="bi bi-chevron-left" style="font-size:11px;"></i> <i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
</span> </span>
@else @else
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous"> <a class="pag-item" href="{{ $paginator->url(1) }}" aria-label="First page" title="First page">
<i class="bi bi-chevron-left" style="font-size:11px;"></i> <i class="bi bi-chevron-double-left" style="font-size:11px;" aria-hidden="true"></i>
</a>
@endif
{{-- Prev --}}
@if ($paginator->onFirstPage())
<span class="pag-item disabled" aria-disabled="true" title="Previous page">
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
</span>
@else
<a class="pag-item" href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous page" title="Previous page">
<i class="bi bi-chevron-left" style="font-size:11px;" aria-hidden="true"></i>
</a> </a>
@endif @endif
@@ -66,12 +77,23 @@ a.pag-item:hover {
{{-- Next --}} {{-- Next --}}
@if ($paginator->hasMorePages()) @if ($paginator->hasMorePages())
<a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next"> <a class="pag-item" href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next page" title="Next page">
<i class="bi bi-chevron-right" style="font-size:11px;"></i> <i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
</a> </a>
@else @else
<span class="pag-item disabled" aria-disabled="true"> <span class="pag-item disabled" aria-disabled="true" title="Next page">
<i class="bi bi-chevron-right" style="font-size:11px;"></i> <i class="bi bi-chevron-right" style="font-size:11px;" aria-hidden="true"></i>
</span>
@endif
{{-- Last --}}
@if ($paginator->hasMorePages() && $paginator->lastPage() > $paginator->currentPage())
<a class="pag-item" href="{{ $paginator->url($paginator->lastPage()) }}" aria-label="Last page" title="Last page ({{ $paginator->lastPage() }})">
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
</a>
@else
<span class="pag-item disabled" aria-disabled="true" title="Last page">
<i class="bi bi-chevron-double-right" style="font-size:11px;" aria-hidden="true"></i>
</span> </span>
@endif @endif
+238
View File
@@ -0,0 +1,238 @@
@extends('layouts.app')
@section('title', 'Studio | ' . config('app.name'))
@section('content')
<style>
.st-head { padding: 24px 32px 8px; }
.st-head h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; }
.st-head h1 i { color: #ef4444; }
.st-head p { margin: 0 0 14px; color: var(--text-secondary); font-size: 13px; }
.st-stat-cards {
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 10px; margin: 12px 0 20px;
}
.st-stat-card {
background: var(--bg-secondary);
border-radius: 10px; padding: 12px 14px;
display: flex; flex-direction: column; gap: 2px;
}
.st-stat-card .k { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); }
.st-stat-card .v { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.st-filter-bar {
display: flex; gap: 8px; overflow-x: auto;
padding: 4px 0; scrollbar-width: none;
margin-bottom: 14px;
}
.st-filter-bar::-webkit-scrollbar { display: none; }
.st-filter-pill {
padding: 6px 12px; border-radius: 999px;
background: var(--bg-secondary); color: var(--text-primary);
font-size: 12px; font-weight: 600; text-decoration: none;
white-space: nowrap; border: 1px solid transparent;
}
.st-filter-pill:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
.st-filter-pill.active { background: #ef4444; color: #fff; }
.st-toolbar {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 14px; margin-bottom: 8px;
background: var(--bg-secondary); border-radius: 10px;
}
.st-toolbar.hidden { display: none; }
.st-toolbar-count { font-size: 13px; color: var(--text-secondary); }
.st-toolbar-actions { display: flex; gap: 8px; align-items: center; }
.st-visibility-select {
background: var(--bg-dark); color: var(--text-primary);
border: 1px solid var(--border-color); border-radius: 6px;
padding: 6px 10px; font-size: 13px;
}
.st-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.st-table th, .st-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border-color);
text-align: left;
}
.st-table th { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); font-weight: 600; }
.st-table tbody tr:hover { background: rgba(255,255,255,.03); }
.st-table .st-check { width: 32px; }
.st-table .st-thumb { width: 76px; height: 44px; object-fit: cover; border-radius: 6px; }
.st-table .st-title-cell { display: flex; gap: 10px; align-items: flex-start; max-width: 320px; }
.st-table .st-title-name {
font-weight: 500; color: var(--text-primary);
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.st-table .st-title-desc {
font-size: 11px; color: var(--text-secondary); margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden;
}
.st-badge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600;
}
.st-badge.pub { background: rgba(34,197,94,.15); color: #4ade80; }
.st-badge.unl { background: rgba(234,179,8,.15); color: #fde047; }
.st-badge.priv { background: rgba(100,116,139,.2); color: #cbd5e1; }
.st-status { font-size: 11px; color: var(--text-secondary); }
.st-row-link { color: var(--text-primary); text-decoration: none; }
.st-row-link:hover { color: #ef4444; }
</style>
<div class="st-head">
<h1><i class="bi bi-sliders" aria-hidden="true"></i> Studio</h1>
<p>Manage every video you've uploaded. Select rows for bulk visibility changes.</p>
<div class="st-stat-cards">
<div class="st-stat-card"><span class="k">Total videos</span> <span class="v">{{ number_format($totals['total']) }}</span></div>
<div class="st-stat-card"><span class="k">Public</span> <span class="v">{{ number_format($totals['public']) }}</span></div>
<div class="st-stat-card"><span class="k">Unlisted</span> <span class="v">{{ number_format($totals['unlisted']) }}</span></div>
<div class="st-stat-card"><span class="k">Private</span> <span class="v">{{ number_format($totals['private']) }}</span></div>
<div class="st-stat-card"><span class="k">Processing</span> <span class="v">{{ number_format($totals['processing']) }}</span></div>
<div class="st-stat-card"><span class="k">Failed</span> <span class="v">{{ number_format($totals['failed']) }}</span></div>
<div class="st-stat-card"><span class="k">Total views</span> <span class="v">{{ number_format($totals['total_views']) }}</span></div>
</div>
<div class="st-filter-bar" role="navigation" aria-label="Filter videos">
@foreach (['all'=>'All', 'public'=>'Public', 'unlisted'=>'Unlisted', 'private'=>'Private', 'shorts'=>'Shorts', 'draft'=>'Processing / Failed'] as $k => $label)
<a href="{{ route('studio', ['filter' => $k]) }}"
class="st-filter-pill {{ $filter === $k ? 'active' : '' }}"
@if($filter === $k) aria-current="page" @endif>{{ $label }}</a>
@endforeach
</div>
</div>
<div style="padding: 0 32px 32px;">
<div class="st-toolbar hidden" id="stToolbar" aria-live="polite">
<span class="st-toolbar-count"><span id="stSelectedCount">0</span> selected</span>
<div class="st-toolbar-actions">
<select class="st-visibility-select" id="stBulkVisibility">
<option value="">Change visibility…</option>
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Private</option>
</select>
<button type="button" class="action-btn" onclick="stApplyBulkVisibility()">Apply</button>
<button type="button" class="action-btn action-btn-danger" onclick="stApplyBulkDelete()">Delete selected</button>
</div>
</div>
@if ($videos->isEmpty())
<div class="yt-empty" style="text-align:center; padding:60px 20px;">
<i class="bi bi-camera-video" style="font-size:48px;color:var(--text-secondary);display:block;margin-bottom:12px;" aria-hidden="true"></i>
<h2>No videos in this filter</h2>
<p style="color:var(--text-secondary);">Switch filters above or upload a new video from the sidebar.</p>
</div>
@else
<table class="st-table">
<thead>
<tr>
<th class="st-check">
<input type="checkbox" id="stSelectAll" onclick="stToggleAll(this)" aria-label="Select all rows">
</th>
<th>Video</th>
<th>Visibility</th>
<th>Views</th>
<th>Likes</th>
<th>Comments</th>
<th>Uploaded</th>
</tr>
</thead>
<tbody>
@foreach ($videos as $v)
<tr>
<td class="st-check"><input type="checkbox" class="st-row-check" value="{{ $v->id }}" onclick="stUpdateToolbar()" aria-label="Select {{ $v->title }}"></td>
<td>
<div class="st-title-cell">
@if ($v->thumbnail)
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" class="st-thumb" alt="" loading="lazy">
@else
<div class="st-thumb" style="background:#1a1a1a;"></div>
@endif
<div style="min-width:0;">
<a class="st-row-link" href="{{ route('videos.show', $v) }}" target="_blank">
<div class="st-title-name" title="{{ $v->title }}">{{ $v->title }}</div>
</a>
@if ($v->description)
@php $plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $v->description))); @endphp
<div class="st-title-desc" title="{{ $plain }}">{{ Str::limit($plain, 70) }}</div>
@endif
</div>
</div>
</td>
<td>
@switch($v->visibility)
@case('public') <span class="st-badge pub">Public</span> @break
@case('unlisted') <span class="st-badge unl">Unlisted</span> @break
@case('private') <span class="st-badge priv">Private</span> @break
@endswitch
@if ($v->status !== 'ready')
<div class="st-status">{{ ucfirst($v->status) }}</div>
@endif
</td>
<td>{{ number_format($v->view_count) }}</td>
<td>{{ number_format($v->likes()->count()) }}</td>
<td>{{ number_format($v->comments()->count()) }}</td>
<td>{{ $v->created_at->format('M j, Y') }}</td>
</tr>
@endforeach
</tbody>
</table>
<div style="margin-top: 16px;">{{ $videos->links() }}</div>
@endif
</div>
<script>
function stCheckedIds() {
return Array.from(document.querySelectorAll('.st-row-check:checked')).map(c => parseInt(c.value, 10));
}
function stUpdateToolbar() {
const n = stCheckedIds().length;
const t = document.getElementById('stToolbar');
document.getElementById('stSelectedCount').textContent = n;
t.classList.toggle('hidden', n === 0);
}
function stToggleAll(cb) {
document.querySelectorAll('.st-row-check').forEach(c => { c.checked = cb.checked; });
stUpdateToolbar();
}
async function stApplyBulkVisibility() {
const ids = stCheckedIds();
const sel = document.getElementById('stBulkVisibility');
if (!ids.length || !sel.value) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
try {
const r = await fetch('{{ route('studio.bulkVisibility') }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ video_ids: ids, visibility: sel.value }),
});
if (!r.ok) throw new Error('bad');
const d = await r.json();
if (typeof showToast === 'function') showToast('Updated ' + d.updated + ' video(s).', 'success');
setTimeout(() => location.reload(), 400);
} catch { if (typeof showToast === 'function') showToast('Could not update videos.', 'error'); }
}
async function stApplyBulkDelete() {
const ids = stCheckedIds();
if (!ids.length) return;
if (!window.confirm('Delete ' + ids.length + ' video(s)? This cannot be undone.')) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
try {
const r = await fetch('{{ route('studio.bulkDelete') }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
body: JSON.stringify({ video_ids: ids }),
});
if (!r.ok) throw new Error('bad');
const d = await r.json();
if (typeof showToast === 'function') showToast('Deleted ' + d.deleted + ' video(s).', 'success');
setTimeout(() => location.reload(), 400);
} catch { if (typeof showToast === 'function') showToast('Could not delete videos.', 'error'); }
}
</script>
@endsection
+117 -22
View File
@@ -122,7 +122,8 @@ $headerSocialMap = [
<div class="ch-meta-row"> <div class="ch-meta-row">
<span class="ch-meta-item"> <span class="ch-meta-item">
<i class="bi bi-camera-video-fill"></i> <i class="bi bi-camera-video-fill"></i>
{{ number_format($videos->count() + $shorts->count()) }} videos @php $vidTotal = $videos->count() + $shorts->count(); @endphp
{{ number_format($vidTotal) }} {{ Str::plural('video', $vidTotal) }}
</span> </span>
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item"> <span class="ch-meta-item">
@@ -136,7 +137,14 @@ $headerSocialMap = [
<i class="bi bi-people-fill"></i> <i class="bi bi-people-fill"></i>
{{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }} {{ number_format($user->subscriber_count) }} {{ Str::plural('subscriber', $user->subscriber_count) }}
</span> </span>
@if($user->location) {{-- Location, age and gender used to render in the public
header meta row — visible to every visitor. That's a
privacy leak the user flagged; those three now show
only to the owner here, and only in the About tab for
everyone (via the existing ch-about-card below). Join
month stays public because it's channel metadata, not
personal data. --}}
@if($isOwner && $user->location)
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item"> <span class="ch-meta-item">
<i class="bi bi-geo-alt-fill"></i> <i class="bi bi-geo-alt-fill"></i>
@@ -148,7 +156,7 @@ $headerSocialMap = [
<i class="bi bi-calendar3"></i> <i class="bi bi-calendar3"></i>
Joined {{ $user->created_at->format('M Y') }} Joined {{ $user->created_at->format('M Y') }}
</span> </span>
@if($user->birthday) @if($isOwner && $user->birthday)
@php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp @php $age = \Carbon\Carbon::parse($user->birthday)->age; @endphp
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item"> <span class="ch-meta-item">
@@ -156,13 +164,13 @@ $headerSocialMap = [
{{ $age }} yrs {{ $age }} yrs
</span> </span>
@endif @endif
@if($user->gender === 'male') @if($isOwner && $user->gender === 'male')
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item" style="color:#3b82f6;font-weight:700;"> <span class="ch-meta-item" style="color:#3b82f6;font-weight:700;">
<i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i> <i class="bi bi-gender-male" style="font-size:14px;-webkit-text-stroke:.4px #3b82f6;filter:drop-shadow(0 0 6px #3b82f640);"></i>
Male Male
</span> </span>
@elseif($user->gender === 'female') @elseif($isOwner && $user->gender === 'female')
<span class="ch-meta-sep">·</span> <span class="ch-meta-sep">·</span>
<span class="ch-meta-item" style="color:#ec4899;font-weight:700;"> <span class="ch-meta-item" style="color:#ec4899;font-weight:700;">
<i class="bi bi-gender-female" style="font-size:14px;-webkit-text-stroke:.4px #ec4899;filter:drop-shadow(0 0 6px #ec489940);"></i> <i class="bi bi-gender-female" style="font-size:14px;-webkit-text-stroke:.4px #ec4899;filter:drop-shadow(0 0 6px #ec489940);"></i>
@@ -181,18 +189,25 @@ $headerSocialMap = [
</div> </div>
@endif @endif
{{-- Horoscope + Compatibility strip & Social buttons side by side --}} {{-- Horoscope + Compatibility strip & Social buttons side by side.
@if($horoscope || $socialLinks->isNotEmpty()) The horoscope portion is sign-derived from the birthday the
owner entered; it only renders to the owner (or to a visitor
who's seeing the compatibility match-up). Pure social buttons
still show to visitors. --}}
@php @php
$hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null; $hColor = $horoscope ? \App\Helpers\Horoscope::elementColor($horoscope['element']) : null;
$showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner); $showCompat = $horoscope && ($compatibility !== null && $viewerSign && !$isOwner);
$showHoroStrip = $horoscope && ($isOwner || $showCompat);
@endphp
@if($showHoroStrip || $socialLinks->isNotEmpty())
@php
if ($showCompat) { if ($showCompat) {
$compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444'); $compatColor = $compatibility >= 75 ? '#22c55e' : ($compatibility >= 55 ? '#eab308' : '#ef4444');
$compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo'))); $compatLabel = $compatibility >= 85 ? 'Soulmates' : ($compatibility >= 70 ? 'Great match' : ($compatibility >= 55 ? 'Good vibes' : ($compatibility >= 40 ? 'Some friction' : 'Tough combo')));
} }
@endphp @endphp
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;"> <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:14px;">
@if($horoscope) @if($showHoroStrip)
<div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;" <div class="ch-horo-strip" style="margin-bottom:0; cursor:pointer;"
onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope"> onclick="switchTab('about',document.querySelector('[data-tab=about]'))" title="Click to view horoscope">
<div class="ch-horo-sign-part"> <div class="ch-horo-sign-part">
@@ -361,10 +376,15 @@ $headerSocialMap = [
<span class="ch-tab-badge">{{ $videos->count() }}</span> <span class="ch-tab-badge">{{ $videos->count() }}</span>
@endif @endif
</button> </button>
@if($shorts->count() > 0) {{-- Always show the Shorts tab for the owner so the "My Shorts"
sidebar link always lands somewhere; for visitors only when
there's content to show. --}}
@if($isOwner || $shorts->count() > 0)
<button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)"> <button class="ch-tab" data-tab="shorts" onclick="switchTab('shorts', this)">
Shorts Shorts
@if($shorts->count() > 0)
<span class="ch-tab-badge">{{ $shorts->count() }}</span> <span class="ch-tab-badge">{{ $shorts->count() }}</span>
@endif
</button> </button>
@endif @endif
@if(($playlists && $playlists->count() > 0) || $isOwner) @if(($playlists && $playlists->count() > 0) || $isOwner)
@@ -394,9 +414,9 @@ $headerSocialMap = [
{{-- ── LEFT: composer + feed ── --}} {{-- ── LEFT: composer + feed ── --}}
<div> <div>
{{-- Composer (owner only) --}} {{-- Composer (owner only, hidden when previewing as a visitor) --}}
@auth @auth
@if(Auth::id() === $user->id) @if(Auth::id() === $user->id && !$preview)
<div class="ch-composer"> <div class="ch-composer">
<form action="{{ route('posts.store', $user->id) }}" method="POST" enctype="multipart/form-data" id="postForm"> <form action="{{ route('posts.store', $user->id) }}" method="POST" enctype="multipart/form-data" id="postForm">
@csrf @csrf
@@ -528,7 +548,7 @@ $headerSocialMap = [
<div class="ch-post-video-info"> <div class="ch-post-video-info">
<div class="ch-post-video-title">{{ $v->title }}</div> <div class="ch-post-video-title">{{ $v->title }}</div>
<div class="ch-post-video-meta"> <div class="ch-post-video-meta">
<span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) }}</span> <span class="ch-post-video-badge"><i class="bi bi-eye"></i> {{ $v->view_count > 0 ? \Illuminate\Support\Number::abbreviate($v->view_count, precision: 1) : '0' }}</span>
@if($v->duration)<span class="ch-post-video-badge"><i class="bi bi-clock"></i> {{ $v->formatted_duration }}</span>@endif @if($v->duration)<span class="ch-post-video-badge"><i class="bi bi-clock"></i> {{ $v->formatted_duration }}</span>@endif
@if($v->is_shorts)<span class="ch-post-video-badge" style="color:#e33;"><i class="bi bi-phone"></i> Short</span>@endif @if($v->is_shorts)<span class="ch-post-video-badge" style="color:#e33;"><i class="bi bi-phone"></i> Short</span>@endif
</div> </div>
@@ -663,24 +683,74 @@ $headerSocialMap = [
<div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;"> <div class="ch-sort-bar" style="margin-bottom:0; flex-shrink:0;">
<span class="ch-sort-label">Sort</span> <span class="ch-sort-label">Sort</span>
<div class="ch-sort-pills"> <div class="ch-sort-pills">
<a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) }}" <a href="{{ request()->fullUrlWithQuery(['sort' => 'latest']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'latest' ? 'active' : '' }}">Latest</a> class="ch-sort-pill {{ $sort === 'latest' ? 'active' : '' }}">Latest</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) }}" <a href="{{ request()->fullUrlWithQuery(['sort' => 'popular']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'popular' ? 'active' : '' }}">Popular</a> class="ch-sort-pill {{ $sort === 'popular' ? 'active' : '' }}">Popular</a>
<a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) }}" <a href="{{ request()->fullUrlWithQuery(['sort' => 'oldest']) . '#videos' }}"
class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a> class="ch-sort-pill {{ $sort === 'oldest' ? 'active' : '' }}">Oldest</a>
</div> </div>
</div> </div>
</div> </div>
<div class="yt-video-grid" id="chVidGrid"> <div class="yt-video-grid" id="chVidGrid">
@foreach($videos as $video) @foreach($videos as $video)
@include('components.video-card', ['video' => $video]) @include('components.video-card', ['video' => $video, 'hideChannelInfo' => true])
@endforeach @endforeach
<div class="ch-vid-no-results" id="chVidNoResults"> <div class="ch-vid-no-results" id="chVidNoResults">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
<span>No videos match your search</span> <span>No videos match your search</span>
</div> </div>
</div> </div>
{{-- Cursor-paginated Load More. The sentinel sits below the grid and
fetches the next slice when scrolled into view. A manual Load More
button is the fallback for browsers without IntersectionObserver. --}}
<div id="chVidSentinel"
data-username="{{ $user->username }}"
data-more-url="{{ route('channel.videos.more', ['username' => $user->username]) }}"
style="height:1px;"></div>
<div id="chVidLoadMoreWrap" style="display:none; text-align:center; padding:16px 0;">
<button type="button" id="chVidLoadMoreBtn" class="action-btn">Load more</button>
</div>
<script>
(function () {
const grid = document.getElementById('chVidGrid');
const sentinel = document.getElementById('chVidSentinel');
const btnWrap = document.getElementById('chVidLoadMoreWrap');
const btn = document.getElementById('chVidLoadMoreBtn');
if (!grid || !sentinel) return;
let cursor = '', busy = false, done = false;
const url = sentinel.dataset.moreUrl;
async function loadMore() {
if (busy || done) return;
busy = true;
try {
const u = new URL(url, window.location.origin);
if (cursor) u.searchParams.set('cursor', cursor);
const r = await fetch(u.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!r.ok) throw new Error('bad');
const d = await r.json();
if (d.items) grid.insertAdjacentHTML('beforeend', d.items);
cursor = d.next_cursor || '';
done = !d.has_more;
if (done) { sentinel.style.display = 'none'; btnWrap.style.display = 'none'; }
} catch (e) {
btnWrap.style.display = 'block';
} finally { busy = false; }
}
btn?.addEventListener('click', loadMore);
if ('IntersectionObserver' in window) {
new IntersectionObserver(entries => {
if (entries.some(e => e.isIntersecting)) loadMore();
}, { rootMargin: '400px 0px' }).observe(sentinel);
} else {
btnWrap.style.display = 'block';
}
})();
</script>
@else @else
<div class="ch-empty"> <div class="ch-empty">
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div> <div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
@@ -699,8 +769,15 @@ $headerSocialMap = [
</div> </div>
{{-- ─────────────────────── SHORTS TAB ──────────────── --}} {{-- ─────────────────────── SHORTS TAB ──────────────── --}}
@if($shorts->count() > 0) @if($isOwner || $shorts->count() > 0)
<div class="ch-tab-content" id="tab-shorts"> <div class="ch-tab-content" id="tab-shorts">
@if($shorts->isEmpty())
<div class="ch-empty">
<div class="ch-empty-icon"><i class="bi bi-phone"></i></div>
<h3>No shorts yet</h3>
<p>Vertical videos under 60 seconds appear here.</p>
</div>
@else
<div class="ch-shorts-grid"> <div class="ch-shorts-grid">
@foreach($shorts as $short) @foreach($shorts as $short)
<a href="{{ route('videos.show', $short) }}" class="ch-short-card"> <a href="{{ route('videos.show', $short) }}" class="ch-short-card">
@@ -722,6 +799,7 @@ $headerSocialMap = [
</a> </a>
@endforeach @endforeach
</div> </div>
@endif
</div> </div>
@endif @endif
@@ -753,8 +831,8 @@ $headerSocialMap = [
@endif @endif
<div class="ch-playlist-overlay"></div> <div class="ch-playlist-overlay"></div>
<div class="ch-playlist-count"> <div class="ch-playlist-count">
<i class="bi bi-play-fill"></i> <i class="bi bi-play-fill" aria-hidden="true"></i>
{{ $playlist->video_count }} videos {{ $playlist->video_count }} {{ Str::plural('video', $playlist->video_count) }}
</div> </div>
</div> </div>
<div class="ch-playlist-name">{{ $playlist->name }}</div> <div class="ch-playlist-name">{{ $playlist->name }}</div>
@@ -768,7 +846,13 @@ $headerSocialMap = [
@else @else
<i class="bi bi-globe"></i> Public <i class="bi bi-globe"></i> Public
@endif @endif
· <i class="bi bi-eye"></i> {{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }} {{-- Hide the view-count row entirely when the playlist has
never been viewed. Previously showed "· eye 0.0" because
Number::abbreviate(0, precision: 1) prints "0.0". --}}
@if($playlist->view_count > 0)
· <i class="bi bi-eye" aria-hidden="true"></i>
{{ \Illuminate\Support\Number::abbreviate($playlist->view_count, precision: 1) }}
@endif
</div> </div>
</a> </a>
@endforeach @endforeach
@@ -1057,7 +1141,7 @@ $headerSocialMap = [
<div class="ch-about-card-title"><i class="bi bi-bar-chart-fill"></i> Stats</div> <div class="ch-about-card-title"><i class="bi bi-bar-chart-fill"></i> Stats</div>
<div class="ch-stat-cards"> <div class="ch-stat-cards">
<div class="ch-stat-card"> <div class="ch-stat-card">
<div class="ch-stat-card-val">{{ \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) }}</div> <div class="ch-stat-card-val">{{ $totalViews > 0 ? \Illuminate\Support\Number::abbreviate($totalViews, precision: 1) : '0' }}</div>
<div class="ch-stat-card-key">Total Views</div> <div class="ch-stat-card-key">Total Views</div>
</div> </div>
<div class="ch-stat-card"> <div class="ch-stat-card">
@@ -1171,7 +1255,13 @@ if ($oldLinks) {
<div class="ch-settings-field"> <div class="ch-settings-field">
<label class="ch-settings-label">Gender</label> <label class="ch-settings-label">Gender</label>
<select name="gender" class="ch-settings-input" style="cursor:pointer;"> <select name="gender" class="ch-settings-input" style="cursor:pointer;">
<option value="">Prefer not to say</option> {{-- Empty value stands for "not chosen yet"
— different from the explicit "prefer not
to say" option. Previously both rendered
the same label, so users saw two
identical choices and the stats grouping
ambiguously bucketed one of them. --}}
<option value="">Select…</option>
<option value="male" {{ old('gender', $user->gender) === 'male' ? 'selected' : '' }}>Male</option> <option value="male" {{ old('gender', $user->gender) === 'male' ? 'selected' : '' }}>Male</option>
<option value="female" {{ old('gender', $user->gender) === 'female' ? 'selected' : '' }}>Female</option> <option value="female" {{ old('gender', $user->gender) === 'female' ? 'selected' : '' }}>Female</option>
<option value="prefer_not_to_say" {{ old('gender', $user->gender) === 'prefer_not_to_say' ? 'selected' : '' }}>Prefer not to say</option> <option value="prefer_not_to_say" {{ old('gender', $user->gender) === 'prefer_not_to_say' ? 'selected' : '' }}>Prefer not to say</option>
@@ -1338,9 +1428,14 @@ if ($oldLinks) {
@php @php
$notifPrefs = $user->notification_preferences ?? []; $notifPrefs = $user->notification_preferences ?? [];
$notifDefs = \App\Models\User::notifDefaults(); $notifDefs = \App\Models\User::notifDefaults();
// function_exists guard so a second render in the same PHP
// process (feature tests fire two requests in one process)
// doesn't fatal with "Cannot redeclare notifVal()".
if (!function_exists('notifVal')) {
function notifVal(array $prefs, array $defs, string $key): bool { function notifVal(array $prefs, array $defs, string $key): bool {
return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true); return isset($prefs[$key]) ? (bool)$prefs[$key] : ($defs[$key] ?? true);
} }
}
@endphp @endphp
<div class="ch-settings-card" id="notifPrefsCard"> <div class="ch-settings-card" id="notifPrefsCard">
<div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div> <div class="ch-settings-card-title"><i class="bi bi-bell-fill"></i> Notification Preferences</div>
+1 -1
View File
@@ -1,6 +1,6 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Watch History | TAKEONE') @section('title', 'Watch History | ' . config('app.name'))
@section('extra_styles') @section('extra_styles')
<style> <style>
+3 -2
View File
@@ -1,6 +1,6 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Liked Videos | TAKEONE') @section('title', 'Liked Videos | ' . config('app.name'))
@section('extra_styles') @section('extra_styles')
<style> <style>
@@ -146,7 +146,8 @@
@endforeach @endforeach
</div> </div>
<div class="mt-4">{{ $videos->onEachSide(1)->links() }}</div> {{-- Cursor paginator has no "N pages" concept — just prev/next. --}}
<div class="mt-4">{{ $videos->links() }}</div>
@endif @endif
@endsection @endsection
@@ -342,10 +342,17 @@
/* ══════════════════════════════════════════════════ /* ══════════════════════════════════════════════════
VIDEO GRID VIDEO GRID
══════════════════════════════════════════════════ */ ══════════════════════════════════════════════════ */
.yt-video-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } /* Fluid grid: as many 260-px columns as fit, each one stretching to fill
@media (max-width: 1100px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } } the leftover space. Removes the three fixed breakpoints that produced
@media (max-width: 600px) { .yt-video-grid { grid-template-columns: 1fr; } } different card widths at 1101px vs 1500px, and `min-width:0` on every
@media (min-width: 1500px) { .yt-video-grid { grid-template-columns: repeat(4, 1fr); } } grid child so a long unbroken title (CJK, "SPARTAWORRIOR") can't widen
its track past the configured 1fr. */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 20px;
}
.yt-video-grid > * { min-width: 0; }
/* ══════════════════════════════════════════════════ /* ══════════════════════════════════════════════════
SHORTS GRID SHORTS GRID
+1 -1
View File
@@ -1,6 +1,6 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Profile | TAKEONE') @section('title', 'Profile | ' . config('app.name'))
@section('extra_styles') @section('extra_styles')
<style> <style>
+1 -1
View File
@@ -1,6 +1,6 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Settings | TAKEONE') @section('title', 'Settings | ' . config('app.name'))
@section('extra_styles') @section('extra_styles')
<style> <style>
+11
View File
@@ -784,6 +784,17 @@
setAudioMode(this.dataset.type === 'music'); setAudioMode(this.dataset.type === 'music');
}); });
}); });
// Honor ?type=generic|music|match forwarded from the desktop chooser
// when routed here on mobile — pre-select the corresponding option.
(function preselectTypeFromQuery() {
try {
const t = new URLSearchParams(window.location.search).get('type');
if (!t) return;
const opt = document.querySelector('#type-options .option-item[data-type="' + t + '"]');
if (opt) opt.click();
} catch (_) {}
}());
document.querySelectorAll('#visibility-options .option-item').forEach(item => { document.querySelectorAll('#visibility-options .option-item').forEach(item => {
item.querySelector('input').addEventListener('change', () => { item.querySelector('input').addEventListener('change', () => {
document.querySelectorAll('#visibility-options .option-item').forEach(o => o.classList.remove('active')); document.querySelectorAll('#visibility-options .option-item').forEach(o => o.classList.remove('active'));
+356 -21
View File
@@ -7,18 +7,28 @@
/* ── Regular video grid ── */ /* ── Regular video grid ── */
.yt-video-grid { .yt-video-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); /* minmax(0, 1fr) keeps every column exactly equal in width.
Plain 1fr lets a track grow if a child's min-content is wider
than the share, which was making the 3rd column wider (and
therefore taller, since the thumb is 16/9) than the first two. */
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 1fr;
align-items: stretch;
gap: 20px; gap: 20px;
} }
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } } .yt-video-grid > .yt-video-card { height: 100%; min-width: 0; }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } } @media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: minmax(0, 1fr); gap: 14px; } }
/* ── Thumbnail orientation fix ── */ /* ── Thumbnail orientation fix ── */
/* ── Thumbnail orientation fix ── */ /* ── Thumbnail orientation fix ──
.yt-video-card .yt-video-thumb img { object-fit: cover; } Always cover so every thumbnail visually fills its 16/9 box the
same way. `!important` beats any leftover inline style from the
old adjust-fit script (cached page in the wild). */
.yt-video-card .yt-video-thumb img { object-fit: cover !important; }
/* ── Search result helpers ── */ /* ── Search result helpers ── */
.search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; } .search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; }
@@ -32,6 +42,34 @@
} }
.results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); } .results-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border-color); }
/* Channel results — card list sized to sit comfortably above the video
grid. Avatar is a fixed 56px circle; name + handle sit to the right. */
.search-channels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
margin-bottom: 8px;
}
.search-channel-card {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 12px;
text-decoration: none;
color: var(--text-primary);
transition: background .15s ease;
}
.search-channel-card:hover { background: rgba(255,255,255,.08); }
.search-channel-card img,
.search-channel-ph {
width: 56px; height: 56px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
background: #333; display: flex; align-items: center; justify-content: center;
font-size: 22px; color: var(--text-secondary);
}
.search-channel-text { min-width: 0; }
.search-channel-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-channel-handle { font-size: 13px; color: var(--text-secondary); }
.yt-empty { text-align: center; padding: 80px 20px; } .yt-empty { text-align: center; padding: 80px 20px; }
/* Language chips */ /* Language chips */
@@ -46,21 +84,37 @@
{{-- ── Filter chip bar ── --}} {{-- ── Filter chip bar ── --}}
@unless(isset($query)) @unless(isset($query))
@php $activeFilter = request('filter', 'all'); @endphp @php $activeFilter = request('filter', 'all'); @endphp
<div class="yt-filter-bar"> <div class="yt-filter-bar-wrap">
<button type="button" class="yt-filter-nav left" aria-label="Scroll left" onclick="scrollFilterBar(-1)"><i class="bi bi-chevron-left"></i></button>
<button type="button" class="yt-filter-nav right" aria-label="Scroll right" onclick="scrollFilterBar(1)"><i class="bi bi-chevron-right"></i></button>
<div class="yt-filter-bar" role="navigation" aria-label="Browse filters">
{{-- aria-current="page" on the active chip so assistive tech can read
the current filter. "New to You" was renamed to "Recent" — it was
just the latest filter, not a personalised recommendation, so the
old label overpromised. --}}
@php
$isAll = $activeFilter === 'all';
$isTrending = request()->is('trending');
$isMusic = $activeFilter === 'music';
$isMatch = $activeFilter === 'match';
$isShorts = request()->is('shorts');
$isPlaylists = $activeFilter === 'playlists';
$isRecent = $activeFilter === 'latest';
@endphp
<a href="{{ route('videos.index') }}" <a href="{{ route('videos.index') }}"
class="yt-chip {{ $activeFilter === 'all' ? 'active' : '' }}">All</a> class="yt-chip {{ $isAll ? 'active' : '' }}" @if($isAll) aria-current="page"@endif>All</a>
<a href="{{ route('videos.trending') }}" <a href="{{ route('videos.trending') }}"
class="yt-chip {{ request()->is('trending') ? 'active' : '' }}">Trending</a> class="yt-chip {{ $isTrending ? 'active' : '' }}" @if($isTrending) aria-current="page"@endif>Trending</a>
<a href="{{ route('videos.index', ['filter' => 'music']) }}" <a href="{{ route('videos.index', ['filter' => 'music']) }}"
class="yt-chip {{ $activeFilter === 'music' ? 'active' : '' }}">Music</a> class="yt-chip {{ $isMusic ? 'active' : '' }}" @if($isMusic) aria-current="page"@endif>Music</a>
<a href="{{ route('videos.index', ['filter' => 'match']) }}" <a href="{{ route('videos.index', ['filter' => 'match']) }}"
class="yt-chip {{ $activeFilter === 'match' ? 'active' : '' }}">Sports</a> class="yt-chip {{ $isMatch ? 'active' : '' }}" @if($isMatch) aria-current="page"@endif>Sports</a>
<a href="{{ route('videos.shorts') }}" <a href="{{ route('videos.shorts') }}"
class="yt-chip {{ request()->is('shorts') ? 'active' : '' }}">Shorts</a> class="yt-chip {{ $isShorts ? 'active' : '' }}" @if($isShorts) aria-current="page"@endif>Shorts</a>
<a href="{{ route('videos.index', ['filter' => 'playlists']) }}" <a href="{{ route('videos.index', ['filter' => 'playlists']) }}"
class="yt-chip {{ $activeFilter === 'playlists' ? 'active' : '' }}">Playlists</a> class="yt-chip {{ $isPlaylists ? 'active' : '' }}" @if($isPlaylists) aria-current="page"@endif>Playlists</a>
<a href="{{ route('videos.index', ['filter' => 'latest']) }}" <a href="{{ route('videos.index', ['filter' => 'latest']) }}"
class="yt-chip {{ $activeFilter === 'latest' ? 'active' : '' }}">New to You</a> class="yt-chip {{ $isRecent ? 'active' : '' }}" @if($isRecent) aria-current="page"@endif>Recent</a>
@if(!empty($langChips ?? [])) @if(!empty($langChips ?? []))
@php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp @php $activeLang = str_starts_with($activeFilter, 'lang:') ? substr($activeFilter, 5) : null; @endphp
@@ -68,6 +122,7 @@
@foreach($langChips as $chip) @foreach($langChips as $chip)
<a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}" <a href="{{ route('videos.index', ['filter' => 'lang:' . $chip['code']]) }}"
class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}" class="yt-chip yt-chip-lang {{ $activeLang === $chip['code'] ? 'active' : '' }}"
@if($activeLang === $chip['code']) aria-current="page"@endif
title="{{ $chip['name'] }} ({{ $chip['count'] }})"> title="{{ $chip['name'] }} ({{ $chip['count'] }})">
<span class="fi fi-{{ $chip['flag'] }}"></span> <span class="fi fi-{{ $chip['flag'] }}"></span>
<span>{{ $chip['name'] }}</span> <span>{{ $chip['name'] }}</span>
@@ -75,8 +130,10 @@
@endforeach @endforeach
@endif @endif
</div> </div>
</div>{{-- /yt-filter-bar-wrap --}}
@endunless @endunless
<div id="filter-content">
{{-- ══════════════════════════════════════════════ {{-- ══════════════════════════════════════════════
SEARCH RESULTS SEARCH RESULTS
══════════════════════════════════════════════ --}} ══════════════════════════════════════════════ --}}
@@ -85,7 +142,8 @@
@php @php
$searchShorts = $shorts ?? collect(); $searchShorts = $shorts ?? collect();
$searchPlaylists = $playlists ?? collect(); $searchPlaylists = $playlists ?? collect();
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count(); $searchChannels = $channels ?? collect();
$totalResults = $videos->count() + $searchShorts->count() + $searchPlaylists->count() + $searchChannels->count();
@endphp @endphp
<div class="search-info"> <div class="search-info">
@@ -93,11 +151,33 @@
<p> <p>
@if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif @if($videos->count()) {{ $videos->count() }} video{{ $videos->count() !== 1 ? 's' : '' }} @endif
@if($searchShorts->count()) &nbsp;·&nbsp; {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif @if($searchShorts->count()) &nbsp;·&nbsp; {{ $searchShorts->count() }} short{{ $searchShorts->count() !== 1 ? 's' : '' }} @endif
@if($searchChannels->count()) &nbsp;·&nbsp; {{ $searchChannels->count() }} channel{{ $searchChannels->count() !== 1 ? 's' : '' }} @endif
@if($searchPlaylists->count()) &nbsp;·&nbsp; {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif @if($searchPlaylists->count()) &nbsp;·&nbsp; {{ $searchPlaylists->count() }} playlist{{ $searchPlaylists->count() !== 1 ? 's' : '' }} @endif
@if($totalResults === 0) No results found @endif @if($totalResults === 0) No results found @endif
</p> </p>
</div> </div>
@if($searchChannels->count())
<div class="results-section-title"><i class="bi bi-person-circle"></i> Channels</div>
<div class="search-channels-grid">
@foreach($searchChannels as $channel)
<a href="{{ route('channel', $channel->channel) }}" class="search-channel-card">
@if($channel->avatar_url)
<img src="{{ $channel->avatar_url }}" alt="{{ $channel->name }}" width="56" height="56" loading="lazy">
@else
<div class="search-channel-ph"><i class="bi bi-person"></i></div>
@endif
<div class="search-channel-text">
<div class="search-channel-name">{{ $channel->name }}</div>
@if($channel->username)
<div class="search-channel-handle">@{{ $channel->username }}</div>
@endif
</div>
</a>
@endforeach
</div>
@endif
@if($videos->count()) @if($videos->count())
<div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div> <div class="results-section-title"><i class="bi bi-play-circle"></i> Videos</div>
<div class="yt-video-grid"> <div class="yt-video-grid">
@@ -157,6 +237,60 @@
══════════════════════════════════════════════ --}} ══════════════════════════════════════════════ --}}
@elseif(!isset($query) && (!isset($filter) || $filter === 'all')) @elseif(!isset($query) && (!isset($filter) || $filter === 'all'))
@if(!empty($homeShorts) && $homeShorts->count())
{{-- Horizontally-scrolling Shorts shelf. Sits above the main feed grid
so the user sees fresh short-form content without the feed being
drowned in 60-second portrait clips. --}}
<section class="home-shorts-shelf" aria-labelledby="home-shorts-heading">
<div class="home-shorts-shelf-head">
<h2 id="home-shorts-heading"><i class="bi bi-lightning-charge-fill" aria-hidden="true"></i> Shorts</h2>
<a href="{{ route('videos.shorts') }}" class="home-shorts-see-all">See all</a>
</div>
<div class="home-shorts-shelf-track">
@foreach($homeShorts as $s)
<a class="home-short-card" href="{{ route('videos.shorts.focused', $s) }}"
aria-label="Open short: {{ $s->title }}">
<img src="{{ $s->thumbnail ? route('media.thumbnail', $s->thumbnail) : 'https://picsum.photos/seed/'.$s->id.'/180/320' }}"
alt="" width="150" height="266" loading="lazy" decoding="async">
<div class="home-short-card-overlay">
<div class="home-short-card-title">{{ Str::limit($s->title, 40) }}</div>
<div class="home-short-card-views">{{ number_format($s->view_count) }} views</div>
</div>
</a>
@endforeach
</div>
</section>
<style>
.home-shorts-shelf { margin: 8px 0 24px; }
.home-shorts-shelf-head { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:12px; }
.home-shorts-shelf-head h2 { font-size:18px; font-weight:600; margin:0; display:flex; align-items:center; gap:8px; }
.home-shorts-shelf-head h2 i { color:#ef4444; }
.home-shorts-see-all { color: var(--text-secondary); font-size:13px; text-decoration:none; }
.home-shorts-see-all:hover { color: var(--text-primary); text-decoration: underline; }
.home-shorts-shelf-track {
display:flex; gap:12px; overflow-x:auto; scroll-snap-type: x proximity;
scrollbar-width: none; -ms-overflow-style: none;
padding-bottom: 4px;
}
.home-shorts-shelf-track::-webkit-scrollbar { display:none; }
.home-short-card {
flex: 0 0 150px; aspect-ratio: 9/16; border-radius: 10px;
overflow: hidden; position: relative; scroll-snap-align: start;
text-decoration: none; color: inherit; background: #1a1a1a;
transition: transform .15s ease;
}
.home-short-card:hover { transform: translateY(-2px); }
.home-short-card img { width:100%; height:100%; object-fit: cover; display:block; }
.home-short-card-overlay {
position:absolute; left:0; right:0; bottom:0; padding: 20px 8px 8px;
background: linear-gradient(transparent, rgba(0,0,0,.75));
color: #fff;
}
.home-short-card-title { font-size:12px; font-weight:600; line-height:1.3; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
.home-short-card-views { font-size:11px; opacity:.8; margin-top:2px; }
</style>
@endif
@if(isset($feedItems) && $feedItems->count()) @if(isset($feedItems) && $feedItems->count())
<div class="yt-video-grid"> <div class="yt-video-grid">
@foreach($feedItems as $entry) @foreach($feedItems as $entry)
@@ -197,21 +331,222 @@
@endif @endif
{{-- Infinite-scroll sentinel. The IntersectionObserver down in @section('scripts')
fetches the next cursor page when this comes into view and appends the
rendered cards to the grid above. A visible "Load more" button is the
always-available fallback. --}}
<div id="feed-sentinel"
data-feed-filter="{{ $filter ?? 'all' }}"
style="height:1px;"></div>
<div id="feed-loadmore-wrap" style="display:none; text-align:center; padding:16px 0;">
<button type="button" id="feed-loadmore" class="action-btn">Load more</button>
</div>
</div>{{-- /filter-content --}}
@endsection @endsection
@section('scripts') @section('scripts')
<script> <script>
/* Thumbnails render at a uniform size: the .yt-video-thumb box is a
strict 16/9 container and every image inside it uses `object-fit:
cover` (set in the shared card CSS) so portrait/square art fills the
frame instead of shrinking with letterboxing. No per-image adjustment
needed. */
/* ─── Filter-bar horizontal scroll ─── */
(function () { (function () {
function adjustPlThumb(img) { var bar = document.querySelector('.yt-filter-bar');
img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover'; var wrap = document.querySelector('.yt-filter-bar-wrap');
if (!bar || !wrap) return;
var leftBtn = wrap.querySelector('.yt-filter-nav.left');
var rightBtn = wrap.querySelector('.yt-filter-nav.right');
function updateNav() {
var canLeft = bar.scrollLeft > 4;
var canRight = (bar.scrollLeft + bar.clientWidth) < (bar.scrollWidth - 4);
if (leftBtn) leftBtn.classList.toggle('visible', canLeft);
if (rightBtn) rightBtn.classList.toggle('visible', canRight);
} }
document.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
if (img.complete && img.naturalWidth) { // Expose for the inline onclick handlers.
adjustPlThumb(img); window.scrollFilterBar = function (dir) {
} else { bar.scrollBy({ left: dir * Math.max(240, bar.clientWidth * 0.8), behavior: 'smooth' });
img.addEventListener('load', function () { adjustPlThumb(img); }); };
// Convert vertical wheel to horizontal — but only when there's no
// shift key (shift+wheel already scrolls horizontally in browsers).
bar.addEventListener('wheel', function (e) {
if (e.deltaY === 0 || e.shiftKey) return;
// Only intercept when the bar can actually scroll in that direction,
// otherwise let the page scroll normally.
var going = e.deltaY > 0 ? 1 : -1;
var atEdge = (going > 0 && bar.scrollLeft + bar.clientWidth >= bar.scrollWidth - 1)
|| (going < 0 && bar.scrollLeft <= 0);
if (atEdge) return;
e.preventDefault();
bar.scrollLeft += e.deltaY;
}, { passive: false });
bar.addEventListener('scroll', updateNav, { passive: true });
window.addEventListener('resize', updateNav);
// Persist scroll position across navigations so clicking a chip
// doesn't snap the bar back to the start.
var STORAGE_KEY = 'ytFilterBarScroll';
try {
var saved = parseInt(sessionStorage.getItem(STORAGE_KEY) || '0', 10);
if (saved > 0) {
// Wait a tick so layout is settled and scrollWidth is accurate.
requestAnimationFrame(function () { bar.scrollLeft = saved; updateNav(); });
} }
} catch (e) {}
bar.addEventListener('scroll', function () {
try { sessionStorage.setItem(STORAGE_KEY, String(bar.scrollLeft)); } catch (e) {}
}, { passive: true });
updateNav();
})();
/* ─── SPA chip navigation — no full page reload ─── */
(function () {
var bar = document.querySelector('.yt-filter-bar');
if (!bar) return;
// Only /videos* URLs are handled in-place — trending/shorts render
// different templates and are cheaper to just navigate to.
function isSpaUrl(href) {
try {
var u = new URL(href, window.location.origin);
if (u.origin !== window.location.origin) return false;
return u.pathname.replace(/\/+$/, '') === '/videos';
} catch (e) { return false; }
}
function setActiveChip(url) {
var target = new URL(url, window.location.origin);
bar.querySelectorAll('.yt-chip').forEach(function (a) {
var u;
try { u = new URL(a.href, window.location.origin); } catch (e) { return; }
var same = u.pathname === target.pathname && u.search === target.search;
a.classList.toggle('active', same);
}); });
}
// No per-image sizing needed — CSS `object-fit: cover` on
// `.yt-video-thumb img` gives every card the same visible image size.
function reinitGrid(_root) {}
var currentReq = 0;
async function swapTo(url, pushHist) {
var reqId = ++currentReq;
var container = document.getElementById('filter-content');
if (!container) { window.location.href = url; return; }
container.style.opacity = '0.5';
try {
var resp = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html' }, credentials: 'same-origin' });
if (reqId !== currentReq) return; // a newer click superseded us
if (!resp.ok) { window.location.href = url; return; }
var html = await resp.text();
var doc = new DOMParser().parseFromString(html, 'text/html');
var next = doc.getElementById('filter-content');
if (!next) { window.location.href = url; return; }
container.innerHTML = next.innerHTML;
reinitGrid(container);
setActiveChip(url);
if (pushHist !== false) history.pushState({ filterUrl: url }, '', url);
// Update <title> in case the new page has a different one
var t = doc.querySelector('title');
if (t) document.title = t.textContent;
window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
} catch (e) {
window.location.href = url;
return;
} finally {
container.style.opacity = '';
}
}
// Delegate: any .yt-chip click within the bar (event delegation survives
// DOM swaps of the grid, though the bar itself isn't swapped).
bar.addEventListener('click', function (e) {
var a = e.target.closest('a.yt-chip');
if (!a || !bar.contains(a)) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var href = a.getAttribute('href');
if (!href || !isSpaUrl(href)) return;
e.preventDefault();
swapTo(href, true);
});
window.addEventListener('popstate', function (e) {
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
});
})();
/* ─── Cursor-paginated infinite scroll ───────────────────────────────────
The server renders the first page of 60 cards. Everything after that is
fetched here, one 24-card page at a time, when the #feed-sentinel scrolls
into view. The sentinel sits right after the grid, so we never fetch
until the user has actually scrolled past the first page.
State lives on #feed-sentinel.dataset so a SPA filter-swap can reset it
without having to rebind listeners. Fallback: an always-visible "Load
more" button that users without IntersectionObserver can click. */
(function () {
const sentinel = document.getElementById('feed-sentinel');
const grid = document.querySelector('.yt-video-grid');
const btnWrap = document.getElementById('feed-loadmore-wrap');
const btn = document.getElementById('feed-loadmore');
if (!sentinel || !grid) return;
let cursor = ''; // null token = next request is the first continuation page
let busy = false;
let done = false;
const filter = sentinel.dataset.feedFilter || 'all';
async function loadMore() {
if (busy || done) return;
busy = true;
try {
const url = new URL("{{ route('videos.feed.more') }}", window.location.origin);
url.searchParams.set('filter', filter);
if (cursor) url.searchParams.set('cursor', cursor);
const resp = await fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) throw new Error('bad status: ' + resp.status);
const data = await resp.json();
if (data.items) {
grid.insertAdjacentHTML('beforeend', data.items);
}
cursor = data.next_cursor || '';
done = !data.has_more;
} catch (e) {
// Reveal the manual Load More so a flaky network doesn't dead-end the user.
btnWrap.style.display = 'block';
} finally {
busy = false;
if (done) {
btnWrap.style.display = 'none';
sentinel.style.display = 'none';
}
}
}
btn?.addEventListener('click', loadMore);
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) loadMore();
}, { rootMargin: '400px 0px' });
io.observe(sentinel);
} else {
// No IntersectionObserver → surface the Load More fallback immediately.
btnWrap.style.display = 'block';
}
})(); })();
</script> </script>
@endsection @endsection
@@ -7,7 +7,12 @@
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id; $isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? ''); $renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot); $hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
$showBox = $hasDesc || $isVideoOwner; $llmEnabledVdb = $hasDesc && app(\App\Services\LlmLyricsService::class)->isEnabled();
// Optional pre-rendered block shown above the description inside the About
// panel. Only the `match` type passes it (the bout summary card); when it is
// unset every other type renders exactly as before.
$aboutPrepend = $aboutPrepend ?? null;
$showBox = $hasDesc || $isVideoOwner || $aboutPrepend !== null;
@endphp @endphp
@if ($showBox) @if ($showBox)
<style> <style>
@@ -36,6 +41,27 @@
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); } .vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
.vdb-show-more i { font-size:14px; transition:transform .2s ease; } .vdb-show-more i { font-size:14px; transition:transform .2s ease; }
.vdb-show-more.expanded i { transform:rotate(180deg); } .vdb-show-more.expanded i { transform:rotate(180deg); }
.vdb-translate-btn { display:inline-flex; align-items:center; gap:6px; background:none; border:1px solid var(--border-color); color:var(--text-primary); font-size:12px; font-weight:600; padding:5px 12px; border-radius:16px; cursor:pointer; margin-top:10px; transition:background .15s, color .15s; font-family:inherit; }
.vdb-translate-btn:hover { background:rgba(255,255,255,.08); }
.vdb-translate-btn.loading { opacity:.6; cursor:wait; }
.vdb-translate-btn.active { color:#3ea6ff; border-color:rgba(62,166,255,.4); }
.vdb-translate-btn svg { width:14px; height:14px; fill:currentColor; }
.vdb-desc-split { display:block; }
/* Line-paired view: each row = one original line + its translation, aligned on
the same baseline so skimming across the divider stays easy. */
.vdb-desc-split.has-translation #vdbDescShort { display:none; }
.vdb-translation-grid { display:grid; grid-template-columns:1fr 1fr; column-gap:16px; row-gap:4px; font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
.vdb-translation-meta { grid-column:1 / -1; font-size:11px; color:var(--text-secondary); margin:0 0 4px; text-transform:uppercase; letter-spacing:.4px; }
.vdb-tr-row { display:contents; }
.vdb-tr-cell { padding:4px 0; white-space:pre-wrap; }
.vdb-tr-cell.vdb-tr-orig { padding-right:16px; border-right:1px solid var(--border-color); }
.vdb-tr-cell.vdb-tr-trans { padding-left:4px; }
.vdb-tr-cell.vdb-tr-empty { min-height:1em; }
@media (max-width:768px) {
.vdb-translation-grid { grid-template-columns:1fr; row-gap:2px; }
.vdb-tr-cell.vdb-tr-orig { padding:4px 0 2px; border-right:none; }
.vdb-tr-cell.vdb-tr-trans { padding:0 0 10px 0; color:#9ad1ff; border-bottom:1px dashed var(--border-color); }
}
</style> </style>
<div class="vdb-wrap" id="vdbWrap"> <div class="vdb-wrap" id="vdbWrap">
@@ -51,6 +77,9 @@
</div> </div>
<div class="vdb-panel active" id="vdb-about"> <div class="vdb-panel active" id="vdb-about">
@if($aboutPrepend !== null)
{!! $aboutPrepend !!}
@endif
<div class="vdb-meta"> <div class="vdb-meta">
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span> <span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
<span>•</span> <span>•</span>
@@ -60,11 +89,21 @@
@if(isset($descriptionSlot)) @if(isset($descriptionSlot))
{!! $descriptionSlot !!} {!! $descriptionSlot !!}
@elseif($renderedDescription !== '') @elseif($renderedDescription !== '')
<div id="vdbDescSplit" class="vdb-desc-split">
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div> <div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
</div>
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button> <button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
@else @else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p> <p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif @endif
@if($llmEnabledVdb)
<button type="button" class="vdb-translate-btn" id="vdbTranslateBtn"
data-video-key="{{ $video->getRouteKey() }}"
data-csrf="{{ csrf_token() }}">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="vdb-translate-label">Translate</span>
</button>
@endif
</div> </div>
@if($isVideoOwner) @if($isVideoOwner)
@@ -176,5 +215,92 @@ window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function
}; };
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck); document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
window.addEventListener('load', window._vdbScheduleOverflowCheck); window.addEventListener('load', window._vdbScheduleOverflowCheck);
// ── Description translate ─────────────────────────────
// Lives on the About tab. Falls back to navigator.language for the target.
function _vdbTargetLang() {
return ((navigator.language || 'en').toLowerCase().split('-')[0]) || 'en';
}
window._vdbToggleTranslate = window._vdbToggleTranslate || async function (btn) {
const split = document.getElementById('vdbDescSplit');
const showMore = document.getElementById('vdbShowMore');
const label = btn.querySelector('.vdb-translate-label');
if (!split) return;
let grid = document.getElementById('vdbTranslationGrid');
const showTranslation = () => {
split.classList.add('has-translation');
if (grid) grid.style.display = 'grid';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
if (showMore) showMore.style.display = 'none';
};
const hideTranslation = () => {
split.classList.remove('has-translation');
if (grid) grid.style.display = 'none';
btn.classList.remove('active');
if (label) label.textContent = 'Translate';
if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
};
if (grid && grid.dataset.loaded === '1') {
split.classList.contains('has-translation') ? hideTranslation() : showTranslation();
return;
}
btn.classList.add('loading');
if (label) label.textContent = 'Translating…';
try {
const r = await fetch('/videos/' + btn.dataset.videoKey + '/description/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': btn.dataset.csrf, 'Accept': 'application/json' },
body: JSON.stringify({ target: _vdbTargetLang() })
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
const segs = Array.isArray(d.segments) ? d.segments : [];
if (!segs.length) throw new Error('Translation failed');
// Build the line-paired grid. Each original line and its translation are
// siblings in the same CSS grid, so a short line on one side still shares
// its row with its counterpart on the other side.
if (!grid) {
grid = document.createElement('div');
grid.id = 'vdbTranslationGrid';
grid.className = 'vdb-translation-grid';
split.appendChild(grid);
}
grid.innerHTML = '';
const meta = document.createElement('div');
meta.className = 'vdb-translation-meta';
meta.textContent = 'Translated → ' + (d.target || _vdbTargetLang()).toUpperCase();
grid.appendChild(meta);
segs.forEach(seg => {
const origText = (seg.original ?? '').toString();
const trText = (seg.translated ?? '').toString();
const o = document.createElement('div');
o.className = 'vdb-tr-cell vdb-tr-orig' + (origText.trim() === '' ? ' vdb-tr-empty' : '');
o.textContent = origText;
const t = document.createElement('div');
t.className = 'vdb-tr-cell vdb-tr-trans' + (trText.trim() === '' ? ' vdb-tr-empty' : '');
t.textContent = trText;
grid.appendChild(o);
grid.appendChild(t);
});
grid.dataset.loaded = '1';
showTranslation();
} catch (err) {
if (window.showToast) window.showToast(err.message || 'Translation failed', 'error');
if (label) label.textContent = 'Translate';
} finally {
btn.classList.remove('loading');
}
};
// Delegate so SPA swaps that re-insert #vdbWrap keep working.
document.addEventListener('click', function (e) {
const b = e.target.closest('#vdbTranslateBtn');
if (b) { e.preventDefault(); window._vdbToggleTranslate(b); }
});
</script> </script>
@endif @endif
@@ -0,0 +1,76 @@
@php
/*
* Match scoreboard entry partial. Builds the JSON payload the client
* script consumes ($msbState) plus a rich $vs payload for the VS card.
* Every value is null-safe — missing fields become empty strings so
* the CSS :empty rules hide their elements (no placeholder text).
*/
$hd = $video->sportsMatch?->headerData();
/* ── Discipline / subtitle text ───────────────────────────────────── */
$subtitlePieces = array_filter([
$hd['championship'] ?? null,
$hd['weight_category'] ?? null,
!empty($hd['court']) ? ('Tatami '.$hd['court']) : null,
], fn ($p) => is_string($p) && trim($p) !== '');
/* ── Playback-time-driven data (rounds + points) ─────────────────── */
$msbRounds = $video->matchRounds()
->orderBy('round_number')
->get(['round_number', 'name', 'start_time_seconds'])
->map(fn ($r) => [
'n' => (int) $r->round_number,
'name' => (string) ($r->name ?? ''),
'start' => (float) ($r->start_time_seconds ?? 0),
])->values()->all();
$msbPoints = $video->matchPoints()
->orderBy('timestamp_seconds')
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
->flatMap(function ($p) {
// "Both" events (if ever recorded) emit two feed rows — one per corner.
$rows = [];
$rows[] = [
't' => (float) $p->timestamp_seconds,
'action' => (string) ($p->action ?? 'Point'),
'pts' => (int) $p->points,
'side' => $p->competitor === 'both' ? 'red' : $p->competitor,
'sr' => (int) ($p->score_red ?? 0),
'sb' => (int) ($p->score_blue ?? 0),
];
if ($p->competitor === 'both') {
$rows[] = $rows[0];
$rows[1]['side'] = 'blue';
$rows[1]['t'] = (float) $p->timestamp_seconds + 0.001;
}
return $rows;
})->values()->all();
$msbState = [
'sport' => $hd['sport'] ?? null,
'discipline' => null,
'subtitle' => implode(' · ', $subtitlePieces),
'red' => [
'name' => $hd['red']['name'] ?? null,
'flag' => $hd['red']['flag'] ?? null,
'club' => $hd['red']['club'] ?? null,
'club_logo' => !empty($hd['red']['club_logo'])
? route('media.thumbnail', $hd['red']['club_logo']) : null,
],
'blue' => [
'name' => $hd['blue']['name'] ?? null,
'flag' => $hd['blue']['flag'] ?? null,
'club' => $hd['blue']['club'] ?? null,
'club_logo' => !empty($hd['blue']['club_logo'])
? route('media.thumbnail', $hd['blue']['club_logo']) : null,
],
'rounds' => $msbRounds ?: [['n' => 1, 'name' => '', 'start' => 0]],
'points' => $msbPoints,
'defaults' => is_array($hd['scoreboard_defaults'] ?? null) ? $hd['scoreboard_defaults'] : [],
];
@endphp
@include('videos.partials.match.scoreboard.styles')
@include('videos.partials.match.scoreboard.overlay')
@include('videos.partials.match.scoreboard.script', ['msbState' => $msbState])
@@ -0,0 +1,99 @@
{{--
Overlay markup. The score bar + timeline blocks are the design's exact
verbatim inline-styled markup from drafts/score-bar.html — only the
{{ }} data holes have been filled and `data-msb` attributes added for
the JS. `data-msb-part` attributes let the gear-menu toggles hide any
part with CSS `display: none` without touching the inline styles.
--}}
<div class="msb-root" id="msbRoot" data-video-id="{{ $video->id }}">
<div class="msb-scrim"></div>
{{-- Fixed-width scale layer (1150 px design canvas). JS scales it to
fit the actual player, so the score bar keeps its pixel-perfect
proportions at any player size. --}}
<div class="msb-scale" id="msbScale">
{{-- Match info (top-left) --}}
<div class="msb-info" data-msb="info" data-msb-part="info">
<div class="rule"></div>
<div class="txt">
<div class="disc" data-msb="disc"></div>
<div class="sub" data-msb="sub"></div>
</div>
</div>
{{-- Live scoring feed (top-right) --}}
<div class="msb-feed" data-msb="feedWrap" data-msb-part="feed">
<div class="hdr"><span class="dot"></span><span>Live scoring</span></div>
<div class="list" data-msb="feed"></div>
</div>
{{-- ═══════════════════════════════════════════════════════════════════
SCORE BAR — verbatim from drafts/score-bar.html (design handoff).
Only {{ }} holes replaced with Blade data + data-msb hooks.
═══════════════════════════════════════════════════════════════ --}}
<div data-msb-part="scorebar" style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;pointer-events:none">
{{-- RED / AKA --}}
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25" aria-hidden="true"></div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span data-msb="redFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)" aria-hidden="true"></span>
<span data-msb="redName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['red']['name'] ?: 'RED' }}</span>
</div>
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span data-msb="redClubName" data-msb-part="clubs" style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['red']['club'] ?? '' }}</span>
<span data-msb="redSenshu" data-msb-part="penalties" style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#ffdf6b;color:#3a2a00;font-weight:700;flex:none">SENSHU</span>
</div>
</div>
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<span data-msb="redCorner" style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
<span data-msb="redScore" style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
</div>
</div>
</div>
{{-- CENTER: round + clock --}}
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
<div data-msb="roundLabel" style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
<div data-msb="clock" style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
<div style="transform:skewX(9deg);display:flex;gap:4px">
<span style="width:20px;height:3px;background:#e8534a"></span>
<span style="width:20px;height:3px;background:#e8534a"></span>
<span style="width:20px;height:3px;background:#3a3734"></span>
</div>
</div>
{{-- BLUE / AO — exact mirror --}}
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
<span data-msb="blueCorner" style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
<span data-msb="blueScore" style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
</div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span data-msb="blueName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['name'] ?: 'BLUE' }}</span>
<span data-msb="blueFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)" aria-hidden="true"></span>
</div>
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span data-msb="bluePenalty" data-msb-part="penalties" style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
<span data-msb="blueClubName" data-msb-part="clubs" style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['club'] ?? '' }}</span>
</div>
</div>
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25" aria-hidden="true"></div>
</div>
</div>
</div>
{{-- POINT TIMELINE — one segment per point event, red/blue, #2a2724 when empty --}}
<div data-msb="timeline" data-msb-part="timeline" style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
@for ($i = 0; $i < 14; $i++)
<div style="flex:1;background:#2a2724"></div>
@endfor
</div>
</div>{{-- /.msb-scale --}}
</div>
@@ -0,0 +1,332 @@
{{--
Scoreboard runtime — one IIFE, deferred to DOMContentLoaded.
Consumes the JSON blob `MSB_STATE` (produced by index.blade.php) and:
• updates score/round/clock/feed/timeline every time the video's
currentTime changes (memoised so the ticker animation only replays
when the visible entries change);
• injects a "Scoreboard" section into #ytpSettingsPanel with a toggle
row for each part;
• persists toggle state in localStorage['msb_prefs'];
• drives the VS intro card (first play of a session, skippable).
--}}
<script>
(function () {
const MSB_STATE = @json($msbState);
const VIDEO_ID = @json($video->id);
const LS_KEY = 'msb_prefs';
const RUN = function () {
const root = document.getElementById('msbRoot');
if (!root) return;
const $ = (name) => document.querySelector('[data-msb="' + name + '"]');
const player = () => document.getElementById('videoPlayer');
// ── Preferences (per-user, persisted) ─────────────────────────────
const APP_DEFAULTS = { scorebar: true, feed: true, info: true, timeline: true, clubs: true, flags: true, penalties: false };
const VIDEO_DEFAULTS = MSB_STATE.defaults || {};
const DEFAULTS = Object.assign({}, APP_DEFAULTS, VIDEO_DEFAULTS);
let prefs;
try { prefs = Object.assign({}, DEFAULTS, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
catch (e) { prefs = { ...DEFAULTS }; }
const savePrefs = () => { try { localStorage.setItem(LS_KEY, JSON.stringify(prefs)); } catch (e) {} };
const applyPrefsClasses = () => {
for (const key of Object.keys(APP_DEFAULTS)) {
document.body.classList.toggle('msb-off-' + key, !prefs[key]);
}
// Signal to the scrim which parts are visible
root.classList.toggle('msb-has-scorebar', !!prefs.scorebar);
root.classList.toggle('msb-has-feed', !!prefs.feed);
root.classList.toggle('msb-has-info', !!prefs.info);
root.classList.toggle('msb-has-timeline', !!prefs.timeline);
};
// ── Corner-label localisation ─────────────────────────────────────
const CORNER_LABELS = {
redblue: { red: 'Red', blue: 'Blue', disc: '' },
karate: { red: 'Aka', blue: 'Ao', disc: 'Karate Kumite' },
taekwondo: { red: 'Hong', blue: 'Chung', disc: 'Taekwondo Kyorugi' },
};
function labelSet() {
const sport = (MSB_STATE.sport || 'karate').toLowerCase();
if (sport === 'taekwondo' || sport === 'taekwondo_kyorugi') return CORNER_LABELS.taekwondo;
if (sport === 'karate' || sport === 'karate_kumite') return CORNER_LABELS.karate;
return CORNER_LABELS.redblue;
}
function pointLabel(p) {
const sport = (MSB_STATE.sport || '').toLowerCase();
if (sport.startsWith('taekwondo')) {
if (p.pts >= 4) return 'Turning body';
if (p.pts === 3) return 'Head kick';
if (p.pts === 2) return 'Body kick';
if (p.pts === 1) return 'Punch';
}
if (p.pts === 3) return 'Ippon';
if (p.pts === 2) return 'Waza-ari';
if (p.pts === 1) return 'Yuko';
return p.action || 'Point';
}
const colorFor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
// ── Fixed-value paints (only once per load) ───────────────────────
function paintFixed() {
const labels = labelSet();
const setText = (name, val, hideIfEmpty = true) => {
const el = $(name); if (!el) return;
el.textContent = val || '';
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
};
// When a club_logo path is set, drop a real <img> into the empty
// placeholder box. When it isn't, the box stays empty — the
// inline-styled background keeps the slot visible without
// showing the stale "CLUB LOGO" text that used to render there.
const setImage = (name, path) => {
const el = $(name); if (!el) return;
if (!path) return;
el.textContent = '';
el.style.background = 'rgba(0,0,0,.32)';
const img = document.createElement('img');
img.src = path; img.alt = '';
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
el.appendChild(img);
};
// When an ISO2 is set, point the flag box's background-image at
// the self-hosted flag-icons SVG. When it isn't, the empty box
// stays empty — no "FLAG" placeholder text.
const setFlag = (name, iso2) => {
const el = $(name); if (!el) return;
const code = (iso2 || '').toLowerCase();
if (!/^[a-z]{2}$/.test(code) || code === 'xx') return;
el.textContent = '';
el.style.background = '#000';
el.style.backgroundImage = 'url({{ asset('vendor/flag-icons/flags/4x3') }}/' + code + '.svg)';
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = 'center';
};
// Identity — default to karate discipline label when sport unknown
setText('disc', labels.disc || MSB_STATE.discipline || '');
setText('sub', MSB_STATE.subtitle || '');
// Hide the whole info block (rule + text) when both rows are empty
const infoBlock = document.querySelector('[data-msb="info"]');
if (infoBlock) {
const empty = !$('disc').textContent && !$('sub').textContent;
infoBlock.style.display = empty ? 'none' : '';
}
// Athletes — brief §2: "no athlete name → show the corner label
// ('RED' / 'BLUE') in the name slot" (always uppercase, not the
// sport-flavoured AKA/AO which would collide with the corner label
// that already sits above the numeral).
setText('redName', MSB_STATE.red.name || 'RED');
setText('blueName', MSB_STATE.blue.name || 'BLUE');
setText('redClubName', MSB_STATE.red.club || '');
setText('blueClubName', MSB_STATE.blue.club || '');
setText('redCorner', labels.red.toUpperCase(), false);
setText('blueCorner', labels.blue.toUpperCase(), false);
setFlag('redFlag', MSB_STATE.red.flag);
setFlag('blueFlag', MSB_STATE.blue.flag);
setImage('redClub', MSB_STATE.red.club_logo);
setImage('blueClub', MSB_STATE.blue.club_logo);
}
// ── Playback-time-driven render (memoised) ────────────────────────
function currentRound(t) {
const rs = MSB_STATE.rounds || [];
if (!rs.length) return { n: 1, name: '', start: 0 };
let cur = rs[0];
for (const r of rs) { if (t >= (r.start || 0)) cur = r; }
return cur;
}
function pointsUpTo(t) {
return (MSB_STATE.points || []).filter(p => p.t <= t + 0.001);
}
function fmtClock(sec) {
sec = Math.max(0, Math.floor(sec || 0));
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
}
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
let lastRound = '', lastClock = '', lastScore = '', lastFeed = '', lastTl = '';
function updateForTime(t) {
const round = currentRound(t);
const totalRounds = Math.max(1, (MSB_STATE.rounds || []).length);
const sport = (MSB_STATE.sport || '').toLowerCase();
// Round label
let roundText = round.name || '';
if (sport.startsWith('taekwondo')) roundText = 'Round ' + round.n + ' / ' + totalRounds;
else if (sport.startsWith('karate')) roundText = roundText || 'Kumite';
if (roundText !== lastRound) { $('roundLabel').textContent = roundText; lastRound = roundText; }
// Clock = time since current round's start (or since t=0 if no round starts)
const inRound = Math.max(0, t - (round.start || 0));
const clockStr = fmtClock(inRound);
if (clockStr !== lastClock) { $('clock').textContent = clockStr; lastClock = clockStr; }
const seen = pointsUpTo(t);
const last = seen[seen.length - 1];
const red = last ? last.sr : 0;
const blue = last ? last.sb : 0;
const sSig = red + '|' + blue;
if (sSig !== lastScore) {
$('redScore').textContent = red;
$('blueScore').textContent = blue;
lastScore = sSig;
}
// Timeline segments across the duration — writes into the design's
// verbatim inline-styled container (14 pre-rendered <div>s).
const SEG_COUNT = Math.max(14, (MSB_STATE.points || []).length);
const tlSig = SEG_COUNT + '|' + seen.map(p => p.side[0]).join('');
if (tlSig !== lastTl) {
const tl = $('timeline');
if (tl) {
let html = '';
for (let i = 0; i < SEG_COUNT; i++) {
const p = seen[i];
const color = p ? colorFor(p.side) : '#2a2724';
html += '<div style="flex:1;background:' + color + '"></div>';
}
tl.innerHTML = html;
}
lastTl = tlSig;
}
// Ticker: last 4, newest first
const last4 = seen.slice(-4).reverse();
const fSig = last4.map(p => p.t + ':' + p.side + ':' + p.pts).join(',');
if (fSig !== lastFeed) {
const feed = $('feed');
if (feed) {
feed.innerHTML = last4.map(p => {
const col = colorFor(p.side);
return '<div class="entry msb-new" style="border-color:' + col + '">'
+ '<span class="ts">' + fmtClock(p.t) + '</span>'
+ '<span class="name">' + esc(pointLabel(p)) + '</span>'
+ '<span class="pts" style="color:' + col + '">+' + p.pts + '</span>'
+ '</div>';
}).join('');
}
lastFeed = fSig;
}
}
// ── Gear-menu injection ───────────────────────────────────────────
// Custom row markup (per design): compact label + 26×14 track + 10 px
// bone knob. Nothing on the platform side changes.
function injectGearRows() {
const panel = document.getElementById('ytpSettingsPanel');
if (!panel) return false;
if (panel.dataset.msbInjected) return true;
const header = document.createElement('div');
header.className = 'msb-gear-header';
header.textContent = 'Scoreboard';
panel.appendChild(header);
const ROWS = [
['scorebar', 'Score bar'],
['feed', 'Live scoring feed'],
['info', 'Match info'],
['timeline', 'Point timeline'],
['clubs', 'Club logos'],
['flags', 'Country flags'],
['penalties', 'Penalties'],
];
for (const [key, label] of ROWS) {
const row = document.createElement('button');
row.type = 'button';
row.className = 'msb-gear-row' + (prefs[key] ? ' on' : '');
row.dataset.msbToggle = key;
row.innerHTML =
'<span>' + label + '</span>' +
'<span class="msb-gear-track"><span class="msb-gear-knob"></span></span>';
row.addEventListener('click', (e) => {
e.stopPropagation();
prefs[key] = !prefs[key];
row.classList.toggle('on', prefs[key]);
savePrefs(); applyPrefsClasses();
});
panel.appendChild(row);
}
panel.dataset.msbInjected = '1';
// Design: gear icon turns red while the menu is open.
const sync = () => document.body.classList.toggle('msb-menu-open', panel.classList.contains('open'));
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['class'] });
sync();
return true;
}
function tryInject(attemptsLeft) {
if (injectGearRows()) return;
if (attemptsLeft > 0) setTimeout(() => tryInject(attemptsLeft - 1), 200);
}
tryInject(30);
// ── Overlay scaling to a fixed 1150 px design canvas ──────────────
// Keeps the score bar, ticker, and identity at their pixel-perfect
// proportions no matter how wide the actual player is.
function initScale() {
// Scale against #videoContainer (the .ytp inner box) — that's the
// element that gets squeezed when the fullscreen highlights drawer
// opens (CSS: `.ytp-wrap.ytp-fullscreen.hl-drawer-open .ytp { width:
// calc(100% - var(--hl-drawer-w)) }`). Using #ytpWrap here would
// keep the overlay at full-screen width and clip its right edge
// behind the drawer.
const wrap = document.getElementById('videoContainer') || document.getElementById('ytpWrap');
const layer = document.getElementById('msbScale');
if (!wrap || !layer) return;
const DESIGN_W = 1150;
const update = () => {
const w = wrap.clientWidth || 1;
const h = wrap.clientHeight || 1;
const s = w / DESIGN_W;
layer.style.transform = 'scale(' + s + ')';
layer.style.height = (h / s) + 'px';
// "small player" auto-hides identity + feed. Skip it in
// fullscreen — the highlights drawer squeezes the container
// width but the user still wants to see the live feed next
// to the drawer.
const inFs = !!(document.fullscreenElement || document.webkitFullscreenElement);
document.body.classList.toggle('msb-small', !inFs && w < 700);
};
// Re-run when entering/exiting fullscreen so the msb-small flag
// updates immediately.
document.addEventListener('fullscreenchange', update);
document.addEventListener('webkitfullscreenchange', update);
update();
if (typeof ResizeObserver === 'function') {
new ResizeObserver(update).observe(wrap);
}
window.addEventListener('resize', update);
}
// ── Wire everything ───────────────────────────────────────────────
applyPrefsClasses();
paintFixed();
initScale();
function attachPlayer() {
const v = player();
if (!v) { setTimeout(attachPlayer, 250); return; }
const tick = () => updateForTime(v.currentTime || 0);
v.addEventListener('timeupdate', tick);
v.addEventListener('seeked', tick);
v.addEventListener('loadedmetadata', tick);
v.addEventListener('ratechange', tick);
tick();
}
attachPlayer();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', RUN);
} else {
setTimeout(RUN, 0);
}
})();
</script>
@@ -0,0 +1,142 @@
{{--
Karate/Taekwondo match scoreboard overlay — all CSS in one place.
Class prefix: msb- (match scoreboard). Nothing else in the codebase uses it.
Everything lives inside #msbRoot which is `position:absolute; inset:0; pointer-events:none`
and is NOT z-indexed on purpose, so #ytpControls (emitted after the slot) paints on top.
--}}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ────────────────────────────── keyframes ────────────────────────────── */
@keyframes msbRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes msbPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
/* ────────────────────────────── root layer ─────────────────────────────
No z-index on the root: source order inside #ytpWrap places the video
controls AFTER the overlay slot, so they paint on top and stay clickable.
------------------------------------------------------------------------ */
.msb-root {
position: absolute; inset: 0;
pointer-events: none;
font-family: 'Barlow Condensed', sans-serif;
color: #efe9e0;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
.msb-root * { box-sizing: border-box; }
/*
* Fixed 1150 px design canvas. `msb-scale` is positioned at (0, 0), sized
* to 1150 px wide, and JS-scaled by `transform: scale(playerWidth / 1150)`
* so every child element (positioned in that 1150 px coord space) shrinks
* or grows proportionally with the player. Height is set at runtime.
*/
.msb-scale {
position: absolute; top: 0; left: 0;
width: 1150px;
transform-origin: top left;
/* transform + height set by JS */
}
/* Scrim disabled — every scoreboard text element has its own text-shadow
for legibility, so we don't tint the video at all. */
.msb-scrim { display: none; }
/* ────────────────────────────── match info (top-left) ────────────────── */
.msb-info { position: absolute; top: 22px; left: 26px; display: flex; align-items: stretch; gap: 12px; }
.msb-info .rule { width: 4px; background: linear-gradient(#e8534a, #7c1d18); }
.msb-info .txt { display: flex; flex-direction: column; gap: 2px; }
.msb-info .disc {
font-family: 'Zen Old Mincho', serif; font-size: 15px; letter-spacing: .34em;
color: #efe9e0; text-transform: uppercase;
}
.msb-info .sub {
font-size: 13px; letter-spacing: .28em; color: #cfc9c1;
text-transform: uppercase; text-shadow: 0 1px 6px rgba(0,0,0,.9);
}
/* Auto-hide row when its text is empty (server empties data-msb-empty) */
.msb-info [data-msb-empty] { display: none; }
/* ────────────────────────────── live scoring ticker (top-right) ──────── */
/* top:56px clears the existing Highlights toggle chip that sits at top:8/right:8 (h=24) */
.msb-feed { position: absolute; top: 56px; right: 24px; width: 262px; display: flex; flex-direction: column; gap: 7px; }
.msb-feed .hdr {
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
font-size: 12px; letter-spacing: .3em; color: #d5cfc7; text-transform: uppercase;
text-shadow: 0 1px 6px rgba(0,0,0,.9);
}
.msb-feed .hdr .dot {
width: 7px; height: 7px; border-radius: 50%;
background: #e8534a; animation: msbPulse 1.6s infinite;
}
.msb-feed .list { display: flex; flex-direction: column; gap: 7px; }
.msb-feed .entry {
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
padding: 7px 10px;
background: rgba(10,10,12,.62); backdrop-filter: blur(6px);
border-right: 3px solid transparent;
}
.msb-feed .entry.msb-new { animation: msbRiseIn .45s ease both; }
.msb-feed .entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; }
.msb-feed .entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; }
.msb-feed .entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
/*
* Score bar + timeline visuals live entirely as inline styles on the verbatim
* markup from drafts/score-bar.html. Nothing here targets those elements
* except the visibility toggles below (via data-msb-part), so the design's
* exact pixel measurements are never overridden.
*/
/* ────────────────────────────── visibility toggles ──────────────────── */
/* Body-level classes hide any part with the matching data-msb-part attr
without touching its inline styles. Master-off hides everything. */
body.msb-off-scorebar [data-msb-part="scorebar"],
body.msb-off-timeline [data-msb-part="timeline"],
body.msb-off-info [data-msb-part="info"],
body.msb-off-feed [data-msb-part="feed"],
body.msb-off-clubs [data-msb-part="clubs"],
body.msb-off-flags [data-msb-part="flags"],
body.msb-off-penalties [data-msb-part="penalties"] { display: none !important; }
/* ────────────────────────────── responsive ──────────────────────────── */
/* Below ~700 px actual player width the identity + ticker disappear
(brief §7). The `msb-small` class is toggled by JS off ResizeObserver. */
body.msb-small .msb-info,
body.msb-small .msb-feed { display: none !important; }
/* ────────────────────────────── injected gear rows ──────────────────── */
/* Matches the design prototype's custom look (26×14 track, 10 px bone knob),
scoped so it never touches the platform's other rows. */
.msb-gear-header {
padding: 10px 14px;
font-size: 11px; letter-spacing: .3em; color: #8f8a83; text-transform: uppercase;
border-top: 1px solid rgba(255,255,255,.1); margin-top: 4px;
font-family: 'Barlow Condensed', sans-serif;
}
.msb-gear-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 9px 14px;
background: transparent; border: 0; width: 100%; cursor: pointer;
font-family: 'Barlow Condensed', sans-serif;
font-size: 14px; letter-spacing: .14em; color: #efe9e0;
text-transform: uppercase; text-align: left;
transition: background .15s ease;
}
.msb-gear-row:hover { background: rgba(255,255,255,.07); }
.msb-gear-track {
position: relative; width: 26px; height: 14px; flex: none;
background: #3a3734; transition: background .18s ease;
}
.msb-gear-knob {
position: absolute; top: 2px; left: 2px;
width: 10px; height: 10px; background: #efe9e0;
transition: left .18s ease;
}
.msb-gear-row.on .msb-gear-track { background: #e8534a; }
.msb-gear-row.on .msb-gear-knob { left: 14px; }
/* Turn the gear icon red while the settings panel is open — matches design */
body.msb-menu-open #ytpSettingsBtn svg { fill: #e8534a; }
</style>
@@ -0,0 +1,80 @@
{{-- ══════════════════════════════════════════════════════════════════════
VS OG — 1200×630 standalone page consumed by Browsershot for the
social-share preview image. Renders the exact same .vs-mini
composition used on video cards, but at fixed 1200×630 with
animations disabled (via .vs-mini-static) so the screenshot always
captures the final composed frame.
Public but noindex — only invoked by the internal ogImage()
controller through a loopback HTTP request from Browsershot.
════════════════════════════════════════════════════════════════════════ --}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OG Preview</title>
<meta name="robots" content="noindex,nofollow">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
{{-- Force http:// on the flag CSS URL — Laravel's asset() caches the
scheme on first use (https from AppServiceProvider::forceScheme) and
URL::forceScheme('http') in the controller can't override that
cache. Chromium hits this endpoint via loopback where https isn't
reachable, so we build the URL ourselves. --}}
<link rel="stylesheet" href="http://{{ request()->getHost() }}/vendor/flag-icons/css/flag-icons.min.css">
<style>
/* Canvas is a true 16:9 at 1200×675 so the 1920×1080 vs-mini stage
maps cleanly with scale 0.625 (no overflow on either axis). WhatsApp/
Facebook accept any aspect ratio for og:image; 1200×675 is inside
their recommended range and preserves the full VS composition. */
html, body {
margin: 0; padding: 0;
width: 1200px; height: 675px;
background: #050507;
overflow: hidden;
}
.og-frame {
position: relative;
width: 1200px; height: 675px;
overflow: hidden;
}
/* Same wrapper the vs-mini expects — .yt-video-thumb is what the mini's
`position: absolute; inset: 0` anchors to, and its aspect drives fit(). */
.og-frame .yt-video-thumb {
position: relative;
width: 100%; height: 100%;
overflow: hidden;
background: #050507;
}
</style>
</head>
<body>
<div class="og-frame">
<div class="yt-video-thumb">
{{-- vs-mini reads $video->sportsMatch->headerData() to compose. --}}
@include('components.partials.vs-mini', ['video' => $video])
</div>
</div>
<script>
// Force the mini into its final composed state — no animation-delay
// window, no ResizeObserver races. We also inline the correct scale for
// a 1200×630 frame (1200/1920 = 0.625) so we don't depend on the fit()
// callback firing before Browsershot captures.
(function () {
const el = document.querySelector('.vs-mini');
if (!el) return;
el.classList.add('vs-mini-static', 'vs-mini-fit');
el.style.setProperty('--vs-mini-scale', String(1200 / 1920));
// Signal readiness for Browsershot's waitUntilNetworkIdle to observe
// once webfonts are done rendering.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => { window.__vsOgReady = true; });
} else {
window.__vsOgReady = true;
}
})();
</script>
</body>
</html>
@@ -0,0 +1,561 @@
{{-- ══════════════════════════════════════════════════════════════════════
VS INTRO SCREEN — arena card overlaid on the first 5 s of the match.
Rendered as a FIXED 1920×1080 (landscape) or 1080×1920 (portrait)
canvas that is transform-scaled to fit the parent player box. Every
child uses ABSOLUTE PIXELS on that canvas so the layout is identical
at every zoom level and never scales off the viewport width.
══════════════════════════════════════════════════════════════════════ --}}
@php
$vsRed = $hd['red'] ?? null;
$vsBlue = $hd['blue'] ?? null;
$vsShow = $hd && (($vsRed['name'] ?? null) || ($vsBlue['name'] ?? null));
$vsFlagBg = fn(?string $c) => (($c = strtolower(trim((string) $c))) !== '' ? $c : 'xx');
$vsCountryName = fn (?string $code) => \App\Data\Countries::name($code);
$vsImg = fn(?string $path) => $path ? route('media.thumbnail', $path) : null;
// Every placeholder from the artifact renders regardless of whether the
// data exists — CSS `.vs-nullable:empty { display: none }` hides any
// element that came back empty, so the layout adapts but the DOM shape
// matches the original design file 1:1.
$vsStr = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
$vsEvent = $vsStr($hd['championship'] ?? '');
// Sub-header stage between the two yellow lines above the weight class.
// Sourced from headerData().stage (championship_name → division → format).
$vsStage = $vsStr($hd['stage'] ?? '');
$vsWeight = $vsStr($hd['weight_category'] ?? '');
$vsMatchNo = $vsStr($hd['match_number'] ?? '');
$vsCourt = $vsStr($hd['court'] ?? '');
$vsRef = $vsStr($hd['referee']['name'] ?? '');
$vsRefFlag = $vsStr($hd['referee']['flag'] ?? '');
$vsVenue = $vsStr($hd['venue']['name'] ?? '');
// Extra per-fighter chips from the artifact (record / rank / stats).
// headerData() doesn't expose them today; fall back to the raw
// participants JSON so the placeholders wire up the moment the fields
// start being written by the uploader form.
$vsParts = $video->sportsMatch?->participants ?? [];
$vsRedRecord = $vsStr($vsParts['p2_record'] ?? '');
$vsRedRank = $vsStr($vsParts['p2_rank'] ?? '');
$vsRedStats = $vsStr($vsParts['p2_stats'] ?? '');
$vsBlueRecord = $vsStr($vsParts['p1_record'] ?? '');
$vsBlueRank = $vsStr($vsParts['p1_rank'] ?? '');
$vsBlueStats = $vsStr($vsParts['p1_stats'] ?? '');
// Fighter identity strings.
$vsRedName = $vsStr($vsRed['name'] ?? '');
$vsBlueName = $vsStr($vsBlue['name'] ?? '');
$vsRedClub = $vsStr($vsRed['club'] ?? '');
$vsBlueClub = $vsStr($vsBlue['club'] ?? '');
$vsRedFlag = $vsStr($vsRed['flag'] ?? '');
$vsBlueFlag = $vsStr($vsBlue['flag'] ?? '');
$vsRedLogo = $vsImg($vsRed['club_logo'] ?? null);
$vsBlueLogo = $vsImg($vsBlue['club_logo'] ?? null);
@endphp
@if ($vsShow)
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
<div id="vsScreen" class="vs-screen" role="dialog" aria-label="Match introduction">
<button type="button" class="vs-skip" id="vsSkip" aria-label="Skip intro">
Skip <span id="vsSkipTimer">6</span>
</button>
{{-- Fixed 1920×1080 (or 1080×1920 in portrait) canvas.
JS sets --vs-scale on #vsScreen to fit whichever parent this sits in. --}}
<div class="vs-stage">
{{-- RED side panel --}}
<div class="vs-panel vs-panel-red">
<div class="vs-panel-photo vs-panel-photo-red"
@if($vsImg($vsRed['headshot'] ?? null)) style="background-image:url('{{ $vsImg($vsRed['headshot']) }}')"@endif></div>
<div class="vs-panel-scrim vs-panel-scrim-red"></div>
<div class="vs-panel-gradient"></div>
</div>
{{-- BLUE side panel --}}
<div class="vs-panel vs-panel-blue">
<div class="vs-panel-photo vs-panel-photo-blue"
@if($vsImg($vsBlue['headshot'] ?? null)) style="background-image:url('{{ $vsImg($vsBlue['headshot']) }}')"@endif></div>
<div class="vs-panel-scrim vs-panel-scrim-blue"></div>
<div class="vs-panel-gradient"></div>
</div>
{{-- Center divider glow line --}}
<div class="vs-divider"></div>
{{-- ══ RED fighter identity (left) — every placeholder from the
original artifact is always in the DOM. Empty ones are hidden
by `.vs-nullable:empty { display: none }` in the CSS below. ══ --}}
<div class="vs-info vs-info-red">
<div class="vs-corner-tag vs-corner-tag-red">AKA · RED</div>
<div class="vs-flag-row vs-nullable-row">
<span class="vs-flag fi fi-{{ $vsFlagBg($vsRedFlag) }} vs-flag-slot {{ $vsRedFlag === '' ? 'vs-nullable-hide' : '' }}"></span>
<div class="vs-country vs-country-red vs-nullable">{{ $vsRedFlag !== '' ? strtoupper($vsCountryName($vsRedFlag) ?? $vsRedFlag) : '' }}</div>
</div>
<div class="vs-name vs-nullable">{{ $vsRedName }}</div>
<div class="vs-club-row vs-nullable-row">
<div class="vs-club-logo vs-club-logo-slot {{ $vsRedLogo ? '' : 'vs-nullable-hide' }}"
@if($vsRedLogo) style="background-image:url('{{ $vsRedLogo }}')"@endif></div>
<div class="vs-club vs-nullable">{{ $vsRedClub }}</div>
</div>
<div class="vs-chips-row vs-nullable-row">
<div class="vs-chip-fighter vs-nullable">{{ $vsRedRecord }}</div>
<div class="vs-chip-fighter vs-chip-fighter-gold vs-nullable">{{ $vsRedRank }}</div>
<div class="vs-chip-fighter vs-nullable">{{ $vsRedStats }}</div>
</div>
</div>
{{-- ══ BLUE fighter identity (right) ══ --}}
<div class="vs-info vs-info-blue">
<div class="vs-corner-tag vs-corner-tag-blue">AO · BLUE</div>
<div class="vs-flag-row vs-flag-row-r vs-nullable-row">
<span class="vs-flag fi fi-{{ $vsFlagBg($vsBlueFlag) }} vs-flag-slot {{ $vsBlueFlag === '' ? 'vs-nullable-hide' : '' }}"></span>
<div class="vs-country vs-country-blue vs-nullable">{{ $vsBlueFlag !== '' ? strtoupper($vsCountryName($vsBlueFlag) ?? $vsBlueFlag) : '' }}</div>
</div>
<div class="vs-name vs-nullable">{{ $vsBlueName }}</div>
<div class="vs-club-row vs-club-row-r vs-nullable-row">
<div class="vs-club-logo vs-club-logo-slot {{ $vsBlueLogo ? '' : 'vs-nullable-hide' }}"
@if($vsBlueLogo) style="background-image:url('{{ $vsBlueLogo }}')"@endif></div>
<div class="vs-club vs-nullable">{{ $vsBlueClub }}</div>
</div>
<div class="vs-chips-row vs-nullable-row">
<div class="vs-chip-fighter vs-nullable">{{ $vsBlueRecord }}</div>
<div class="vs-chip-fighter vs-chip-fighter-gold vs-nullable">{{ $vsBlueRank }}</div>
<div class="vs-chip-fighter vs-nullable">{{ $vsBlueStats }}</div>
</div>
</div>
{{-- Top: event + stage + weight (all three always in DOM) --}}
<div class="vs-top">
<div class="vs-top-event vs-nullable">{{ $vsEvent }}</div>
<div class="vs-top-stage-row {{ $vsStage === '' ? 'vs-nullable-hide' : '' }}">
<div class="vs-top-stage-line vs-top-stage-line-l"></div>
<div class="vs-top-stage">{{ $vsStage }}</div>
<div class="vs-top-stage-line vs-top-stage-line-r"></div>
</div>
<div class="vs-top-weight vs-nullable">{{ $vsWeight }}</div>
</div>
{{-- Center VS --}}
<div class="vs-center">
<div class="vs-word">VS
<div class="vs-shine-wrap"><div class="vs-shine"></div></div>
</div>
</div>
<div class="vs-flash"></div>
{{-- Bottom: match + court + referee chips. All three chips + both
diamond separators are always in the DOM; each chip hides when
its value is empty and each diamond hides when either neighbour
is hidden (via :has() below). --}}
<div class="vs-bottom">
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsMatchNo === '' ? '1' : '0' }}">
<span class="vs-chip-label">Match</span>
<span class="vs-chip-value vs-nullable">{{ $vsMatchNo }}</span>
</div>
<div class="vs-diamond vs-diamond-match-court"></div>
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsCourt === '' ? '1' : '0' }}">
<span class="vs-chip-label">Court</span>
<span class="vs-chip-value vs-nullable">{{ $vsCourt }}</span>
</div>
<div class="vs-diamond vs-diamond-court-ref"></div>
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsRef === '' ? '1' : '0' }}">
<span class="vs-chip-label">Referee</span>
<span class="vs-chip-value vs-chip-value-name vs-nullable">{{ $vsRef }}</span>
</div>
</div>
</div>
</div>
<style>
/* ═══════════════════════════════════════════════════════════════════════
Fixed 1920×1080 canvas, transform-scaled to fit its parent (.ytp
inside the player, or .vs-preview-stage on the preview page). Every
child dimension is in ABSOLUTE PIXELS on that canvas — never vw/vh —
so the layout is identical whether the box is 400 px or 4000 px wide.
JS writes --vs-scale via ResizeObserver on the parent.
═══════════════════════════════════════════════════════════════════════ */
#vsScreen.vs-screen {
position: absolute; inset: 0; z-index: 40;
background: #050507;
font-family: 'Barlow Condensed', sans-serif;
color: #e8e6e0;
overflow: hidden;
opacity: 1;
transition: opacity .35s ease;
--vs-scale: 1;
--vs-stage-w: 1920px;
--vs-stage-h: 1080px;
}
#vsScreen.vs-hide { opacity: 0; pointer-events: none; }
#vsScreen .vs-stage {
position: absolute;
left: 50%; top: 50%;
width: var(--vs-stage-w); height: var(--vs-stage-h);
transform: translate(-50%, -50%) scale(var(--vs-scale));
transform-origin: center center;
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
overflow: hidden;
will-change: transform;
}
/* Skip button lives OUTSIDE the scaled stage so it stays at real pixels
in the top-right of the video area at any container size. */
#vsScreen .vs-skip {
position: absolute; top: 12px; right: 14px; z-index: 50;
background: rgba(10,10,14,.72);
color: #fff; border: 1px solid rgba(255,255,255,.28);
padding: 6px 14px;
font: 700 12px/1 'Barlow Condensed', sans-serif;
letter-spacing: .22em; text-transform: uppercase;
cursor: pointer;
backdrop-filter: blur(6px);
transition: background .15s, border-color .15s;
}
#vsScreen .vs-skip:hover { background: rgba(10,10,14,.92); border-color: rgba(255,255,255,.55); }
#vsScreen .vs-skip #vsSkipTimer { display: inline-block; margin-left: 6px; opacity: .8; }
/* ══ EVERYTHING BELOW: absolute pixels on the 1920×1080 canvas ══ */
/* ── Side panels ── */
#vsScreen .vs-panel { position: absolute; overflow: hidden; }
#vsScreen .vs-panel-red {
inset: 0 auto 0 0; width: 56%;
background: oklch(0.28 0.09 25);
clip-path: polygon(0 0, 100% 0, 82% 100%, 0 100%);
animation: vsPanelL .9s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-panel-blue {
inset: 0 0 0 auto; width: 56%;
background: oklch(0.28 0.09 255);
clip-path: polygon(18% 0, 100% 0, 100% 100%, 0 100%);
animation: vsPanelR .9s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-panel-photo {
position: absolute; inset: 0;
background-size: cover; background-position: center;
animation: vsSlowDrift 18s ease-in-out infinite;
}
#vsScreen .vs-panel-photo-blue { animation-direction: reverse; }
#vsScreen .vs-panel-scrim-red { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(115deg, oklch(0.45 0.18 25 / 0.55) 0%, transparent 55%); }
#vsScreen .vs-panel-scrim-blue { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(245deg, oklch(0.45 0.15 255 / 0.55) 0%, transparent 55%); }
#vsScreen .vs-panel-gradient {
position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(to top, rgba(5,5,7,.95) 0%, rgba(5,5,7,.72) 22%, rgba(5,5,7,.30) 42%, transparent 62%),
linear-gradient(to bottom, rgba(5,5,7,.82) 0%, rgba(5,5,7,.35) 18%, transparent 32%);
}
/* ── Divider ── */
#vsScreen .vs-divider {
position: absolute; top: -6%; bottom: -6%; left: 50%;
width: 3px; margin-left: -1.5px;
transform: rotate(10.15deg);
background: linear-gradient(to bottom, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
pointer-events: none; filter: blur(1px);
}
/* ── Fighter identity blocks ── (absolute px on the 1920×1080 canvas) */
#vsScreen .vs-info {
position: absolute; z-index: 6; max-width: 44%;
display: flex; flex-direction: column; gap: 13px;
}
#vsScreen .vs-info-red { left: 43.2px; bottom: 118.8px; align-items: flex-start; animation: vsRiseUp .8s .7s cubic-bezier(.22,1,.36,1) both; }
#vsScreen .vs-info-blue { right: 43.2px; bottom: 118.8px; align-items: flex-end; text-align: right; animation: vsRiseUp .8s .85s cubic-bezier(.22,1,.36,1) both; }
#vsScreen .vs-corner-tag {
font: 800 21.6px/1 'Barlow Condensed', sans-serif;
letter-spacing: .35em; color: #fff;
padding: 5.4px 15.1px 5.4px 18.9px;
}
#vsScreen .vs-corner-tag-red { background: oklch(0.55 0.20 25); }
#vsScreen .vs-corner-tag-blue { background: oklch(0.50 0.16 255); }
#vsScreen .vs-flag-row { display: flex; align-items: center; gap: 15.1px; }
#vsScreen .vs-flag-row-r { flex-direction: row-reverse; }
#vsScreen .vs-flag {
width: 56.2px; height: auto; aspect-ratio: 4/3;
background-size: cover !important; background-position: center !important;
border: 1px solid rgba(255,255,255,.35);
box-shadow: 0 4px 18px rgba(0,0,0,.6);
display: inline-block; line-height: 0;
}
#vsScreen .vs-country { font: 700 32.4px/1 'Barlow Condensed', sans-serif; letter-spacing: .28em; }
#vsScreen .vs-country-red { color: oklch(0.85 0.05 25); }
#vsScreen .vs-country-blue { color: oklch(0.85 0.05 255); }
#vsScreen .vs-name {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 71.3px; line-height: .95;
text-transform: uppercase; color: #fff;
text-shadow: 0 6px 30px rgba(0,0,0,.8);
}
#vsScreen .vs-club-row { display: flex; align-items: center; gap: 13px; margin-top: 4.3px; }
#vsScreen .vs-club-row-r { flex-direction: row-reverse; }
#vsScreen .vs-club-logo {
width: 69.1px; height: 69.1px;
border-radius: 50%;
background: rgba(255,255,255,.06);
background-size: cover; background-position: center;
border: 1px solid rgba(255,255,255,.2);
}
#vsScreen .vs-club {
font: 600 30.2px/1.1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
color: rgba(232,230,224,.9);
}
/* Fighter chips row (Record / Rank / Stats) — from the original artifact. */
#vsScreen .vs-chips-row {
display: flex; flex-wrap: wrap; gap: 9.7px; margin-top: 5.4px;
}
#vsScreen .vs-info-blue .vs-chips-row { justify-content: flex-end; }
#vsScreen .vs-chip-fighter {
font: 700 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
padding: 5.4px 14px;
background: rgba(10,10,14,.62);
border: 1px solid rgba(255,255,255,.25);
color: #fff;
}
#vsScreen .vs-chip-fighter-gold {
border-color: oklch(0.85 0.16 85 / .6);
color: oklch(0.87 0.14 85);
}
/* ══ Placeholder hiding ══
Every placeholder DOM element is always rendered. Empty ones are hidden
here — this keeps the design's HTML shape intact (same as the artifact)
while adapting the visible layout to whatever data actually exists. */
#vsScreen .vs-nullable:empty { display: none; }
/* When every direct child of a "row" wrapper is hidden, the row collapses too. */
#vsScreen .vs-nullable-row:not(:has(> :not(.vs-nullable-hide):not(.vs-nullable:empty))) { display: none; }
/* Image slots explicitly marked as missing (flag / club logo without data). */
#vsScreen .vs-nullable-hide { display: none; }
/* Bottom chips: whole chip hides when its value is empty, and the
diamond separators next to a hidden chip hide too. */
#vsScreen .vs-chip.vs-nullable-chip[data-vs-empty="1"] { display: none; }
#vsScreen .vs-bottom .vs-diamond-match-court:has(+ .vs-chip[data-vs-empty="1"]),
#vsScreen .vs-bottom .vs-chip[data-vs-empty="1"] + .vs-diamond-court-ref,
#vsScreen .vs-bottom .vs-chip[data-vs-empty="1"] + .vs-diamond-match-court,
#vsScreen .vs-bottom .vs-diamond-court-ref:has(+ .vs-chip[data-vs-empty="1"]) { display: none; }
/* ── Top (event / stage / weight) ── */
#vsScreen .vs-top {
position: absolute; top: 34.6px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 10.8px;
z-index: 8; width: 92%; pointer-events: none;
animation: vsDropIn .8s .5s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-top-event {
font: 700 30.2px/1.05 'Barlow Condensed', sans-serif;
letter-spacing: .42em; text-transform: uppercase;
color: rgba(232,230,224,.92); text-align: center;
text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
#vsScreen .vs-top-stage-row { display: flex; align-items: center; gap: 17.3px; }
#vsScreen .vs-top-stage-line { height: 2px; width: 64.8px; }
#vsScreen .vs-top-stage-line-l { background: linear-gradient(to left, oklch(0.85 0.16 85), transparent); }
#vsScreen .vs-top-stage-line-r { background: linear-gradient(to right, oklch(0.85 0.16 85), transparent); }
#vsScreen .vs-top-stage {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 38.9px;
letter-spacing: .30em; padding-left: .3em;
color: oklch(0.85 0.16 85); text-transform: uppercase;
}
#vsScreen .vs-top-weight {
font: 700 42px/1.1 'Anton', 'Barlow Condensed', sans-serif;
letter-spacing: .28em; padding-left: .28em; text-transform: uppercase;
color: #ffffff;
text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
/* ── Center VS ── */
#vsScreen .vs-center {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -52%);
z-index: 7; pointer-events: none;
display: flex; align-items: center; justify-content: center;
animation: vsSlam .7s 1.1s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-word {
position: relative;
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 183.6px;
font-style: italic;
color: #fffdf5; line-height: 1;
-webkit-text-stroke: 2px oklch(0.85 0.16 85 / 0.6);
animation: vsPulse 2.4s ease-in-out infinite;
overflow: visible;
}
#vsScreen .vs-shine-wrap { position: absolute; inset: -10% -20%; overflow: hidden; pointer-events: none; }
#vsScreen .vs-shine {
position: absolute; top: 0; bottom: 0; width: 34%;
background: linear-gradient(to right, transparent, rgba(255,255,255,.16), transparent);
animation: vsShine 5s ease-in-out infinite;
}
#vsScreen .vs-flash {
position: absolute; inset: 0; background: #fff;
opacity: 0; pointer-events: none; z-index: 9;
animation: vsFlash .9s 1.5s ease-out both;
}
/* ── Bottom chips ── */
#vsScreen .vs-bottom {
position: absolute; bottom: 32.4px; left: 50%; transform: translateX(-50%);
display: flex; gap: 17.3px; z-index: 8; align-items: center;
flex-wrap: wrap; justify-content: center; max-width: 94%;
animation: vsRiseC .8s 1.3s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-chip {
display: flex; align-items: baseline; gap: 8.6px;
background: rgba(10,10,14,.72);
border: 1px solid oklch(0.85 0.16 85 / .45);
padding: 10.8px 23.8px; backdrop-filter: blur(6px);
}
#vsScreen .vs-chip-label {
font: 600 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .30em; text-transform: uppercase;
color: rgba(232,230,224,.65);
}
#vsScreen .vs-chip-value {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 34.6px; color: #fff;
}
#vsScreen .vs-chip-value-name {
font-family: 'Barlow Condensed', sans-serif;
font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
font-size: 28.1px;
}
#vsScreen .vs-diamond {
width: 6px; height: 6px; transform: rotate(45deg);
background: oklch(0.85 0.16 85);
}
/* ── Portrait canvas (1080×1920): stack layout ── */
#vsScreen.vs-portrait { --vs-stage-w: 1080px; --vs-stage-h: 1920px; }
#vsScreen.vs-portrait .vs-panel-red { inset: 0 0 auto 0; width: 100%; height: 56%;
clip-path: polygon(0 0, 100% 0, 100% 82%, 0 96%); animation-name: vsPanelT; }
#vsScreen.vs-portrait .vs-panel-blue { inset: auto 0 0 0; width: 100%; height: 56%;
clip-path: polygon(0 18%, 100% 4%, 100% 100%, 0 100%); animation-name: vsPanelB; }
#vsScreen.vs-portrait .vs-divider {
left: -4%; right: -4%; top: 50%; bottom: auto;
width: auto; height: 3px; margin-top: -1.5px; margin-left: 0;
transform: rotate(-4.48deg);
background: linear-gradient(to right, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
}
#vsScreen.vs-portrait .vs-info-red { left: 43.2px; top: 129.6px; bottom: auto; }
#vsScreen.vs-portrait .vs-info-blue { right: 43.2px; bottom: 129.6px; top: auto; }
/* ── Animations ── */
@keyframes vsPulse {
0%,100% { text-shadow: 0 0 30px rgba(255,215,120,.55), 0 0 90px rgba(255,170,60,.3); transform: scale(1); }
50% { text-shadow: 0 0 65px rgba(255,220,130,1), 0 0 160px rgba(255,170,60,.7); transform: scale(1.045); }
}
@keyframes vsShine { 0% { transform: translateX(-130%) skewX(-18deg); } 60%,100% { transform: translateX(230%) skewX(-18deg); } }
@keyframes vsSlowDrift { 0% { transform: translate3d(0,0,0) scale(1.02); } 50% { transform: translate3d(0,-1.2%,0) scale(1.05); } 100% { transform: translate3d(0,0,0) scale(1.02); } }
@keyframes vsPanelL { from { transform: translateX(-105%); } to { transform: translateX(0); } }
@keyframes vsPanelR { from { transform: translateX( 105%); } to { transform: translateX(0); } }
@keyframes vsPanelT { from { transform: translateY(-105%); } to { transform: translateY(0); } }
@keyframes vsPanelB { from { transform: translateY( 105%); } to { transform: translateY(0); } }
@keyframes vsRiseUp { from { opacity: 0; transform: translateY(43.2px); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsRiseC { from { opacity: 0; transform: translate(-50%, 43.2px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsDropIn { from { opacity: 0; transform: translate(-50%, -32.4px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsSlam {
0% { opacity: 0; transform: translate(-50%,-52%) scale(3.4) rotate(-6deg); }
60% { opacity: 1; transform: translate(-50%,-52%) scale(.92) rotate(1deg); }
80% { transform: translate(-50%,-52%) scale(1.06); }
100% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); }
}
@keyframes vsFlash { 0% { opacity: 0; } 12% { opacity: .85; } 100% { opacity: 0; } }
</style>
<script>
(function () {
const VS_INTRO_SECONDS = 6;
const vs = document.getElementById('vsScreen');
if (!vs) return;
const video = document.getElementById('videoPlayer');
const timerEl = document.getElementById('vsSkipTimer');
let hidden = false, tickInt = null;
// ── Fit the fixed 1920×1080 (or 1080×1920) canvas to the parent box.
function fit() {
const host = vs.parentElement;
if (!host) return;
const w = host.clientWidth, h = host.clientHeight;
if (!w || !h) return;
const portrait = h > w * 1.05;
vs.classList.toggle('vs-portrait', portrait);
const sw = portrait ? 1080 : 1920;
const sh = portrait ? 1920 : 1080;
const scale = Math.min(w / sw, h / sh);
vs.style.setProperty('--vs-scale', scale);
}
fit();
if (window.ResizeObserver && vs.parentElement) {
new ResizeObserver(fit).observe(vs.parentElement);
} else {
window.addEventListener('resize', fit);
}
// ── Hold the video at t=0 while the intro counts down ──────────
// Autoplay may already have kicked in before this script ran; pause
// it, keep it pinned at the start, and only release it at 0.
function pinVideo() {
if (!video) return;
try { video.pause(); } catch (_) {}
try { video.currentTime = 0; } catch (_) {}
}
pinVideo();
// Some browsers fire 'play' immediately after we pause (autoplay
// retry). Re-pause until the intro finishes.
const playGuard = () => { if (!hidden) pinVideo(); };
if (video) video.addEventListener('play', playGuard);
function releaseVideo() {
if (!video) return;
video.removeEventListener('play', playGuard);
try { video.currentTime = 0; } catch (_) {}
const p = video.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
}
// ── Dismiss overlay + start playback ───────────────────────────
function hide() {
if (hidden) return;
hidden = true;
if (tickInt) { clearInterval(tickInt); tickInt = null; }
releaseVideo();
vs.classList.add('vs-hide');
setTimeout(() => { vs.remove(); }, 400);
}
document.getElementById('vsSkip')?.addEventListener('click', (e) => {
e.stopPropagation(); e.preventDefault(); hide();
});
// ── Real-time countdown, independent of video timeline ─────────
const started = performance.now();
function tick() {
const elapsed = (performance.now() - started) / 1000;
const left = Math.max(0, VS_INTRO_SECONDS - elapsed);
if (timerEl) timerEl.textContent = Math.ceil(left);
if (left <= 0) hide();
}
tick();
tickInt = setInterval(tick, 100);
})();
</script>
@endif
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more