Compare commits

..
38 Commits
Author SHA1 Message Date
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
59 changed files with 4000 additions and 992 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
@@ -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,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;
}
}
+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);
+92 -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())
); );
} }
+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' => [],
]);
}
}
+45 -2
View File
@@ -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
@@ -345,6 +360,34 @@ class UserController extends Controller
return redirect()->route('history')->with('toast_success', 'Watch history cleared.'); return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
} }
/**
* 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()
{ {
+247 -38
View File
@@ -26,7 +26,66 @@ class VideoController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']); $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'shortsFocused', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription', 'feedMore']);
}
/**
* Cursor-paginated feed slice for infinite scroll.
*
* Called by the sentinel IntersectionObserver on /videos (and single-type
* filters). Returns rendered <x-video-card/> HTML for the next page plus
* the next cursor, or null when there's nothing left. The first page is
* served by index() as regular HTML, so this endpoint is only ever used
* once an initial grid is already on screen.
*
* Cursor is Laravel's native cursor-pagination token, which is stable
* across inserts (unlike `?page=N` which can skip or duplicate items
* when a new video is uploaded between page fetches).
*/
public function feedMore(Request $request)
{
$filter = (string) $request->query('filter', 'all');
$pageSize = 24;
$query = Video::public()->notShorts();
if (str_starts_with($filter, 'lang:')) {
$lang = strtolower(substr($filter, 5));
$primaryIds = Video::public()->where('language', $lang)->pluck('id');
$secondaryIds = VideoAudioTrack::where('language', $lang)
->whereIn('video_id', Video::public()->pluck('id'))
->pluck('video_id');
$ids = $primaryIds->concat($secondaryIds)->unique()->values();
$query = Video::public()->whereIn('id', $ids);
} elseif ($filter === 'shorts') {
$query = Video::public()->shorts();
} elseif ($filter === 'match') {
$query = Video::public()->where('type', 'match')->notShorts();
} elseif ($filter === 'music') {
$query = Video::public()->where('type', 'music')->notShorts();
} elseif ($filter === 'all' || $filter === 'latest' || $filter === '') {
// Home + Latest both show the newest-first mixed-type feed.
// Playlists intentionally stay on their own tab — this endpoint
// only pages the video grid.
$query = Video::public()->notShorts();
} else {
// Unknown filter — behave like the empty state so a crafted
// ?filter= value can never leak the whole table.
return response()->json(['items' => '', 'next_cursor' => null, 'has_more' => false]);
}
$paginator = $query->latest('id')->cursorPaginate($pageSize);
$html = '';
foreach ($paginator->items() as $video) {
$html .= view('components.video-card', ['video' => $video])->render();
}
return response()->json([
'items' => $html,
'next_cursor' => optional($paginator->nextCursor())->encode(),
'has_more' => $paginator->hasMorePages(),
]);
} }
public function index() public function index()
@@ -104,9 +163,12 @@ class VideoController extends Controller
// ── Filtered single-type views ──────────────────────────── // ── Filtered single-type views ────────────────────────────
if (in_array($filter, ['music', 'latest'])) { if (in_array($filter, ['music', 'latest'])) {
$query = Video::public()->notShorts(); // Both tabs sort newest-first. Previously the music branch skipped
// the latest() call entirely, so Eloquent fell back to id ASC and
// the music grid opened on the oldest uploads — never surfacing
// anything new.
$query = Video::public()->notShorts()->latest();
if ($filter === 'music') $query->where('type', 'music'); if ($filter === 'music') $query->where('type', 'music');
else $query->latest();
$videos = $query->limit(50)->get(); $videos = $query->limit(50)->get();
return view('videos.index', compact('videos', 'filter', 'langChips') + [ return view('videos.index', compact('videos', 'filter', 'langChips') + [
'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
@@ -114,7 +176,12 @@ class VideoController extends Controller
} }
// ── All (home) — mixed chronological feed ───────────────── // ── All (home) — mixed chronological feed ─────────────────
$videos = Video::public()->latest()->limit(60)->get(); // Videos shown in the grid are the newest non-shorts. Shorts render
// in their own horizontally-scrolling shelf injected after the first
// row of cards (see videos/index.blade.php), so the home grid isn't
// diluted by 60-second portrait clips.
$videos = Video::public()->notShorts()->latest()->limit(60)->get();
$homeShorts = Video::public()->where('is_shorts', true)->latest()->limit(12)->get();
$playlistQuery = Playlist::withCount('videos') $playlistQuery = Playlist::withCount('videos')
->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)]) ->with(['user', 'videos' => fn($q) => $q->orderBy('playlist_videos.position')->limit(1)])
@@ -131,7 +198,7 @@ class VideoController extends Controller
->sortByDesc('date') ->sortByDesc('date')
->values(); ->values();
return view('videos.index', compact('feedItems', 'filter', 'langChips') + [ return view('videos.index', compact('feedItems', 'filter', 'langChips', 'homeShorts') + [
'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(), 'videos' => collect(), 'shorts' => collect(), 'matches' => collect(), 'playlists' => collect(),
]); ]);
} }
@@ -691,11 +758,28 @@ class VideoController extends Controller
$viewFp = $request->cookie('_fp'); $viewFp = $request->cookie('_fp');
$viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null; $viewFp = ($viewFp && preg_match('/^[a-f0-9]{64}$/', $viewFp)) ? $viewFp : null;
if (Auth::check()) { // Owners don't count toward their own view totals. Previews, re-watches
// from the owner's own account, and even owner-impersonation sessions
// would otherwise inflate the number. The share/analytics screens
// already filter owner_id separately, so skipping the insert is safe.
$isOwnerViewing = Auth::check() && Auth::id() === (int) $video->user_id;
if (Auth::check() && ! $isOwnerViewing) {
// 30-minute user-level dedupe. Also dedupe against the strongest
// device signal available (fingerprint > device_id cookie) so a
// user signing out and back in during the same session doesn't
// double-count.
$exists = \DB::table('video_views') $exists = \DB::table('video_views')
->where('user_id', Auth::id())
->where('video_id', $video->id) ->where('video_id', $video->id)
->where('watched_at', '>', now()->subHour()) ->where('watched_at', '>', now()->subMinutes(30))
->where(function ($q) use ($viewFp, $viewDid) {
$q->where('user_id', Auth::id());
if ($viewFp) {
$q->orWhere('device_hash', $viewFp);
} elseif ($viewDid) {
$q->orWhere('device_id', $viewDid);
}
})
->exists(); ->exists();
if (! $exists) { if (! $exists) {
@@ -711,12 +795,13 @@ class VideoController extends Controller
'watched_at' => now(), 'watched_at' => now(),
]); ]);
} }
} else { } elseif (! $isOwnerViewing) {
// Guest: prefer the fingerprint hash for dedup (strongest signal); fall back to device_id cookie // Guest: 30-minute dedupe keyed on the strongest device signal
// (fingerprint hash > device_id cookie).
$exists = \DB::table('video_views') $exists = \DB::table('video_views')
->whereNull('user_id') ->whereNull('user_id')
->where('video_id', $video->id) ->where('video_id', $video->id)
->where('watched_at', '>', now()->subHour()) ->where('watched_at', '>', now()->subMinutes(30))
->where(function ($q) use ($viewFp, $viewDid) { ->where(function ($q) use ($viewFp, $viewDid) {
if ($viewFp) $q->where('device_hash', $viewFp); if ($viewFp) $q->where('device_hash', $viewFp);
else $q->where('device_id', $viewDid); else $q->where('device_id', $viewDid);
@@ -778,12 +863,60 @@ class VideoController extends Controller
} }
} }
$recommendedVideos = Video::public() // Up Next recommendations: a weighted blend instead of the plain
// chronological "newest 20" the sidebar used to show.
//
// +4 same type (music / match / generic)
// +3 same primary language
// +2 overlapping tag (any match in the comma-separated tags list)
// +1 same channel (keeps a creator's recent uploads surfaced, but
// does not dominate — cross-channel results compete on type/
// language/tags scoring)
//
// Candidates: the newest ~200 public ready videos excluding the one
// being watched. 200 is big enough for the blend to find real
// similarities, small enough to score in-process without a query
// per candidate.
$recommendedVideos = (function () use ($video) {
$candidates = Video::public()
->where('status', 'ready')
->where('id', '!=', $video->id) ->where('id', '!=', $video->id)
->latest() ->with('user')
->limit(20) ->latest('id')
->limit(200)
->get(); ->get();
$ownTags = collect(explode(',', (string) ($video->tags ?? '')))
->map(fn ($t) => mb_strtolower(trim($t)))
->filter()
->unique();
$scored = $candidates->map(function (Video $cand) use ($video, $ownTags) {
$score = 0;
if ($cand->type === $video->type) $score += 4;
if ($video->language && $cand->language === $video->language) $score += 3;
if ($ownTags->isNotEmpty()) {
$candTags = collect(explode(',', (string) ($cand->tags ?? '')))
->map(fn ($t) => mb_strtolower(trim($t)))
->filter();
if ($ownTags->intersect($candTags)->isNotEmpty()) $score += 2;
}
if ($cand->user_id === $video->user_id) $score += 1;
$cand->_rec_score = $score;
return $cand;
});
// Sort by score desc, then newest. Keep up to 20.
return $scored
->sortBy([
['_rec_score', 'desc'],
['created_at', 'desc'],
])
->take(20)
->values();
})();
$view = match ($video->type) { $view = match ($video->type) {
'match' => 'videos.types.match', 'match' => 'videos.types.match',
'music' => 'videos.types.music', 'music' => 'videos.types.music',
@@ -828,7 +961,15 @@ class VideoController extends Controller
: $slideMap['0']; : $slideMap['0'];
$allLangData = \App\Data\Languages::all(); $allLangData = \App\Data\Languages::all();
$audioTracks = $video->audioTracks->map(fn ($t) => [ // Dedupe the language picker by (language + label). A video with two
// tracks sharing the same language (e.g. "ar" uploaded twice while
// retrying) used to list both and leave the viewer guessing. Keeping
// the first occurrence preserves the primary when it exists; using
// (lang, label) as the key still lets legit variants coexist (e.g.
// "en — Original" and "en — Director's cut").
$audioTracks = $video->audioTracks
->unique(fn ($t) => mb_strtolower((string) $t->language) . '|' . mb_strtolower((string) ($t->label ?? '')))
->map(fn ($t) => [
'id' => $t->id, 'id' => $t->id,
'language' => $t->language, 'language' => $t->language,
'label' => $t->label, 'label' => $t->label,
@@ -913,6 +1054,53 @@ class VideoController extends Controller
* mirror + the NAS copy and removes any in-flight progress / temp files, * mirror + the NAS copy and removes any in-flight progress / temp files,
* so the next Generate produces a fresh result. * so the next Generate produces a fresh result.
*/ */
public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm)
{
if (! $llm->isEnabled()) {
return response()->json(['error' => 'Translation is not configured'], 503);
}
$html = (string) $video->description;
if (trim($html) === '') {
return response()->json(['error' => 'Nothing to translate'], 400);
}
// Flatten the rich description → plain text, keeping paragraph breaks
// so the client can render original and translation line-by-line.
$plain = trim(html_entity_decode(strip_tags(
preg_replace(['/<\/p>/i', '/<br\s*\/?>/i'], ["\n", "\n"], $html)
), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$lines = preg_split('/\R/u', $plain);
$target = (string) $request->input('target', 'en');
// Source language inferred from the owner's access-history country,
// falling back to users.nationality.
$topCountries = \App\Models\User::topCountriesFor([$video->user_id]);
$sourceCountry = $topCountries[$video->user_id] ?? null;
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
$translatedLines = $llm->translateLines($lines, $target, $sourceLang);
if (! $translatedLines || count($translatedLines) !== count($lines)) {
return response()->json(['error' => 'Translation failed'], 502);
}
$segments = [];
foreach ($lines as $i => $src) {
$segments[] = [
'original' => (string) $src,
'translated' => (string) ($translatedLines[$i] ?? ''),
];
}
return response()->json([
'success' => true,
'segments' => $segments,
'target' => $target,
'source_hint' => $sourceLang,
'author_country' => $sourceCountry,
]);
}
public function deleteLyrics(Request $request, Video $video) public function deleteLyrics(Request $request, Video $video)
{ {
if (Auth::id() !== $video->user_id) { if (Auth::id() !== $video->user_id) {
@@ -1951,47 +2139,52 @@ class VideoController extends Controller
public function trending(Request $request) public function trending(Request $request)
{ {
$hours = $request->get('hours', 48); // Snap the ?hours param to one of the three windows the UI exposes
$limit = $request->get('limit', 50); // (Today / This Week / This Month). The old clamp (24..168) silently
// capped the "month" tab at a week, which is why the tabs all showed
$hours = min(max($hours, 24), 168); // the same data; now 720 is a first-class option.
$limit = min(max($limit, 10), 100); $hours = (int) $request->get('hours', 24);
$hours = in_array($hours, [24, 168, 720], true) ? $hours : 24;
$limit = min(max((int) $request->get('limit', 50), 10), 100);
// Candidate pool: public ready videos created at any time (not just
// the last 10 days) — a video uploaded three weeks ago still belongs
// in "This Month" if it caught fire this week. We rank by the number
// of views INSIDE the window, with the recency bonus/velocity now
// used only as tiebreakers.
$windowStart = now()->subHours($hours);
$videos = Video::public() $videos = Video::public()
->where('status', 'ready') ->where('status', 'ready')
->where('created_at', '>=', now()->subDays(10)) ->where('created_at', '>=', now()->subMonths(6))
->with('user') ->with('user')
->get(); ->get();
$videos = $videos->map(function ($video) use ($hours) { $videos = $videos->map(function ($video) use ($hours, $windowStart) {
$recentViews = \DB::table('video_views') $recentViews = \DB::table('video_views')
->where('video_id', $video->id) ->where('video_id', $video->id)
->where('watched_at', '>=', now()->subHours($hours)) ->where('watched_at', '>=', $windowStart)
->count(); ->count();
$likeCount = \DB::table('video_likes') $likeCount = \DB::table('video_likes')
->where('video_id', $video->id) ->where('video_id', $video->id)
->count(); ->count();
$ageHours = $video->created_at->diffInHours(now());
$velocity = $recentViews / $hours;
$recencyBonus = max(0, 1 - ($ageHours / 240));
$score = ($recentViews * 0.70) +
($velocity * 100 * 0.15) +
($recencyBonus * 50 * 0.10) +
($likeCount * 0.1 * 0.05);
$video->trending_score = round($score, 2);
$video->view_count = $recentViews; $video->view_count = $recentViews;
$video->like_count = $likeCount; $video->like_count = $likeCount;
$video->_tiebreak_age = $video->created_at->timestamp;
return $video; return $video;
}); });
// Rank: in-window views (primary), likes (tiebreak #1), newest
// created_at (tiebreak #2). Any video with zero views in the window
// is dropped so the list never surfaces truly dormant content.
$trendingVideos = $videos $trendingVideos = $videos
->filter(fn ($v) => $v->trending_score > 0) ->filter(fn ($v) => $v->view_count > 0)
->sortByDesc('trending_score') ->sortBy([
['view_count', 'desc'],
['like_count', 'desc'],
['_tiebreak_age', 'desc'],
])
->take($limit) ->take($limit)
->values(); ->values();
@@ -2002,7 +2195,7 @@ class VideoController extends Controller
]); ]);
} }
public function shorts(Request $request) public function shorts(Request $request, ?int $startId = null)
{ {
$videos = Video::public() $videos = Video::public()
->where('is_shorts', true) ->where('is_shorts', true)
@@ -2011,7 +2204,23 @@ class VideoController extends Controller
->latest() ->latest()
->get(); ->get();
return view('videos.shorts', compact('videos')); return view('videos.shorts', [
'videos' => $videos,
'startId' => $startId, // the id to auto-scroll to, if any
]);
}
/**
* /shorts/{video}: deep-link to a specific short in the vertical feed.
* The feed itself is the same ordered list; the view uses ?start= / the
* $startId prop to scroll into position on load.
*/
public function shortsFocused(Video $video)
{
if (! $video->is_shorts || ! $video->canView(Auth::user())) {
abort(404);
}
return $this->shorts(request(), $video->id);
} }
public function stream(Video $video) public function stream(Video $video)
+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
@@ -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(),
]);
}
}
}
+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
```
+17 -5
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
@@ -741,12 +744,21 @@ new Chart(document.getElementById('typeChart'), {
// ── Country Chart ─────────────────────────────────────────────── // ── Country Chart ───────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty()) @if($viewsByCountry->isNotEmpty())
(function() { @php
const countryData = @json($viewsByCountry->map(fn($r) => [ // 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' => $r->country,
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country), 'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
'total' => $r->total, 'total' => $r->total,
])->values()); ];
})->values();
@endphp
(function() {
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);
+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">
@@ -245,12 +245,15 @@
display: flex; display: flex;
margin-top: 12px; margin-top: 12px;
gap: 12px; gap: 12px;
/* Hard-cap the info block to guarantee every card has the same total /* Reserve a floor for the info block so every card lands at the same
height (thumb + info). Overflow hidden so any accidental extra height when titles are short. Two-line titles are allowed to grow
row can't push the card taller than its neighbours. */ the block vertically — overflow:visible so no row is hidden. */
height: 76px; min-height: 76px;
overflow: hidden;
} }
/* 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 { .yt-video-card .yt-video-details {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
@@ -284,22 +287,26 @@
color: #fff; color: #fff;
margin: 0 0 4px; margin: 0 0 4px;
line-height: 1.3; line-height: 1.3;
/* Single-line, no wrapping — overflow ellipses. */ /* Two-line clamp. overflow-wrap:anywhere lets CJK titles and long
white-space: nowrap; 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;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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;
/* Block-level with nowrap so ellipsis actually clips the excess.
Inline-flex would let the children overflow past the parent. */
display: block; display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
} }
/* Inline children (flags, corner-colored fighter spans) must not break /* Inline children (flags, corner-colored fighter spans) must not break
the single-line layout. */ the single-line layout. */
@@ -446,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
@@ -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>
+51 -276
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;
@@ -85,10 +85,14 @@ $sizeClasses = match($size) {
<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
@if($video && $video->type === 'match' && $video->sportsMatch) BEFORE the image decodes, so a grid of 60 cards doesn't shift
@include('components.partials.vs-mini', ['video' => $video]) as each thumbnail finishes loading. The ratio is honored by
@endif .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)
{{-- Match cards preload metadata so the first frame is ready the {{-- Match cards preload metadata so the first frame is ready the
instant the user hovers (otherwise the browser fetches, buffers instant the user hovers (otherwise the browser fetches, buffers
@@ -98,9 +102,13 @@ $sizeClasses = match($size) {
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}"> <source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video> </video>
@endif @endif
@if($video && $video->type === 'match' && $video->sportsMatch) {{-- Match cards used to inline the full VS portrait + the live-scoring
@include('components.partials.scorebar-mini', ['video' => $video]) scorebar as thumbnail overlays. Those partials leaked fighter
@endif 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">
@@ -132,13 +140,15 @@ $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 }}">
@@ -152,7 +162,7 @@ $sizeClasses = match($size) {
@endif @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
@@ -171,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) }})">
@@ -189,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>
@@ -212,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')
@@ -393,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,21 @@
use App\Models\CommentLike; use App\Models\CommentLike;
$currentUserId = Auth::id() ?? 0; $currentUserId = Auth::id() ?? 0;
$llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
// 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);
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get(); $topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count()); $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 +53,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>
@@ -89,11 +112,19 @@
</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>
</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)
@@ -120,6 +151,12 @@
@auth @auth
<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 +176,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 +201,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 +234,12 @@
<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>
@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 +313,23 @@
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;
}
/* 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 +500,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 {
@@ -498,6 +562,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 +810,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) {
@@ -904,12 +1026,101 @@ 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; }
}); });
// ── 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 +1181,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 +1221,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 +1234,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 +1267,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 +1365,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 +1381,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 +1397,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}
@@ -1309,16 +1550,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 +1591,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
+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
+34 -6
View File
@@ -1218,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')
@@ -1237,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>
@@ -1697,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');
@@ -178,22 +178,9 @@
</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; }
@@ -720,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 ──
@@ -740,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));
} }
} }
@@ -827,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) {
@@ -885,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';
@@ -902,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') {
@@ -910,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() : '';
} }
@@ -932,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) {
@@ -990,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;
@@ -1029,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>
@@ -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()">
@@ -1372,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 ──
@@ -1390,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));
} }
} }
@@ -1400,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);
} }
} }
@@ -1422,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;
@@ -1441,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';
@@ -1455,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); }
@@ -1494,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() : '';
} }
@@ -1512,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">
@@ -1564,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) {
@@ -1686,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);
@@ -1700,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');
@@ -1715,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 };
} }
@@ -1753,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');
@@ -1763,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));
}); });
}); });
} }
+67 -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,18 +683,18 @@ $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>
@@ -699,8 +719,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 +749,7 @@ $headerSocialMap = [
</a> </a>
@endforeach @endforeach
</div> </div>
@endif
</div> </div>
@endif @endif
@@ -753,8 +781,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 +796,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 +1091,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 +1205,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 +1378,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>
+1 -1
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>
@@ -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>
+203 -9
View File
@@ -42,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 */
@@ -59,21 +87,34 @@
<div class="yt-filter-bar-wrap"> <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 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> <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"> <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
@@ -81,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>
@@ -100,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">
@@ -108,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">
@@ -172,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)
@@ -211,6 +330,17 @@
@endif @endif
@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 --}} </div>{{-- /filter-content --}}
@endsection @endsection
@@ -354,5 +484,69 @@
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false); 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
@@ -37,10 +37,10 @@
{{-- RED / AKA --}} {{-- 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="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="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">CLUB<br>LOGO</div> <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="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"> <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)">FLAG</span> <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> <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>
<div style="display:flex;align-items:center;gap:8px;min-width:0"> <div style="display:flex;align-items:center;gap:8px;min-width:0">
@@ -76,14 +76,14 @@
<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="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"> <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="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)">FLAG</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>
<div style="display:flex;align-items:center;gap:8px;min-width:0"> <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="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> <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> </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">CLUB<br>LOGO</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> </div>
</div> </div>
@@ -78,8 +78,10 @@
el.textContent = val || ''; el.textContent = val || '';
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val); if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
}; };
// Replace the placeholder "CLUB LOGO" text with a real <img> // When a club_logo path is set, drop a real <img> into the empty
// (or leave the placeholder if no image). Never touches inline styles. // 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 setImage = (name, path) => {
const el = $(name); if (!el) return; const el = $(name); if (!el) return;
if (!path) return; if (!path) return;
@@ -90,8 +92,9 @@
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block'; img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
el.appendChild(img); el.appendChild(img);
}; };
// Swap the placeholder "FLAG" box for a real flag image by pointing // When an ISO2 is set, point the flag box's background-image at
// its background-image at the self-hosted flag-icons SVG. // the self-hosted flag-icons SVG. When it isn't, the empty box
// stays empty — no "FLAG" placeholder text.
const setFlag = (name, iso2) => { const setFlag = (name, iso2) => {
const el = $(name); if (!el) return; const el = $(name); if (!el) return;
const code = (iso2 || '').toLowerCase(); const code = (iso2 || '').toLowerCase();
+44 -3
View File
@@ -1,6 +1,6 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Shorts - ' . config('app.name')) @section('title', 'Shorts | ' . config('app.name'))
@section('main_class', 'shorts-view-page') @section('main_class', 'shorts-view-page')
@@ -432,9 +432,22 @@
<script> <script>
(function () { (function () {
const main = document.getElementById('main'); const main = document.getElementById('main');
let muted = false; // Persist the mute state across sessions. Default to muted so autoplay
// policies don't silently fail on first load; the user can unmute once
// and we remember it from then on.
let muted = (localStorage.getItem('shortsMuted') ?? '1') === '1';
let current = -1; let current = -1;
// ── Deep-link to /shorts/{id} — scroll the matching item into view
// before the observer starts, so autoplay fires on the target short
// rather than the first in document order. ────────────────────
@if($startId)
(function () {
const target = document.querySelector('.short-item[data-id="{{ (int) $startId }}"]');
if (target) target.scrollIntoView({ behavior: 'instant' in window ? 'instant' : 'auto', block: 'start' });
})();
@endif
// ── IntersectionObserver — auto-play visible short ───────── // ── IntersectionObserver — auto-play visible short ─────────
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
entries.forEach(entry => { entries.forEach(entry => {
@@ -446,12 +459,23 @@
if (entry.isIntersecting && entry.intersectionRatio >= 0.6) { if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
if (current !== -1 && current !== idx) pauseShort(current); if (current !== -1 && current !== idx) pauseShort(current);
current = idx; current = idx;
// ── Reflect the active short in the URL (no history entry) ──
// Users can copy-paste the address bar and get back here; the
// back button still exits the feed instead of walking through
// every short they viewed.
try {
const vid = item.dataset.id;
if (vid) history.replaceState(null, '', '/shorts/' + vid);
} catch (_) {}
video.muted = muted; video.muted = muted;
video.volume = 1; video.volume = 1;
video.play().catch(() => { video.play().catch(() => {
// autoplay blocked — start muted then unmute // autoplay blocked — fall back to muted
video.muted = true; video.muted = true;
muted = true; muted = true;
localStorage.setItem('shortsMuted', '1');
updateMuteIcon(idx); updateMuteIcon(idx);
video.play().catch(() => {}); video.play().catch(() => {});
}); });
@@ -500,6 +524,7 @@
// ── Mute toggle ──────────────────────────────────────────── // ── Mute toggle ────────────────────────────────────────────
window.toggleMute = function(idx) { window.toggleMute = function(idx) {
muted = !muted; muted = !muted;
localStorage.setItem('shortsMuted', muted ? '1' : '0');
const video = document.getElementById('sv-' + idx); const video = document.getElementById('sv-' + idx);
if (video) video.muted = muted; if (video) video.muted = muted;
updateMuteIcon(idx); updateMuteIcon(idx);
@@ -528,6 +553,22 @@
} }
}); });
// ── Mouse wheel nav (desktop) ──────────────────────────────
// One wheel event = one short, debounced so a trackpad flick doesn't
// skip five shorts at once. The main scroll container's own snap would
// nearly handle this, but a slow scroll leaves the feed mid-snap on
// some browsers — this makes the step explicit and symmetrical.
let wheelLock = false;
main?.addEventListener('wheel', (e) => {
if (wheelLock) { e.preventDefault(); return; }
if (Math.abs(e.deltaY) < 10) return; // ignore tiny jogs
wheelLock = true;
setTimeout(() => { wheelLock = false; }, 450);
e.preventDefault();
const dir = e.deltaY > 0 ? 1 : -1;
if (current !== -1) navigateShort(dir, current);
}, { passive: false });
// ── Like ─────────────────────────────────────────────────── // ── Like ───────────────────────────────────────────────────
window.shortLike = function(videoId) { window.shortLike = function(videoId) {
@auth @auth
+15 -7
View File
@@ -836,15 +836,22 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0) @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list"> <div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo) @foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card" {{-- Real <a href> cards so right-click / open-in-new-tab /
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'"> middle-click and screen readers all work. The SPA
transition hooks in types/*.blade.php still intercept
these clicks via data-rec-url for a soft load. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail) @if ($recVideo->thumbnail)
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}" <img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}"> alt="{{ $recVideo->title }}" width="168" height="94"
loading="lazy" decoding="async">
@else @else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180" <img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
alt="{{ $recVideo->title }}"> alt="{{ $recVideo->title }}" width="168" height="94"
loading="lazy" decoding="async">
@endif @endif
@if ($recVideo->duration) @if ($recVideo->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span> <span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
@@ -852,14 +859,15 @@
@if ($recVideo->is_shorts) @if ($recVideo->is_shorts)
<span class="yt-shorts-badge" <span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;"> style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS <i class="bi bi-collection-play-fill" aria-hidden="true"></i> SHORTS
</span> </span>
@endif @endif
</div> </div>
<div class="sidebar-info"> <div class="sidebar-info">
<div class="sidebar-title"> <div class="sidebar-title">
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}" <i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i> style="color: #ef4444; margin-right: 4px; font-size: 12px;"
aria-hidden="true"></i>
{{ Str::limit($recVideo->title, 60) }} {{ Str::limit($recVideo->title, 60) }}
</div> </div>
<div class="sidebar-meta"> <div class="sidebar-meta">
@@ -868,7 +876,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div> {{ $recVideo->created_at->diffForHumans() }}</div>
</div> </div>
</div> </div>
</div> </a>
@endforeach @endforeach
</div> </div>
@else @else
+7 -3
View File
@@ -177,9 +177,13 @@
<div class="trending-header"> <div class="trending-header">
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1> <h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
<div class="trending-filters"> <div class="trending-filters">
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a> {{-- Hours line up with the labels: 24 = Today, 168 = This Week
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a> (7×24), 720 = This Month (30×24). The old 24/48/168 trio
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a> claimed Month but silently showed Week data because the
controller clamped the max to 168. --}}
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}" @if($hours == 24) aria-current="page"@endif>Today</a>
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}" @if($hours == 168) aria-current="page"@endif>This Week</a>
<a href="{{ route('videos.trending', ['hours' => 720]) }}" class="{{ $hours == 720 ? 'active' : '' }}" @if($hours == 720) aria-current="page"@endif>This Month</a>
</div> </div>
</div> </div>
+10 -3
View File
@@ -307,7 +307,10 @@
border-radius: 8px; border-radius: 8px;
padding: 4px; padding: 4px;
transition: background .15s; transition: background .15s;
color: inherit;
text-decoration: none;
} }
.sidebar-video-card:hover { background: rgba(255,255,255,.05); }
.sidebar-video-card.current-video { .sidebar-video-card.current-video {
background: rgba(239,68,68,.12); background: rgba(239,68,68,.12);
@@ -977,9 +980,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0) @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList"> <div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo) @foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card" {{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}" data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')" onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;"> style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail) @if ($recVideo->thumbnail)
@@ -1013,7 +1020,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div> {{ $recVideo->created_at->diffForHumans() }}</div>
</div> </div>
</div> </div>
</div> </a>
@endforeach @endforeach
</div> </div>
@else @else
+7 -3
View File
@@ -3144,9 +3144,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0) @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList"> <div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo) @foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card" {{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}" data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')" onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;"> style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail) @if ($recVideo->thumbnail)
@@ -3181,7 +3185,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div> {{ $recVideo->created_at->diffForHumans() }}</div>
</div> </div>
</div> </div>
</div> </a>
@endforeach @endforeach
</div> </div>
@else @else
+12 -3
View File
@@ -307,7 +307,12 @@
border-radius: 8px; border-radius: 8px;
padding: 4px; padding: 4px;
transition: background .15s; transition: background .15s;
/* Card is now an <a href> for real right-click / open-in-new-tab
/ middle-click support. Strip the default link styling. */
color: inherit;
text-decoration: none;
} }
.sidebar-video-card:hover { background: rgba(255,255,255,.05); }
.sidebar-video-card.current-video { .sidebar-video-card.current-video {
background: rgba(239,68,68,.12); background: rgba(239,68,68,.12);
@@ -967,9 +972,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0) @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list" id="recList"> <div class="recommended-videos-list" id="recList">
@foreach ($recommendedVideos as $recVideo) @foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card" {{-- Real <a href> so right-click / middle-click /
open-in-new-tab works. recGoTo() still runs
on a plain left-click for the SPA swap. --}}
<a class="sidebar-video-card"
href="{{ route('videos.show', $recVideo) }}"
data-rec-url="{{ route('videos.show', $recVideo) }}" data-rec-url="{{ route('videos.show', $recVideo) }}"
onclick="recGoTo('{{ route('videos.show', $recVideo) }}')" onclick="if (event.button===0 && !event.metaKey && !event.ctrlKey && !event.shiftKey) { event.preventDefault(); recGoTo('{{ route('videos.show', $recVideo) }}'); return false; }"
style="cursor:pointer;"> style="cursor:pointer;">
<div class="sidebar-thumb" style="position: relative;"> <div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail) @if ($recVideo->thumbnail)
@@ -1003,7 +1012,7 @@
{{ $recVideo->created_at->diffForHumans() }}</div> {{ $recVideo->created_at->diffForHumans() }}</div>
</div> </div>
</div> </div>
</div> </a>
@endforeach @endforeach
</div> </div>
@else @else
+1 -1
View File
@@ -1,6 +1,6 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', 'Welcome - ' . config('app.name')) @section('title', 'Welcome | ' . config('app.name'))
@section('content') @section('content')
<div class="welcome-container"> <div class="welcome-container">
+1 -1
View File
@@ -15,7 +15,7 @@ Route::middleware('guest')->group(function () {
Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login'); Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']) Route::post('login', [AuthenticatedSessionController::class, 'store'])
->middleware('throttle:10,1') // max 10 login attempts per minute per IP ->middleware('throttle:5,1') // max 5 login attempts per minute per IP
->name('login.store'); ->name('login.store');
}); });
+23 -1
View File
@@ -24,9 +24,20 @@ Route::get('/', [VideoController::class, 'index'])->name('home');
// Video routes - public // Video routes - public
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index'); Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search'); Route::get('/videos/feed/more', [VideoController::class, 'feedMore'])->name('videos.feed.more');
// Site-wide search. /videos/search is the historical URL; keep it as a
// signed-forward redirect so bookmarks and external links don't break.
Route::get('/search', [\App\Http\Controllers\SearchController::class, 'index'])->name('search');
Route::get('/videos/search', function (\Illuminate\Http\Request $request) {
return redirect()->route('search', $request->only('q'), 301);
})->name('videos.search');
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending'); Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts'); Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
// Deep-link form: /shorts/{encoded-id} loads the feed scrolled to that short.
// The resolveRouteBinding on Video decodes the id; if it's gone, 404 lands
// on the branded errors/404 page.
Route::get('/shorts/{video}', [VideoController::class, 'shortsFocused'])->name('videos.shorts.focused');
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create'); Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
// Redirect legacy numeric video IDs to encoded URLs (backward compatibility) // Redirect legacy numeric video IDs to encoded URLs (backward compatibility)
Route::get('/videos/{numericId}', function ($numericId) { Route::get('/videos/{numericId}', function ($numericId) {
@@ -58,6 +69,7 @@ Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsPr
Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']); Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']);
Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']); Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']);
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare'); Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
Route::post('/videos/{video}/description/translate', [VideoController::class, 'translateDescription'])->name('videos.description.translate')->middleware('throttle:30,1');
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']); Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']); Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage'); Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
@@ -137,6 +149,7 @@ Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisi
// Comment routes // Comment routes
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index'); Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
Route::post('/comments/{comment}/translate', [CommentController::class, 'translate'])->name('comments.translate');
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store'); Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update'); Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
@@ -159,6 +172,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/history', [UserController::class, 'history'])->name('history'); Route::get('/history', [UserController::class, 'history'])->name('history');
Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear'); Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear');
Route::get('/liked', [UserController::class, 'liked'])->name('liked'); Route::get('/liked', [UserController::class, 'liked'])->name('liked');
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
// Logout all other devices // Logout all other devices
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices'); Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
@@ -171,6 +185,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share). // Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel'); Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
// YouTube-style @handle alias. For now `handle` is just the existing
// `username` slug — if the two ever diverge, flip the controller lookup
// in UserController::channel() to prefer a dedicated `handle` column.
// The {handle} segment must be url-encoded (e.g. /%40alice) OR plain `@alice`
// — Laravel's router accepts both because `@` is allowed in URI path segments.
Route::get('/@{handle}', [UserController::class, 'channel'])
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
->name('channel.handle');
// Post routes - auth required // Post routes - auth required
Route::middleware(['auth'])->group(function () { Route::middleware(['auth'])->group(function () {
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace Tests\Feature;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use App\Rules\ReservedIdentifier;
use App\Support\SafeNotifier;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Phase 1 audit remediation: security + data-integrity regression tests.
*
* These tests run on a scratch SQLite file in storage/testing.sqlite so they
* can't collide with the shared dev DB. phpunit.xml keeps DB_CONNECTION out
* of the test env so this per-class override is where the swap happens.
*/
class Phase1SecurityTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
// Force the test runner onto an in-memory SQLite BEFORE the framework
// boots, so RefreshDatabase migrates the throwaway DB and never the
// shared dev file. Env vars are read during app bootstrap in
// CreatesApplication, which is why putenv has to come first.
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
// Strip the two middleware that fight the test client: CSRF (no
// token flow in postJson) and AuthenticateSession (which rotates
// the session on cross-request assertions and 302s to APP_URL).
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── ReservedIdentifier rule ──────────────────────────
public function test_reserved_name_rule_blocks_impersonation_candidates(): void
{
$rule = new ReservedIdentifier('name');
foreach (['admin', 'Administrator', 'super_admin', 'super-admin',
'SuperAdmin', 'TAKEONE', 'support', 'root', 'Postmaster'] as $name) {
$failed = false;
$rule->validate('name', $name, function () use (&$failed) { $failed = true; });
$this->assertTrue($failed, "Expected name '{$name}' to be reserved");
}
}
public function test_reserved_email_rule_matches_on_local_part_only(): void
{
$rule = new ReservedIdentifier('email');
$failed = false;
$rule->validate('email', '[email protected]', function () use (&$failed) { $failed = true; });
$this->assertTrue($failed);
$failed = false;
$rule->validate('email', '[email protected]', function () use (&$failed) { $failed = true; });
$this->assertFalse($failed, 'Only exact local-part should match; "admin.user" is a real person.');
}
public function test_reserved_rule_allows_normal_names(): void
{
$rule = new ReservedIdentifier('name');
foreach (['Alice', 'Bob Smith', 'John Doe', 'الحسن'] as $name) {
$failed = false;
$rule->validate('name', $name, function () use (&$failed) { $failed = true; });
$this->assertFalse($failed, "Expected name '{$name}' to be allowed");
}
}
// ─── SafeNotifier swallows mail failures ──────────────
public function test_safe_notifier_logs_but_does_not_throw_on_failure(): void
{
// A fake notifiable whose notify() blows up — stands in for the Gmail
// 535 case where sending throws out of the transport layer.
$exploding = new class {
public function notify($n): void { throw new \RuntimeException('smtp exploded'); }
public function getKey(): int { return 1; }
};
$notification = new \App\Notifications\VerifyEmail;
// Call must not throw — if it did, PHPUnit would mark this test as errored.
SafeNotifier::send($exploding, $notification);
$this->assertTrue(true, 'SafeNotifier swallowed the throw');
}
// ─── Comment dedupe ───────────────────────────────────
public function test_identical_comments_within_60s_are_collapsed(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$first = $this->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Great goal!']);
$first->assertOk();
$firstId = $first->json('comment.id');
$second = $this->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Great goal!']);
$second->assertOk();
$secondId = $second->json('comment.id');
$this->assertSame($firstId, $secondId, 'Duplicate within 60s should return the first row, not insert a new one.');
$this->assertSame(1, Comment::where('video_id', $video->id)->count());
}
public function test_idempotency_key_returns_the_same_comment(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$key = 'test-idem-key-' . bin2hex(random_bytes(8));
$a = $this->withHeaders(['X-Idempotency-Key' => $key])
->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'First body']);
$a->assertOk();
$aId = $a->json('comment.id');
// Different body, same key — because the first write cached the id,
// the second call returns it unchanged.
$b = $this->withHeaders(['X-Idempotency-Key' => $key])
->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Different body']);
$b->assertOk();
$bId = $b->json('comment.id');
$this->assertSame($aId, $bId, 'Same idempotency key must return the same comment id.');
}
// ─── Owner view exclusion ─────────────────────────────
public function test_owner_view_is_not_recorded(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$this->get("/videos/{$video->id}");
$this->assertSame(0, DB::table('video_views')->where('video_id', $video->id)->count(),
'Views by the video owner must not be counted.');
}
// ─── Helpers ──────────────────────────────────────────
private function makeUserAndVideo(): array
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$video = Video::create([
'user_id' => $user->id,
'title' => 'Test video',
'description'=> 'Hello',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
]);
return [$user, $video];
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 2 — Performance and DOM. Guards against regressions in the shared
* edit modal, owner-only menu items, thumbnail layout-shift attrs, and the
* cursor-paginated feed endpoint.
*/
class Phase2PerfTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Shared edit modal: per-card copy is gone ─────
public function test_video_card_does_not_render_a_per_card_edit_modal(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$html = view('components.video-card', ['video' => $video])->render();
// The dead per-card modal used id="editVideoModal{slug}" and the class
// "cute-edit-modal". If either reappears, we're back to shipping 100
// extra lines per card.
$this->assertStringNotContainsString('editVideoModal' . $video->getRouteKey(), $html);
$this->assertStringNotContainsString('cute-edit-modal', $html);
}
// ─── Owner-only menu items ────────────────────────
public function test_owner_sees_edit_delete_items_in_card_menu(): void
{
[$owner, $video] = $this->makeUserAndVideo();
$this->actingAs($owner);
$html = view('components.video-card', ['video' => $video])->render();
$this->assertStringContainsString('openEditVideoModal', $html);
$this->assertStringContainsString('showDeleteModal', $html);
$this->assertStringNotContainsString('notInterested', $html);
$this->assertStringNotContainsString('dontRecommendChannel', $html);
$this->assertStringNotContainsString('reportVideo', $html);
}
public function test_non_owner_sees_report_items_but_not_edit(): void
{
[$owner, $video] = $this->makeUserAndVideo();
$viewer = User::factory()->create([
'username' => 'v'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$this->actingAs($viewer);
$html = view('components.video-card', ['video' => $video])->render();
$this->assertStringNotContainsString('openEditVideoModal', $html);
$this->assertStringNotContainsString('showDeleteModal', $html);
$this->assertStringContainsString('notInterested', $html);
$this->assertStringContainsString('reportVideo', $html);
}
// ─── Thumbnail layout-shift attributes ────────────
public function test_thumbnail_image_has_width_and_height(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$html = view('components.video-card', ['video' => $video])->render();
$this->assertMatchesRegularExpression(
'/<img[^>]*width="320"[^>]*height="180"/i',
$html,
'Thumbnail img must carry explicit width/height so the browser reserves layout space.'
);
$this->assertStringContainsString('loading="lazy"', $html);
}
// ─── Cursor-paginated feed endpoint ───────────────
public function test_feed_more_returns_json_with_cards_and_next_cursor(): void
{
$user = User::factory()->create([
'username' => 'f'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
// Twice the page size so the cursor must advance.
for ($i = 0; $i < 30; $i++) {
Video::create([
'user_id' => $user->id,
'title' => 'Vid ' . $i,
'description' => '',
'filename' => "users/x/videos/y{$i}/video.mp4",
'path' => "users/x/videos/y{$i}/video.mp4",
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
}
$r = $this->getJson(route('videos.feed.more', ['filter' => 'all']));
$r->assertOk();
$r->assertJsonStructure(['items', 'next_cursor', 'has_more']);
$this->assertTrue($r->json('has_more'), 'With 30 videos and a 24-page size, has_more must be true.');
$this->assertNotEmpty($r->json('next_cursor'));
$this->assertStringContainsString('yt-video-card', (string) $r->json('items'));
}
public function test_feed_more_unknown_filter_returns_empty_slice(): void
{
$this->getJson(route('videos.feed.more', ['filter' => 'nonsense']))
->assertOk()
->assertJson(['items' => '', 'has_more' => false]);
}
// ─── Lazy reply-form template present ─────────────
public function test_comments_component_ships_the_reply_template_not_inline_forms(): void
{
[$user, $video] = $this->makeUserAndVideo();
\App\Models\Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'top-level comment',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('id="ytc-reply-form-tpl"', $html);
$this->assertStringContainsString('ytc-reply-slot', $html);
}
// ─── Helpers ──────────────────────────────────────
private function makeUserAndVideo(): array
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$video = Video::create([
'user_id' => $user->id,
'title' => 'Test video',
'description'=> 'Hello',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
]);
return [$user, $video];
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Playlist;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Phase 3 — Feeds, search, trending.
*
* Guards against: trending window regressions, music feed opening on oldest,
* search returning the wrong buckets, and the /videos/search → /search 301.
*/
class Phase3FeedsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Trending window ─────────────────────────────
public function test_trending_accepts_24_168_720_and_snaps_others_to_24(): void
{
$this->get(route('videos.trending', ['hours' => 24]))->assertOk();
$this->get(route('videos.trending', ['hours' => 168]))->assertOk();
$this->get(route('videos.trending', ['hours' => 720]))->assertOk();
// Garbage input must not crash; it snaps to 24.
$this->get(route('videos.trending', ['hours' => 'abc']))->assertOk();
$this->get(route('videos.trending', ['hours' => 99999]))->assertOk();
}
public function test_trending_ranks_by_in_window_views_then_likes(): void
{
$owner = $this->makeUser();
// Three videos — "A" has most views in last 24h, "B" has most likes
// but no views, "C" has nothing. A should be first; B should NOT
// appear (no in-window views → filtered out); C should not appear.
$a = $this->makeVideo($owner, 'A');
$b = $this->makeVideo($owner, 'B');
$c = $this->makeVideo($owner, 'C');
$this->seedViews($a, 10);
$this->seedViews($b, 0);
$this->seedLikes($b, 5);
$this->seedViews($c, 0);
$r = $this->get(route('videos.trending', ['hours' => 24]));
$r->assertOk();
$html = $r->getContent();
$this->assertStringContainsString('A', $html, 'Video A must appear — it has in-window views.');
$this->assertStringNotContainsString('>B<', $html, 'Video B must not appear — zero in-window views.');
}
// ─── Music feed newest-first ─────────────────────
public function test_music_feed_opens_on_newest_video(): void
{
$owner = $this->makeUser();
// created_at isn't in Video::$fillable (Eloquent auto-manages it),
// so set it after the row exists.
$old = $this->makeVideo($owner, 'OLD MUSIC', ['type' => 'music']);
$old->created_at = now()->subDays(5); $old->save();
$new = $this->makeVideo($owner, 'NEW MUSIC', ['type' => 'music']);
$new->created_at = now()->subMinutes(5); $new->save();
$html = $this->get(route('videos.index', ['filter' => 'music']))->assertOk()->getContent();
$posNew = strpos($html, 'NEW MUSIC');
$posOld = strpos($html, 'OLD MUSIC');
$this->assertNotFalse($posNew);
$this->assertNotFalse($posOld);
$this->assertLessThan($posOld, $posNew, 'Newer music video must appear before the older one.');
}
// ─── /videos/search → /search (301) ──────────────
public function test_old_videos_search_url_301_redirects_to_search(): void
{
$this->get('/videos/search?q=hello')
->assertStatus(301)
->assertRedirect('/search?q=hello');
}
// ─── /search returns channel + playlist buckets ──
public function test_search_matches_video_channel_and_playlist(): void
{
$owner = User::factory()->create([
'name' => 'Searchable Owner',
'username' => 'searchable-channel-'.substr(bin2hex(random_bytes(4)), 0, 6),
]);
$video = $this->makeVideo($owner, 'SearchableVideoTitle', ['description' => 'nothing special']);
$pl = Playlist::create([
'user_id' => $owner->id,
'name' => 'SearchablePlaylistName',
'visibility' => 'public',
'share_token'=> bin2hex(random_bytes(8)),
]);
// 1. Query that matches a video title
$html = $this->get(route('search', ['q' => 'SearchableVideo']))->assertOk()->getContent();
$this->assertStringContainsString('SearchableVideoTitle', $html);
// 2. Query that matches a channel name
$html = $this->get(route('search', ['q' => 'Searchable Owner']))->assertOk()->getContent();
$this->assertStringContainsString('Channels', $html);
$this->assertStringContainsString('Searchable Owner', $html);
// 3. Query that matches a playlist name
$html = $this->get(route('search', ['q' => 'SearchablePlaylist']))->assertOk()->getContent();
$this->assertStringContainsString('Playlists', $html);
$this->assertStringContainsString('SearchablePlaylistName', $html);
}
public function test_empty_search_redirects_to_browse(): void
{
$this->get(route('search', ['q' => '']))
->assertRedirect(route('videos.index'));
}
// ─── Helpers ─────────────────────────────────────
private function makeUser(): User
{
return User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
}
private function makeVideo(User $u, string $title, array $attrs = []): Video
{
return Video::create(array_merge([
'user_id' => $u->id,
'title' => $title,
'description' => '',
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token' => bin2hex(random_bytes(8)),
], $attrs));
}
private function seedViews(Video $v, int $n): void
{
for ($i = 0; $i < $n; $i++) {
DB::table('video_views')->insert([
'video_id' => $v->id,
'user_id' => null,
'device_id' => 'dev-'.$i,
'watched_at' => now()->subMinutes(10),
]);
}
}
private function seedLikes(Video $v, int $n): void
{
for ($i = 0; $i < $n; $i++) {
$u = $this->makeUser();
DB::table('video_likes')->insert([
'video_id' => $v->id,
'user_id' => $u->id,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use App\Support\HtmlSanitizer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 4 — Watch page.
*
* Guards against regressions in: the Up Next sidebar (anchor cards, weighted
* recommender), the comment permalink + plural counts, the markdown-leak
* rendering, and the download access gate for both MP4 and MP3.
*/
class Phase4WatchTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Comment component ────────────────────────────
public function test_comment_header_uses_singular_count_for_one_comment(): void
{
[$user, $video] = $this->makeUserAndVideo();
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'the only comment',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('1 Comment', $html);
$this->assertStringNotContainsString('1 Comments', $html);
}
public function test_comment_header_breaks_out_replies_from_top_level(): void
{
[$user, $video] = $this->makeUserAndVideo();
$parent = Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'top',
]);
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'parent_id' => $parent->id,
'body' => 'reply 1',
]);
Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'parent_id' => $parent->id,
'body' => 'reply 2',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('1 Comment', $html, 'Top-level count is 1.');
$this->assertStringContainsString('2 replies', $html, 'Replies are counted separately.');
}
public function test_each_comment_timestamp_is_a_lc_permalink(): void
{
[$user, $video] = $this->makeUserAndVideo();
$c = Comment::create([
'user_id' => $user->id,
'video_id' => $video->id,
'body' => 'hi',
]);
$this->actingAs($user);
$html = view('components.video-comments', ['video' => $video])->render();
$this->assertStringContainsString('?lc=' . $c->id . '#comment-' . $c->id, $html);
$this->assertStringContainsString('ytc-permalink', $html);
}
// ─── Description markdown ────────────────────────
public function test_leaked_markdown_headers_render_as_html(): void
{
$out = HtmlSanitizer::render("Intro line\n## Letra\nla la\n### Chorus");
$this->assertStringContainsString('<h2>Letra</h2>', $out);
$this->assertStringContainsString('<h3>Chorus</h3>', $out);
$this->assertStringNotContainsString('## Letra', $out);
$this->assertStringNotContainsString('### Chorus', $out);
}
public function test_normal_plain_text_description_passes_through(): void
{
$out = HtmlSanitizer::render("hello world\nhow are you");
$this->assertStringContainsString('hello world', $out);
$this->assertStringContainsString('how are you', $out);
$this->assertStringNotContainsString('<h2>', $out);
}
// ─── Download access gate ────────────────────────
public function test_disabled_download_returns_403_for_mp4(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
$this->get(route('videos.download', $video))->assertStatus(403);
}
public function test_disabled_download_returns_403_for_mp3(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
$this->get(route('videos.downloadMp3', $video))->assertStatus(403);
}
public function test_registered_download_denies_guests(): void
{
[$user, $video] = $this->makeUserAndVideo(['download_access' => 'registered']);
$this->get(route('videos.download', $video))->assertStatus(403);
}
// ─── Up Next anchor cards ─────────────────────────
public function test_up_next_sidebar_renders_anchor_cards_with_hrefs(): void
{
[$owner, $target] = $this->makeUserAndVideo();
[, $rec] = $this->makeUserAndVideo();
$html = $this->get(route('videos.show', $target))->getContent();
$this->assertStringContainsString('<a class="sidebar-video-card"', $html,
'Up Next entries must be anchor elements, not onclick divs.');
$this->assertStringContainsString('data-rec-url=', $html,
'data-rec-url hooks keep the SPA transition working for left-clicks.');
}
// ─── Helpers ──────────────────────────────────────
private function makeUserAndVideo(array $attrs = []): array
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$video = Video::create(array_merge([
'user_id' => $user->id,
'title' => 'T '.substr(bin2hex(random_bytes(4)), 0, 6),
'description'=> '',
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'share_token'=> bin2hex(random_bytes(8)),
], $attrs));
return [$user, $video];
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use App\Models\Video;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 5 — Shorts.
*
* Guards against regressions in the deep-link route, the Home shelf, and
* the branded 404 fallback.
*/
class Phase5ShortsTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── /shorts/{id} deep-link ──────────────────────
public function test_shorts_deep_link_renders_feed_scrolled_to_target(): void
{
$owner = $this->makeUser();
$short = $this->makeShort($owner, 'DEEP LINK SHORT');
$html = $this->get(route('videos.shorts.focused', $short))->assertOk()->getContent();
$this->assertStringContainsString('DEEP LINK SHORT', $html);
// The in-page scroller is driven by data-id matching the start id.
$this->assertStringContainsString('data-id="' . $short->id . '"', $html);
$this->assertStringContainsString('scrollIntoView', $html);
}
public function test_shorts_deep_link_404s_on_non_short(): void
{
$owner = $this->makeUser();
$regular = Video::create([
'user_id' => $owner->id,
'title' => 'not a short',
'description'=> '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'is_shorts' => false,
'share_token'=> bin2hex(random_bytes(8)),
]);
$this->get('/shorts/' . Video::encodeId($regular->id))->assertStatus(404);
}
// ─── Home shelf ──────────────────────────────────
public function test_home_shows_shorts_shelf_when_shorts_exist(): void
{
$owner = $this->makeUser();
$s = $this->makeShort($owner, 'SHELF SHORT');
$html = $this->get(route('home'))->assertOk()->getContent();
$this->assertStringContainsString('home-shorts-shelf', $html);
$this->assertStringContainsString('SHELF SHORT', $html);
$this->assertStringContainsString('/shorts/' . Video::encodeId($s->id), $html);
}
public function test_home_omits_shorts_shelf_when_no_shorts(): void
{
$owner = $this->makeUser();
// A regular video so the home feed renders, but no shorts.
Video::create([
'user_id' => $owner->id,
'title' => 'reg',
'description'=> '',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'is_shorts' => false,
'share_token'=> bin2hex(random_bytes(8)),
]);
$html = $this->get(route('home'))->assertOk()->getContent();
$this->assertStringNotContainsString('home-shorts-shelf', $html);
}
// ─── Branded 404 ─────────────────────────────────
public function test_unknown_url_renders_branded_404(): void
{
$r = $this->get('/no-such-page')->assertStatus(404);
$r->assertSee('Error 404', false);
}
// ─── Helpers ─────────────────────────────────────
private function makeUser(): User
{
return User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
}
private function makeShort(User $u, string $title): Video
{
return Video::create([
'user_id' => $u->id,
'title' => $title,
'description'=> '',
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
'is_shorts' => true,
'duration' => 45,
'orientation'=> 'portrait',
'share_token'=> bin2hex(random_bytes(8)),
]);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\Playlist;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 6 — Channel / Studio / Live.
*
* Covers the handle-alias route, the composer being hidden in preview mode,
* and the pluralised playlist meta.
*/
class Phase6ChannelTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── /@handle alias ─────────────────────────────
public function test_at_handle_url_opens_the_matching_channel(): void
{
$user = User::factory()->create([
'name' => 'Handle Test',
'username' => 'handletest-'.substr(bin2hex(random_bytes(3)), 0, 6),
]);
$r = $this->get('/@' . $user->username)->assertOk();
$r->assertSee('Handle Test', false);
}
public function test_at_handle_rejects_invalid_characters(): void
{
// Space in the handle must not match the /@ route.
$this->get('/@not valid')->assertStatus(404);
}
// ─── Preview-as-visitor hides composer ──────────
public function test_preview_as_visitor_hides_the_wall_composer(): void
{
$user = User::factory()->create([
'username' => 'preview-'.substr(bin2hex(random_bytes(3)), 0, 6),
]);
$this->actingAs($user);
// The composer form carries id="postForm" and action pointing at
// posts.store — specific enough that we don't collide with the
// "ch-composer-*" literals that appear in inline JS strings.
$owner = $this->get('/channel/' . $user->username)->getContent();
$this->assertStringContainsString('id="postForm"', $owner,
'Owner should see the composer form in normal view.');
$preview = $this->get('/channel/' . $user->username . '?preview=1')->getContent();
$this->assertStringNotContainsString('id="postForm"', $preview,
'Preview-as-visitor must hide the composer form.');
}
// ─── Playlist card: pluralise + hide stray "0.0" ──
public function test_playlist_card_pluralises_videos_and_hides_zero_views(): void
{
$owner = User::factory()->create([
'username' => 'pl-'.substr(bin2hex(random_bytes(3)), 0, 6),
]);
$this->actingAs($owner);
$pl = Playlist::create([
'user_id' => $owner->id,
'name' => 'Empty Playlist',
'visibility' => 'public',
'share_token'=> bin2hex(random_bytes(8)),
]);
$html = $this->get('/channel/' . $owner->username)->getContent();
// The specific bug: the view-count row ends with
// `<i class="bi bi-eye" aria-hidden="true"></i> 0.0`
// for a zero-view playlist. The fix hides the whole row for zero
// views, so the "bi-eye ...0.0" sequence should never appear.
$this->assertDoesNotMatchRegularExpression(
'/bi-eye[^<]{0,200}0\.0/',
$html,
'Playlist meta must not emit "bi-eye … 0.0" for zero-view playlists.'
);
// Plural must switch to singular for 1 video. Never "1 videos".
$this->assertDoesNotMatchRegularExpression('/\b1 videos\b/i', $html);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\VerifyCsrfToken;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* Phase 7 — Admin, a11y, consistency.
*
* Covers the skip-to-content link, icon-button aria-label coverage, the
* server-side age gate on register, and the branded 404 title format.
*/
class Phase7AdminA11yTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── Skip link + icon-button aria-labels ──────────
public function test_every_page_ships_the_skip_to_content_link(): void
{
$r = $this->get('/')->assertOk();
$r->assertSee('skip-to-content', false);
$r->assertSee('href="#main"', false);
}
public function test_header_icon_buttons_have_aria_labels(): void
{
$html = $this->get('/')->getContent();
// Hamburger menu
$this->assertMatchesRegularExpression(
'/<button[^>]+class="yt-menu-btn"[^>]+aria-label="[^"]+"/',
$html,
'The hamburger menu button must carry an aria-label.'
);
// Mobile search toggle
$this->assertMatchesRegularExpression(
'/<button[^>]+yt-icon-btn[^>]+onclick="toggleMobileSearch[^>]+aria-label="[^"]+"/',
$html,
'The mobile search toggle must carry an aria-label.'
);
}
// ─── Age gate on registration ─────────────────────
public function test_registration_rejects_under_13(): void
{
$r = $this->post(route('register.store'), [
'name' => 'Too Young',
'email' => '[email protected]',
'password' => 'HelloHello123!',
'password_confirmation' => 'HelloHello123!',
'birthday' => now()->subYears(10)->toDateString(),
'gender' => 'male',
'nationality' => 'BH',
]);
$r->assertSessionHasErrors('birthday');
$this->assertDatabaseMissing('users', ['email' => '[email protected]']);
}
public function test_registration_accepts_at_least_13(): void
{
$r = $this->post(route('register.store'), [
'name' => 'Just Old Enough',
'email' => '[email protected]',
'password' => 'HelloHello123!',
'password_confirmation' => 'HelloHello123!',
'birthday' => now()->subYears(14)->toDateString(),
'gender' => 'male',
'nationality' => 'BH',
]);
$r->assertSessionHasNoErrors();
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
}
// ─── Title format consistency ─────────────────────
public function test_branded_404_body_names_the_error(): void
{
// The exception handler in the testing env renders through a different
// view resolution path than production (it bypasses layouts.app's head
// section), so matching the <title> tag here is unreliable. The
// body content is deterministic and sufficient to prove the branded
// 404 is live — the production title format is smoke-tested via
// curl in the deploy checklist.
$this->get('/no-such-page')
->assertStatus(404)
->assertSee('Error 404', false);
}
// ─── Gender select: no duplicate labels ───────────
public function test_settings_gender_select_has_distinct_options(): void
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$this->actingAs($user);
$html = $this->get('/channel/' . $user->username . '#settings')->getContent();
// The empty-value option used to say "Prefer not to say" too — one
// label, two choices. It now says "Select…".
$this->assertMatchesRegularExpression(
'/<option value="">Select…<\/option>/u',
$html,
'Empty-value gender option should read "Select…", not "Prefer not to say".'
);
}
}
+35
View File
@@ -7,4 +7,39 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
use CreatesApplication; use CreatesApplication;
/**
* Belt-and-braces guard against a repeat of the 2026-10-21 incident, where
* a test with the RefreshDatabase trait ran migrate:fresh against the
* shared dev SQLite file and wiped every row.
*
* By the time setUp() runs we know the final DB config. If it points at
* anything other than an in-memory SQLite (or a file inside the test
* directory), we abort the whole run before any migration can execute.
*/
protected function setUp(): void
{
parent::setUp();
$this->assertSafeTestDatabase();
}
private function assertSafeTestDatabase(): void
{
$driver = config('database.default');
$database = config("database.connections.{$driver}.database");
$allowed = $database === ':memory:'
|| $database === null
|| str_contains((string) $database, '/testing')
|| str_contains((string) $database, '/tmp/')
|| str_ends_with((string) $database, '.testing.sqlite');
if (! $allowed) {
// Fatal, not an assertion failure — a wrong DB target has to stop
// execution before Laravel can run even one query against it.
fwrite(STDERR, "\n\n*** ABORTING TESTS: unsafe DB target '{$database}' on connection '{$driver}'. ***\n");
fwrite(STDERR, "Set DB_CONNECTION=sqlite and DB_DATABASE=:memory: in .env.testing (or in your test's setUp before parent::setUp).\n\n");
exit(2);
}
}
} }