Compare commits

..

10 Commits

Author SHA1 Message Date
khannurien
fb8364e24d v3: reworked posting — three panels, drafts, duplicate detection, upload progress
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Posting a dump was a single form where the important choices were the easiest
to miss. It is now three panels: link or file, why & where, playlists.

Composition:
- No more URL/File toggle. An empty panel offers both ways in at once and the
  kind follows what you actually did; a file dropped anywhere in the modal is
  accepted, not just on the zone.
- Categories and visibility get their own panel instead of a disclosure that
  read as optional, and the primary button stays "Next" until they've been
  seen. Visibility carries a real label now.
- The draft (link, title, why, categories, visibility) is mirrored to
  localStorage on every change and restored on reopen, so Escape or a stray
  backdrop click costs nothing. Only an attached file can't be restored, so
  that is the one case that asks before closing.
- URL dumps can carry a poster-supplied title instead of being stuck with
  whatever the page scraped, editable right under the preview.
- Multipart uploads go through XHR so there is a real progress bar and a
  percentage on the button, rather than 50 MB of silence.

Duplicates:
- New dumps.url_canonical column (+ index, backfilled by 0013) holding a lossy
  key that ignores scheme, www., trailing slashes, tracking parameters and
  YouTube share shapes. GET /api/dumps/by-url reads it, and the create form
  warns "already dumped by X" while it fetches the preview. Never blocking.

Fixes:
- /api/preview now reports whether the page was actually reached: a failed
  fetch still yields a hostname-only stub, so a dead link and a page without
  metadata used to render identically.
- The Web Share Target never worked. The manifest posts to "/", but the index
  redirect dropped the query string, so every Android share landed on the feed
  with nothing pre-filled.
- File dumps no longer take the extension into their title.
- The link field no longer autofocuses on touch, where it raised a keyboard
  over the modal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiAPtJZeCYYk8rKehtLUQU
2026-09-08 14:40:37 +00:00
khannurien
d76154d15d v3: keep pasted description images out of the dump kind and the card teasers
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
The dump modal's window-level paste listener looked at the clipboard files
before it looked at the event target, so an image pasted into the "Why?"
field was claimed twice: the editor uploaded it as an inline attachment,
and the modal switched itself to file mode with that same image as the
dump. Writing a URL dump with an illustrated description was impossible.
The target guard that already existed for text pastes now runs first and
covers files too — fields own their paste, and the URL input keeps its own
handler for the image-to-file-dump shortcut. Pasting onto the modal body,
where nothing else is listening, still starts a file dump.

Card teasers are line-clamped boxes, which an embedded image blows apart.
Inline markdown — the mode the dump and journal cards already ask for —
now drops img nodes, so a description image shows up on the detail page
and nowhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiAPtJZeCYYk8rKehtLUQU
2026-09-08 12:43:59 +00:00
khannurien
79b7adce8f v3: fix the queue and re-resolve edge cases in native bandcamp playback
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m59s
Re-resolving a stream is a network round-trip, and nothing checked that the
player still belonged to that item once it landed. Picking another dump —
or closing the player — while a resolve was in flight let the stale result
swap the queue back; from onError, which resolves with autoplay, it would
also start playing over whatever was chosen instead. A generation counter,
bumped by playQueue/advanceTo/stop, now drops any result that no longer owns
the player, with a separate counter owning the resolving flag so a
superseded resolve can't clear a newer one's spinner.

A track that stays dead after a re-resolve is one track, not one album: the
cooldown path now steps to the next entry and only falls back to the iframe
on the last one. A failed resolve still goes straight to the embed, since
that's an album-wide failure rather than a single bad URL. And a track that
has vanished from the release, or lost its streamable flag to Bandcamp's
free-play cap, no longer resumes track 1 at the dead track's offset — the
findIndex miss resets the offset instead of seeking past the end of another
track.

Clicking the playing row rewound it but never resumed: seekTo only moves
currentTime, so re-selecting a finished or paused track looked like a
no-op. It now resumes as well. Same idea in the compact rich-content card,
whose button advertises "Pause" while active but re-played on click — in
native mode that meant a fresh /api/bandcamp/tracks and a restart from
track 1. It pauses for streams now; embeds have no transport of their own
and keep restarting, as before.

playRichContent reports whether playback actually started, so a native-mode
Bandcamp page with no streams and no stored embedUrl — an artist root, a
/music index, a preorder — is no longer a dead click: the journal card
navigates to the dump as it used to, and the rich-content card opens the
source page.

Also: the tralbum fetch releases the response body on its two error paths.
The endpoint is unauthenticated, so random /track/<slug> URLs would leak a
connection per request while also defeating the cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E56F55N7m5GGKKEwoYFJtH
2026-08-30 17:29:14 +00:00
khannurien
2606ef4ccc v3: native bandcamp playback and a queue in the global player
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
The player now holds a queue instead of a single item, so albums play
through and single tracks share the same code path. Adds a "stream" item
kind for expiring, remotely-hosted sources: Bandcamp signs its mp3 URLs
for ~24h, so an item carries the page it came from and re-resolves itself
on error or on a stale restored session, falling back to the iframe embed
if that fails too.

Gated behind GERBEUR_BANDCAMP_PLAYER (default "embed"), injected into a
meta tag the same way as site-name/site-emoji.

Also: player header gains artwork and a subtitle, volume persists across
queue advances and reloads, and cross-origin streams get a plain seek bar
rather than a waveform that can never decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SbtjNsT5wvuABnfqhegZEJ
2026-08-22 12:14:28 +00:00
khannurien
eb323a8ba8 v3: try to autoplay media opened in global player
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s
2026-08-22 09:25:57 +00:00
khannurien
89314b1b6e v3: journal now shows the generated still for video dumps, like the hot/new feeds already did
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
The hot/new feeds go through FilePreview, which asks for
GET /api/thumbnails/:dumpId unconditionally on a video mime and lets VideoThumb
fall back to an icon if it 404s. The journal instead only recognised image files
and dump.thumbnailMime — and that field maps to custom_thumbnail_mime, i.e. a
thumbnail somebody uploaded by hand. Nothing on the Dump object advertises the
ffmpeg still the route generates on demand, so hasThumbnail() returned false,
mode degraded to text, and the card rendered a 🎬.

hasThumbnail() and JournalCard's thumbnail resolution now both treat a video
file as art, resolving to the same route. Since hasThumbnail() also gates grid
footprints, video dumps can now claim feature/tall/wide slots — the mosaic has
noticeably more image cards as a result, which is the point.

ThumbnailPlaceholder gains `glyph` and `seed`: a host without ffmpeg gets a 404
and lands on the placeholder, where the mime emoji says more about the dump than
an initial taken from its filename, and the hue is seeded from the filename
since there's no hostname to hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:04:09 +00:00
khannurien
7303509b6f v3: fix migration 0011 mistaking real og:images for favicons
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
0011 tested for "favicon" anywhere in the path, so mirtitles.org's declared
og:image — /wp-content/uploads/2022/04/mir-logo-favicon.png — was moved into
faviconUrl and its thumbnailUrl cleared. Without a thumbnail, hasThumbnail() is
false and the journal mosaic demoted the dump from an image card to a
pull-quote. A blanket .svg match did the same to real artwork (tanibis.net's
header.svg).

0012 judges an icon by its filename rather than the whole path: named
favicon…/apple-touch-icon…/icon…, living in an icons/ directory, or ending in
.ico. That spares mir-logo-favicon.png and also catches icon_SEARCH.png, which
0011 missed. It runs in both directions, and is deliberately narrow when
reversing — a value is only promoted back to thumbnailUrl if it matched 0011's
rule but not this one, so rows written by a normal fetch aren't disturbed.

On the dev database: 4 thumbnails restored, 1 icon reclassified, real favicons
left alone, and a second pass rewrites nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:51:49 +00:00
khannurien
1cb904d2cf v3: generated placeholder thumbnails for pages with no preview image, and a fix for hotlink-protected ones
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Thumbnails were failing in two different ways that both ended as an empty box.

Cloudflare hotlink protection answers a cross-site Referer with 403, so images
we had extracted correctly (dles.aukspot.com's og:image among them) never
rendered — every onError handler set display:none and swallowed it. The new
Thumbnail component loads with referrerPolicy="no-referrer", retries once
through /api/proxy-image for hosts that reject an empty referrer too, and only
then falls back to a placeholder.

Separately, the extraction cascade ended at the page's icon and then a guessed
/favicon.ico, so thumbnailUrl was almost never empty — just a 16x16 icon
cover-cropped into a 128x72 box. It now stops at real artwork, with faviconUrl
and accentColor (theme-color / msapplication-TileColor / mask-icon) as their own
fields. An absent thumbnailUrl finally means "no artwork", which is what makes
the placeholder possible: the site's own color mixed into the theme surface,
with its favicon centered on it, or its initial. Contrast holds for any
third-party color by construction rather than by luminance math, so nyt only
has to set --thumb-tint-strength to 0% to stay monochrome and geocities only
has to raise it. Missing accents fall back to a stable hostname-derived hue, so
rows saved before this get a tint with no backfill.

Migration 0011 reclassifies favicon-shaped thumbnailUrls on existing dumps.
The journal mosaic keeps its pull-quote and text fallbacks — the placeholder
appears there only to repair a broken image.

Also fixed: refresh silently overwrote good metadata with a failure stub, the
refresh button swallowed every error, refresh never broadcast the update,
extractBestIcon ranked SVG icons below 16x16 PNGs, and shared links with no
artwork carried no og:image at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:43:55 +00:00
khannurien
0e138be6df v3: youtube embeds now honour the timestamp in the source url, with a migration backfilling existing dumps
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:47:26 +00:00
khannurien
a9b94c0bfb v3: added an "in collections" section to dump pages, listing the collections a dump belongs to
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 11:41:36 +00:00
64 changed files with 4441 additions and 677 deletions

View File

@@ -14,6 +14,15 @@ GERBEUR_SITE_NAME=gerbeur
# (server startup), so changing it only needs a restart — no rebuild. # (server startup), so changing it only needs a restart — no rebuild.
GERBEUR_SITE_EMOJI=🚚 GERBEUR_SITE_EMOJI=🚚
# How Bandcamp links are played.
# embed (default) the bandcamp.com iframe — cannot autoplay
# native resolve the page's mp3 streams and play them in gerbeur's
# own player: autoplay, seeking, and albums as a queue.
# Note this bypasses Bandcamp's player, so their play counts
# and 3-play preview limit do not apply.
# Applied at runtime; changing it needs a restart of both the API and Vite.
GERBEUR_BANDCAMP_PLAYER=embed
# Port the API server listens on (the container's internal port). # Port the API server listens on (the container's internal port).
GERBEUR_PORT=8000 GERBEUR_PORT=8000

View File

@@ -98,6 +98,17 @@ export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
// only needs a restart — no rebuild. // only needs a restart — no rebuild.
export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
// Which Bandcamp playback path the frontend uses.
// "embed" — the bandcamp.com iframe (cannot autoplay)
// "native" — resolve the page's mp3 streams and play them in the app's own
// player, which autoplays and supports albums as a queue
// Anything unrecognised falls back to "embed", so a typo degrades safely.
export type BandcampPlayer = "embed" | "native";
export const BANDCAMP_PLAYER: BandcampPlayer =
Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native"
? "native"
: "embed";
// Background color for generated icons and the manifest. Mirrors the // Background color for generated icons and the manifest. Mirrors the
// hard-coded theme-color in index.html. // hard-coded theme-color in index.html.
export const THEME_COLOR = "#111827"; export const THEME_COLOR = "#111827";

View File

@@ -8,6 +8,10 @@ import { up as up0006Categories } from "./migrations/0006_categories.ts";
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts"; import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts"; import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts"; import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
interface Migration { interface Migration {
name: string; name: string;
@@ -27,6 +31,13 @@ const MIGRATIONS: Migration[] = [
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens }, { name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
{ name: "0008_chat_messages", up: up0008ChatMessages }, { name: "0008_chat_messages", up: up0008ChatMessages },
{ name: "0009_chat_reply", up: up0009ChatReply }, { name: "0009_chat_reply", up: up0009ChatReply },
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
{ name: "0011_split_favicon_thumbnail", up: up0011SplitFaviconThumbnail },
{
name: "0012_fix_favicon_reclassification",
up: up0012FixFaviconReclassification,
},
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
]; ];
export function runMigrations(db: DatabaseSync): void { export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,76 @@
import type { DatabaseSync } from "node:sqlite";
// Backfills the `start` parameter on stored YouTube embed URLs.
//
// `rich_content.embedUrl` is computed once, at dump creation, and previously
// dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so
// those videos always opened at 0:00 in the global player. The provider now
// translates `t`/`start` into the `start` param the iframe player honours; this
// migration applies the same translation to rows written before that fix.
//
// Purely local — it re-derives the param from the already-stored source URL and
// makes no network calls. Self-contained (no imports from api/services) so it
// stays reproducible even if the provider's parsing evolves later.
//
// Idempotent: rows whose embed URL already carries a `start` are skipped, so a
// fresh database built from schema.sql is simply a no-op.
/** Parse `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
function extractStartSeconds(url: string): number | null {
let raw: string | null;
try {
const u = new URL(url);
raw = u.searchParams.get("t") ?? u.searchParams.get("start");
} catch {
return null;
}
if (!raw) return null;
if (/^\d+$/.test(raw)) return Number(raw);
const hms = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i.exec(raw);
if (!hms || (!hms[1] && !hms[2] && !hms[3])) return null;
return Number(hms[1] ?? 0) * 3600 + Number(hms[2] ?? 0) * 60 +
Number(hms[3] ?? 0);
}
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, url, rich_content FROM dumps
WHERE kind = 'url' AND url IS NOT NULL AND rich_content IS NOT NULL;`,
).all() as { id: string; url: string; rich_content: string }[];
const update = db.prepare(
`UPDATE dumps SET rich_content = ? WHERE id = ?;`,
);
let patched = 0;
for (const row of rows) {
let rich: { type?: string; embedUrl?: string };
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
}
if (rich.type !== "youtube" || !rich.embedUrl) continue;
const start = extractStartSeconds(row.url);
if (!start) continue;
let embed: URL;
try {
embed = new URL(rich.embedUrl);
} catch {
continue;
}
if (embed.searchParams.has("start")) continue;
embed.searchParams.set("start", String(start));
update.run(JSON.stringify({ ...rich, embedUrl: embed.toString() }), row.id);
patched++;
}
if (patched > 0) {
console.log(`[migrate] 0010: added start offset to ${patched} embed(s)`);
}
}

View File

@@ -0,0 +1,74 @@
import type { DatabaseSync } from "node:sqlite";
// Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
// `faviconUrl` field.
//
// The extraction cascade used to end at the page's icon and then at a guessed
// `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just
// held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72
// box. The cascade now stops at real artwork, and an absent `thumbnailUrl`
// means "no artwork", which is what lets the frontend draw a placeholder.
// This migration gives rows written before that change the same meaning.
//
// Purely local — it classifies the already-stored URL and makes no network
// calls, so `accentColor` is deliberately not backfilled: the frontend derives
// a stable hue from the hostname whenever one is missing, and
// `refreshDumpMetadata` fetches the real color on demand.
//
// Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh
// database built from schema.sql is a no-op.
/**
* Whether a stored thumbnail URL is really a site icon.
*
* Deliberately loose. A false positive (a genuine cover image living under
* `/assets/icons/`) renders contained on a tinted field instead of
* cover-cropped — mildly wrong, never broken — so chasing them isn't worth the
* extra rules.
*/
function isIconUrl(raw: string): boolean {
let pathname: string;
try {
pathname = new URL(raw).pathname;
} catch {
return false;
}
return /favicon|apple-touch-icon|\/icons?\//i.test(pathname) ||
/\.(ico|svg)$/i.test(pathname);
}
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, rich_content FROM dumps
WHERE kind = 'url' AND rich_content IS NOT NULL;`,
).all() as { id: string; rich_content: string }[];
const update = db.prepare(
`UPDATE dumps SET rich_content = ? WHERE id = ?;`,
);
let patched = 0;
for (const row of rows) {
let rich: { thumbnailUrl?: string; faviconUrl?: string };
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
}
if (rich.faviconUrl || !rich.thumbnailUrl) continue;
if (!isIconUrl(rich.thumbnailUrl)) continue;
const { thumbnailUrl: _dropped, ...rest } = rich;
update.run(
JSON.stringify({ ...rest, faviconUrl: rich.thumbnailUrl }),
row.id,
);
patched++;
}
if (patched > 0) {
console.log(
`[migrate] 0011: reclassified ${patched} favicon thumbnail(s)`,
);
}
}

View File

@@ -0,0 +1,100 @@
import type { DatabaseSync } from "node:sqlite";
// Repairs 0011, which was too eager about what counts as a site icon.
//
// 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image`
// like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was
// moved into `faviconUrl` and its `thumbnailUrl` cleared — which also demoted
// the dump from an image card to a pull-quote in the journal mosaic. A blanket
// `.svg` match had the same effect on real artwork.
//
// `isIconUrl` here looks at the *filename* rather than the whole path: an icon
// is named `favicon…`, `apple-touch-icon…` or `icon…`, lives in an `icons/`
// directory, or ends in `.ico`. That both spares `mir-logo-favicon.png` and
// catches `icon_SEARCH.png`, which 0011 missed.
//
// Applied in both directions, and deliberately narrow when reversing: a value
// is only promoted back to `thumbnailUrl` if it matches 0011's rule but not
// this one, so rows written by a normal fetch aren't disturbed.
/** 0011's rule, kept verbatim so its false positives can be identified. */
function wasIconUrl0011(raw: string): boolean {
let pathname: string;
try {
pathname = new URL(raw).pathname;
} catch {
return false;
}
return /favicon|apple-touch-icon|\/icons?\//i.test(pathname) ||
/\.(ico|svg)$/i.test(pathname);
}
/** Whether a URL names a site icon, judged on its filename and directory. */
function isIconUrl(raw: string): boolean {
let pathname: string;
try {
pathname = new URL(raw).pathname;
} catch {
return false;
}
const segments = pathname.split("/").filter(Boolean);
const filename = segments.pop() ?? "";
if (/\.ico$/i.test(filename)) return true;
if (segments.some((s) => /^(favicons?|icons?)$/i.test(s))) return true;
const basename = filename.replace(/\.[^.]+$/, "");
return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename);
}
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, rich_content FROM dumps
WHERE kind = 'url' AND rich_content IS NOT NULL;`,
).all() as { id: string; rich_content: string }[];
const update = db.prepare(
`UPDATE dumps SET rich_content = ? WHERE id = ?;`,
);
let restored = 0;
let reclassified = 0;
for (const row of rows) {
let rich: { thumbnailUrl?: string; faviconUrl?: string };
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
}
// Artwork 0011 mistook for an icon: put it back.
if (
rich.faviconUrl && !rich.thumbnailUrl &&
wasIconUrl0011(rich.faviconUrl) && !isIconUrl(rich.faviconUrl)
) {
const { faviconUrl: _dropped, ...rest } = rich;
update.run(
JSON.stringify({ ...rest, thumbnailUrl: rich.faviconUrl }),
row.id,
);
restored++;
continue;
}
// An icon 0011's rule didn't recognize: move it now.
if (
rich.thumbnailUrl && !rich.faviconUrl && isIconUrl(rich.thumbnailUrl)
) {
const { thumbnailUrl: _dropped, ...rest } = rich;
update.run(
JSON.stringify({ ...rest, faviconUrl: rich.thumbnailUrl }),
row.id,
);
reclassified++;
}
}
if (restored > 0 || reclassified > 0) {
console.log(
`[migrate] 0012: restored ${restored} thumbnail(s), reclassified ${reclassified} icon(s)`,
);
}
}

View File

@@ -0,0 +1,119 @@
import type { DatabaseSync } from "node:sqlite";
// Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
// check on the create form — and backfills it for every existing URL dump.
//
// Purely local: it re-derives the key from the URL already stored on each row
// and makes no network calls.
//
// The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts`
// as it stood when this migration shipped, not an import of it. A migration
// runs exactly once per database, so importing the live version would mean two
// databases migrating at different times end up with keys computed under
// different rules. Improving the shared canonicalizer therefore calls for a new
// backfill migration rather than an edit here.
//
// Idempotent: the column is only added when missing and only rows whose key is
// still NULL are touched, so a fresh database built from schema.sql is a no-op.
const TRACKING_PARAMS = new Set([
"fbclid",
"gclid",
"dclid",
"msclkid",
"twclid",
"yclid",
"mc_cid",
"mc_eid",
"igshid",
"igsh",
"si",
"spm",
"ref_src",
"ref_url",
"_ga",
"_gl",
"__twitter_impression",
]);
function isTrackingParam(key: string): boolean {
const k = key.toLowerCase();
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
}
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtube-nocookie.com",
]);
function youtubeVideoId(
host: string,
pathname: string,
params: URLSearchParams,
): string | null {
if (host === "youtu.be") return pathname.split("/")[1] || null;
if (!YOUTUBE_HOSTS.has(host)) return null;
if (pathname === "/watch") return params.get("v");
if (/^\/(embed|shorts|live)\//.test(pathname)) {
return pathname.split("/")[2] || null;
}
return null;
}
function canonicalizeUrl(raw: string): string | null {
let u: URL;
try {
u = new URL(raw);
} catch {
return null;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const host = u.hostname.toLowerCase().replace(/^www\./, "");
if (!host) return null;
const videoId = youtubeVideoId(host, u.pathname, u.searchParams);
if (videoId) return `https://youtube.com/watch?v=${videoId}`;
const listId = YOUTUBE_HOSTS.has(host) && u.pathname === "/playlist"
? u.searchParams.get("list")
: null;
if (listId) return `https://youtube.com/playlist?list=${listId}`;
const path = u.pathname.replace(/\/+$/, "");
const params = [...u.searchParams.entries()]
.filter(([key]) => !isTrackingParam(key))
.sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
const query = new URLSearchParams(params).toString();
const hash = /^#!?\//.test(u.hash) ? u.hash : "";
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
}
export function up(db: DatabaseSync): void {
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string;
}[];
if (!columns.some((c) => c.name === "url_canonical")) {
db.exec(`ALTER TABLE dumps ADD COLUMN url_canonical TEXT;`);
}
db.exec(
`CREATE INDEX IF NOT EXISTS idx_dumps_url_canonical ON dumps(url_canonical);`,
);
const rows = db.prepare(
`SELECT id, url FROM dumps WHERE url IS NOT NULL AND url_canonical IS NULL;`,
).all() as { id: string; url: string }[];
const update = db.prepare(
`UPDATE dumps SET url_canonical = ? WHERE id = ?;`,
);
for (const row of rows) {
const canonical = canonicalizeUrl(row.url);
if (canonical) update.run(canonical, row.id);
}
}

View File

@@ -7,6 +7,8 @@ CREATE TABLE dumps (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT, updated_at TEXT,
url TEXT, url TEXT,
-- Lossy lookup key for "has this been dumped already?" — see api/lib/canonical-url.ts
url_canonical TEXT,
slug TEXT, slug TEXT,
rich_content TEXT, rich_content TEXT,
file_name TEXT, file_name TEXT,
@@ -117,6 +119,7 @@ CREATE TABLE dump_backlinks (
CREATE INDEX idx_dumps_user ON dumps(user_id); CREATE INDEX idx_dumps_user ON dumps(user_id);
CREATE INDEX idx_dumps_url ON dumps(url); CREATE INDEX idx_dumps_url ON dumps(url);
CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical);
CREATE INDEX idx_votes_user ON votes(user_id); CREATE INDEX idx_votes_user ON votes(user_id);
CREATE INDEX idx_playlists_user ON playlists(user_id); CREATE INDEX idx_playlists_user ON playlists(user_id);
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position); CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);

104
api/lib/canonical-url.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* Canonical form of a URL, used *only* to recognise that two links point at the
* same thing — the duplicate check the create form runs while it fetches a
* preview. It is a lookup key, never something we display or fetch: `dumps.url`
* keeps the exact string the poster submitted.
*
* Because it is only ever compared against other canonical forms, it may be
* lossy in ways a real URL never could be — it forces `https`, drops `www.`,
* and throws away share/tracking parameters and timestamps, so
* `http://www.example.com/a/?utm_source=x` and `https://example.com/a` collapse
* to one key.
*
* Stored in `dumps.url_canonical`. Migration 0013 carries a frozen copy of this
* logic to backfill existing rows; changing the rules here therefore needs a
* new backfill migration, or old rows keep keys computed under the old rules.
*/
const TRACKING_PARAMS = new Set([
"fbclid",
"gclid",
"dclid",
"msclkid",
"twclid",
"yclid",
"mc_cid",
"mc_eid",
"igshid",
"igsh",
"si",
"spm",
"ref_src",
"ref_url",
"_ga",
"_gl",
"__twitter_impression",
]);
function isTrackingParam(key: string): boolean {
const k = key.toLowerCase();
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
}
// Hosts already stripped of a leading "www.".
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtube-nocookie.com",
]);
/**
* The video a YouTube URL points at, in any of the shapes people paste
* (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, `/live/ID`).
* Timestamps are deliberately ignored: the same video linked at 2:30 is still
* the same video for duplicate purposes.
*/
function youtubeVideoId(
host: string,
pathname: string,
params: URLSearchParams,
): string | null {
if (host === "youtu.be") return pathname.split("/")[1] || null;
if (!YOUTUBE_HOSTS.has(host)) return null;
if (pathname === "/watch") return params.get("v");
if (/^\/(embed|shorts|live)\//.test(pathname)) {
return pathname.split("/")[2] || null;
}
return null;
}
export function canonicalizeUrl(raw: string): string | null {
let u: URL;
try {
u = new URL(raw);
} catch {
return null;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const host = u.hostname.toLowerCase().replace(/^www\./, "");
if (!host) return null;
const videoId = youtubeVideoId(host, u.pathname, u.searchParams);
if (videoId) return `https://youtube.com/watch?v=${videoId}`;
const listId = YOUTUBE_HOSTS.has(host) && u.pathname === "/playlist"
? u.searchParams.get("list")
: null;
if (listId) return `https://youtube.com/playlist?list=${listId}`;
// A single trailing slash is never meaningful; "/" itself becomes "".
const path = u.pathname.replace(/\/+$/, "");
const params = [...u.searchParams.entries()]
.filter(([key]) => !isTrackingParam(key))
.sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
const query = new URLSearchParams(params).toString();
// A bare "#section" anchor points into the same page, so it is dropped —
// but "#/route" and "#!/route" address distinct pages of a hash-routed app.
const hash = /^#!?\//.test(u.hash) ? u.hash : "";
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
}

View File

@@ -1,5 +1,5 @@
import { Context, Next, send } from "@oak/oak"; import { Context, Next, send } from "@oak/oak";
import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "./site-icons.ts"; import { emojiToCodepoint } from "./site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
@@ -13,7 +13,8 @@ async function serveIndexHtml(
const html = raw const html = raw
.replaceAll("__SITE_NAME__", OG_SITE_NAME) .replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION); .replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
context.response.type = "text/html"; context.response.type = "text/html";
context.response.body = html; context.response.body = html;
} }

View File

@@ -9,6 +9,7 @@ import usersRouter from "./routes/users.ts";
import avatarsRouter from "./routes/avatars.ts"; import avatarsRouter from "./routes/avatars.ts";
import wsRouter from "./routes/ws.ts"; import wsRouter from "./routes/ws.ts";
import previewRouter from "./routes/preview.ts"; import previewRouter from "./routes/preview.ts";
import bandcampRouter from "./routes/bandcamp.ts";
import playlistsRouter from "./routes/playlists.ts"; import playlistsRouter from "./routes/playlists.ts";
import commentsRouter from "./routes/comments.ts"; import commentsRouter from "./routes/comments.ts";
import chatRouter from "./routes/chat.ts"; import chatRouter from "./routes/chat.ts";
@@ -71,6 +72,10 @@ app.use(
previewRouter.routes(), previewRouter.routes(),
previewRouter.allowedMethods(), previewRouter.allowedMethods(),
); );
app.use(
bandcampRouter.routes(),
bandcampRouter.allowedMethods(),
);
app.use( app.use(
playlistsRouter.routes(), playlistsRouter.routes(),
playlistsRouter.allowedMethods(), playlistsRouter.allowedMethods(),

View File

@@ -2,7 +2,7 @@ import { Context, Next } from "@oak/oak";
import { getDump } from "../services/dump-service.ts"; import { getDump } from "../services/dump-service.ts";
import { getUserByUsername } from "../services/user-service.ts"; import { getUserByUsername } from "../services/user-service.ts";
import { getPlaylistById } from "../services/playlist-service.ts"; import { getPlaylistById } from "../services/playlist-service.ts";
import { OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "../lib/site-icons.ts"; import { emojiToCodepoint } from "../lib/site-icons.ts";
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
@@ -11,6 +11,8 @@ interface OGMeta {
title: string; title: string;
description?: string; description?: string;
imageUrl?: string; imageUrl?: string;
/** True only for real artwork — a square site icon must not claim a wide card. */
imageIsWide?: boolean;
url: string; url: string;
} }
@@ -23,7 +25,9 @@ function escapeAttr(s: string): string {
} }
function buildTags(meta: OGMeta): string { function buildTags(meta: OGMeta): string {
const card = meta.imageUrl ? "summary_large_image" : "summary"; const card = meta.imageUrl && meta.imageIsWide
? "summary_large_image"
: "summary";
const tags = [ const tags = [
`<title>${escapeAttr(meta.title)}</title>`, `<title>${escapeAttr(meta.title)}</title>`,
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`, `<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
@@ -65,7 +69,8 @@ async function loadIndexHtml(): Promise<string | null> {
cachedHtml = (await Deno.readTextFile(path)) cachedHtml = (await Deno.readTextFile(path))
.replaceAll("__SITE_NAME__", OG_SITE_NAME) .replaceAll("__SITE_NAME__", OG_SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION); .replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
return cachedHtml; return cachedHtml;
} catch { } catch {
continue; continue;
@@ -105,10 +110,14 @@ export async function ogMiddleware(ctx: Context, next: Next) {
} else if (dump.richContent?.thumbnailUrl) { } else if (dump.richContent?.thumbnailUrl) {
imageUrl = dump.richContent.thumbnailUrl; imageUrl = dump.richContent.thumbnailUrl;
} }
// No artwork: fall back to our own site icon rather than the target's
// favicon, which is typically too small to survive a social card (and may
// be hotlink-protected).
meta = { meta = {
title: dump.title, title: dump.title,
description: dump.comment, description: dump.comment,
imageUrl, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
imageIsWide: !!imageUrl,
url: pageUrl, url: pageUrl,
}; };
} catch { /* not found or private — serve default */ } } catch { /* not found or private — serve default */ }
@@ -121,7 +130,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
meta = { meta = {
title: user.username, title: user.username,
description: user.description, description: user.description,
imageUrl, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
url: pageUrl, url: pageUrl,
}; };
} catch { /* not found */ } } catch { /* not found */ }
@@ -135,7 +144,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
meta = { meta = {
title: playlist.title, title: playlist.title,
description: playlist.description, description: playlist.description,
imageUrl, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
url: pageUrl, url: pageUrl,
}; };
} }

View File

@@ -10,7 +10,16 @@ export interface RichContent {
siteName?: string; siteName?: string;
title?: string; title?: string;
description?: string; description?: string;
/**
* A real preview image (og:image, a large content image, a video still).
* Never a favicon — an absent value means "this page offers no artwork",
* which is what makes the generated placeholder possible.
*/
thumbnailUrl?: string; thumbnailUrl?: string;
/** The page's own icon, used as the placeholder's glyph. */
faviconUrl?: string;
/** The page's declared brand color, normalized to `#rrggbb`. */
accentColor?: string;
videoId?: string; videoId?: string;
embedUrl?: string; embedUrl?: string;
} }
@@ -470,11 +479,27 @@ function isStringArray(value: unknown): value is string[] {
export interface CreateUrlDumpRequest { export interface CreateUrlDumpRequest {
url: string; url: string;
/** Overrides the title scraped from the page — the poster edited the preview. */
title?: string;
comment?: string; comment?: string;
isPrivate?: boolean; isPrivate?: boolean;
categoryIds?: string[]; categoryIds?: string[];
} }
/**
* An existing dump pointing at the same URL, as shown by the create form's
* duplicate hint. Display-only projection — see `findDumpsByUrl`.
*/
export interface DumpUrlMatch {
id: string;
slug?: string;
title: string;
username: string;
createdAt: Date;
voteCount: number;
commentCount: number;
}
export function isCreateUrlDumpRequest( export function isCreateUrlDumpRequest(
obj: unknown, obj: unknown,
): obj is CreateUrlDumpRequest { ): obj is CreateUrlDumpRequest {
@@ -490,6 +515,13 @@ export function isCreateUrlDumpRequest(
typeof o.comment === "string" && typeof o.comment === "string" &&
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX (o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false; ) return false;
if ("title" in o && typeof o.title !== "string" && o.title !== null) {
return false;
}
if (
typeof o.title === "string" &&
(o.title as string).length > VALIDATION.DUMP_TITLE_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false; if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false; if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true; return true;

26
api/routes/bandcamp.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Router } from "@oak/oak";
import { resolveBandcamp } from "../services/bandcamp-stream-service.ts";
const bandcampRouter = new Router();
/**
* Resolve a Bandcamp page to its streamable tracks.
*
* Public, like /api/preview: it only reads a public page and returns URLs the
* same page already hands any visitor. Errors are thrown as APIException and
* rendered by errorMiddleware; the client treats any failure as "fall back to
* the iframe embed".
*/
bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
const force = ctx.request.url.searchParams.get("force") === "1";
const album = await resolveBandcamp(url, { force });
// Private: the signed URLs in the body are per-fetch, so they must not be
// held in a shared cache. Well inside the 24h signature life either way.
ctx.response.headers.set("Cache-Control", "private, max-age=3600");
ctx.response.body = { success: true, data: album };
});
export default bandcampRouter;

View File

@@ -5,9 +5,11 @@ import {
APIException, APIException,
type APIResponse, type APIResponse,
type Dump, type Dump,
type DumpUrlMatch,
isCreateUrlDumpRequest, isCreateUrlDumpRequest,
isUpdateDumpRequest, isUpdateDumpRequest,
type PaginatedData, type PaginatedData,
type Playlist,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
@@ -20,6 +22,7 @@ import {
createFileDump, createFileDump,
createUrlDump, createUrlDump,
deleteDump, deleteDump,
findDumpsByUrl,
getDump, getDump,
listDumps, listDumps,
refreshDumpMetadata, refreshDumpMetadata,
@@ -30,6 +33,7 @@ import {
} from "../services/dump-service.ts"; } from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts"; import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts";
import { getPlaylistsForDump } from "../services/playlist-service.ts";
const router = new Router({ prefix: "/api/dumps" }); const router = new Router({ prefix: "/api/dumps" });
@@ -98,6 +102,17 @@ router.post(
}, },
); );
// Registered ahead of "/:dumpId" so the literal path wins over the parameter.
router.get("/by-url", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const url = ctx.request.url.searchParams.get("url") ?? "";
const responseBody: APIResponse<DumpUrlMatch[]> = {
success: true,
data: findDumpsByUrl(url, requestingUserId),
};
ctx.response.body = responseBody;
});
router.get("/:dumpId", async (ctx) => { router.get("/:dumpId", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dump = getDump(ctx.params.dumpId, requestingUserId); const dump = getDump(ctx.params.dumpId, requestingUserId);
@@ -128,6 +143,18 @@ router.get("/:dumpId/related", async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
router.get("/:dumpId/playlists", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
// Resolve through getDump so private dumps 404 for anyone but their owner.
const dump = getDump(ctx.params.dumpId, requestingUserId);
const playlists = getPlaylistsForDump(dump.id, requestingUserId);
const responseBody: APIResponse<Playlist[]> = {
success: true,
data: playlists,
};
ctx.response.body = responseBody;
});
router.get("/", async (ctx) => { router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);

View File

@@ -1,8 +1,8 @@
import { Router } from "@oak/oak"; import { Router } from "@oak/oak";
import { import {
fetchRichContent,
fetchWithTimeout, fetchWithTimeout,
isValidHttpUrl, isValidHttpUrl,
tryFetchRichContent,
} from "../services/rich-content-service.ts"; } from "../services/rich-content-service.ts";
import { APIErrorCode } from "../model/interfaces.ts"; import { APIErrorCode } from "../model/interfaces.ts";
@@ -18,8 +18,15 @@ previewRouter.get("/api/preview", async (ctx) => {
}; };
return; return;
} }
const data = await fetchRichContent(url); // `reached` is reported separately because a failed fetch still yields a
ctx.response.body = { success: true, data: data ?? null }; // usable stub (hostname only). Without the flag the create form cannot tell
// "this page has no preview" from "this link is dead", and shows the same
// bare card for both.
const { ok, content } = await tryFetchRichContent(url);
ctx.response.body = {
success: true,
data: { reached: ok, richContent: content ?? null },
};
}); });
/** /**

View File

@@ -0,0 +1,138 @@
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { fetchWithTimeout, isValidHttpUrl } from "./rich-content-service.ts";
import {
BANDCAMP_HOST_RE,
parseTralbum,
type Tralbum,
} from "./bandcamp-tralbum.ts";
/**
* Resolves a Bandcamp page to its streamable tracks, at play time.
*
* Bandcamp's signed stream URLs expire 24h after the page fetch, so they can
* never be baked into `rich_content` at dump-creation time the way `embedUrl`
* is. This service is the play-time counterpart: the frontend asks for a page,
* gets a whole tracklist back, and plays the mp3s directly from bcbits.
*/
/** Comfortably inside the 24h signature life, so a cache hit is never a URL
* that is about to expire mid-listen. */
const TTL_MS = 12 * 60 * 60 * 1000;
const MAX_ENTRIES = 200;
const cache = new Map<string, Tralbum>();
/** Cache key: a page is the same page regardless of query, fragment or case. */
function cacheKey(url: string): string {
try {
const u = new URL(url);
return `${u.protocol}//${u.hostname.toLowerCase()}${
u.pathname.replace(/\/+$/, "")
}`;
} catch {
return url;
}
}
function readCache(key: string): Tralbum | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.resolvedAt > TTL_MS) {
cache.delete(key);
return null;
}
return hit;
}
function writeCache(key: string, value: Tralbum): void {
// Map preserves insertion order, so the first key is the oldest write.
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, value);
}
/**
* Fetch and parse a Bandcamp page.
*
* `force` bypasses the cache — used by the client's re-resolve path, so a
* cached entry whose URLs have started 403ing can't keep being handed back.
*/
export async function resolveBandcamp(
pageUrl: string,
{ force = false }: { force?: boolean } = {},
): Promise<Tralbum> {
if (!isValidHttpUrl(pageUrl)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid URL",
);
}
let hostname: string;
try {
hostname = new URL(pageUrl).hostname;
} catch {
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid URL");
}
if (!BANDCAMP_HOST_RE.test(hostname)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Not a Bandcamp URL",
);
}
const key = cacheKey(pageUrl);
if (!force) {
const hit = readCache(key);
if (hit) return hit;
}
let res: Response;
try {
res = await fetchWithTimeout(pageUrl, 8000);
} catch {
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Could not reach Bandcamp",
);
}
// Every path out of here that doesn't read the body has to release it, or the
// connection stays open until GC. This endpoint is unauthenticated: a caller
// hammering random /track/<slug> URLs would otherwise leak one body a request.
if (!res.ok) {
await res.body?.cancel();
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
`Bandcamp returned ${res.status}`,
);
}
if (!(res.headers.get("content-type") ?? "").startsWith("text/html")) {
await res.body?.cancel();
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Bandcamp did not return a page",
);
}
const parsed = parseTralbum(await res.text(), pageUrl);
if (!parsed) {
// Covers "no data-tralbum", "hasAudio: false" and preorder-only releases
// with nothing streamable — all cases where the client falls back to the
// iframe embed rather than showing an error.
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"No streamable tracks on this page",
);
}
writeCache(key, parsed);
return parsed;
}

View File

@@ -0,0 +1,149 @@
/**
* Parser for Bandcamp's `data-tralbum` attribute.
*
* Every Bandcamp track and album page carries the whole release — titles,
* durations and signed mp3 stream URLs — as one HTML-escaped JSON blob on a
* `data-tralbum` attribute. One fetch of an album page therefore yields every
* track's stream URL; there is no per-track request.
*
* The stream URLs are signed and expire 24h after the page was fetched, which
* is why nothing here is ever persisted to `rich_content` — see
* `api/routes/bandcamp.ts` for the resolve-at-play-time endpoint.
*
* The shape is undocumented, so every field is treated as optional and the
* whole parse is best-effort: a `null` return means "not a Bandcamp page",
* which callers turn into a fallback to the iframe embed.
*/
/** Hostnames Bandcamp serves its own sites from. Anchored at the end so that
* `bandcamp.com.example.org` cannot pass. Artists on custom domains do not
* match this and keep using the embed. */
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
export interface TralbumTrack {
/** Position in the parsed array — the stable handle used to re-resolve a
* track after its signed URL expires. */
index: number;
trackNum?: number;
title: string;
/** Seconds, fractional. */
duration?: number;
streamUrl: string | null;
/** False for preorder, unreleased and still-encoding tracks. They stay in
* the list so numbering matches the release, but never enter the queue. */
streamable: boolean;
/** Bandcamp caps free plays per track; a capped track may serve a clip. */
capped: boolean;
}
export interface Tralbum {
sourceUrl: string;
itemType: "album" | "track";
title?: string;
artist?: string;
artworkUrl?: string;
tracks: TralbumTrack[];
/** Epoch ms of the fetch that produced these signed URLs. */
resolvedAt: number;
}
/**
* Decode HTML entities in a single pass.
*
* The single pass is the point: `decodeHtmlEntities` in rich-content-service.ts
* replaces `&amp;` before `&quot;`, so an escaped-ampersand-then-quot sequence
* (`&amp;quot;`, i.e. the literal text `&quot;` inside a title) decodes twice
* and yields a bare `"` — which lands inside the JSON string and breaks the
* parse. Matching every entity form in one regex means each is replaced once.
*/
function decodeEntitiesOnce(input: string): string {
const named: Record<string, string> = {
quot: '"',
apos: "'",
amp: "&",
lt: "<",
gt: ">",
nbsp: " ",
};
return input.replace(
/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z]+));/g,
(match, dec: string, hex: string, name: string) => {
if (dec) return String.fromCodePoint(Number(dec));
if (hex) return String.fromCodePoint(parseInt(hex, 16));
return named[name.toLowerCase()] ?? match;
},
);
}
/** Resolve Bandcamp's protocol-relative and root-relative art URLs. */
function artworkUrl(artId: unknown): string | undefined {
if (typeof artId !== "number" && typeof artId !== "string") return undefined;
const id = String(artId).trim();
if (!/^\d+$/.test(id)) return undefined;
// _16 is the large square art Bandcamp's own player uses.
return `https://f4.bcbits.com/img/a${id}_16.jpg`;
}
/**
* Pull the `data-tralbum` blob out of a Bandcamp page and normalise it.
* Returns null when the page has no blob, no audio, or nothing parseable.
*/
export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
const match = html.match(/\bdata-tralbum=(["'])([\s\S]*?)\1/);
if (!match) return null;
let raw: Record<string, unknown>;
try {
raw = JSON.parse(decodeEntitiesOnce(match[2]));
} catch {
return null;
}
if (!raw || typeof raw !== "object") return null;
if (raw.hasAudio === false) return null;
const rawTracks = Array.isArray(raw.trackinfo) ? raw.trackinfo : [];
const tracks: TralbumTrack[] = rawTracks.map((entry, index) => {
const t = (entry ?? {}) as Record<string, unknown>;
const file = (t.file ?? {}) as Record<string, unknown>;
const mp3 = typeof file["mp3-128"] === "string" ? file["mp3-128"] : null;
// Only ever hand out bcbits URLs. Without this the endpoint would relay
// whatever host a hostile page put in the blob straight to the browser.
const streamUrl = mp3 && isBcbitsUrl(mp3) ? mp3 : null;
return {
index,
trackNum: typeof t.track_num === "number" ? t.track_num : undefined,
title: typeof t.title === "string" && t.title.trim()
? t.title
: `Track ${index + 1}`,
duration: typeof t.duration === "number" && t.duration > 0
? t.duration
: undefined,
streamUrl,
streamable: streamUrl !== null && t.streaming === 1 &&
t.unreleased_track !== true && !t.encoding_pending,
capped: t.is_capped === true,
};
});
if (!tracks.some((t) => t.streamable)) return null;
const current = (raw.current ?? {}) as Record<string, unknown>;
return {
sourceUrl,
itemType: raw.item_type === "album" ? "album" : "track",
title: typeof current.title === "string" ? current.title : undefined,
artist: typeof raw.artist === "string" ? raw.artist : undefined,
artworkUrl: artworkUrl(raw.art_id),
tracks,
resolvedAt: Date.now(),
};
}
function isBcbitsUrl(url: string): boolean {
try {
const { protocol, hostname } = new URL(url);
return protocol === "https:" && /(?:^|\.)bcbits\.com$/.test(hostname);
} catch {
return false;
}
}

View File

@@ -3,6 +3,7 @@ import {
APIException, APIException,
type CreateUrlDumpRequest, type CreateUrlDumpRequest,
type Dump, type Dump,
type DumpUrlMatch,
type UpdateDumpRequest, type UpdateDumpRequest,
} from "../model/interfaces.ts"; } from "../model/interfaces.ts";
import { import {
@@ -13,7 +14,11 @@ import {
dumpRowToApi, dumpRowToApi,
isDumpRow, isDumpRow,
} from "../model/db.ts"; } from "../model/db.ts";
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts"; import {
fetchRichContent,
isValidHttpUrl,
tryFetchRichContent,
} from "./rich-content-service.ts";
import { import {
broadcastDumpDeleted, broadcastDumpDeleted,
broadcastDumpUpdated, broadcastDumpUpdated,
@@ -24,6 +29,7 @@ import {
notifyUserFollowersNewDump, notifyUserFollowersNewDump,
} from "./notification-service.ts"; } from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts"; import { makeSlug, UUID_RE } from "../lib/slugify.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { import {
DUMP_ALLOWED_MIME_PREFIXES, DUMP_ALLOWED_MIME_PREFIXES,
DUMP_ALLOWED_MIME_TYPES, DUMP_ALLOWED_MIME_TYPES,
@@ -59,13 +65,16 @@ export async function createUrlDump(
const dumpId = crypto.randomUUID(); const dumpId = crypto.randomUUID();
const createdAt = new Date(); const createdAt = new Date();
const richContent = await fetchRichContent(request.url); const richContent = await fetchRichContent(request.url);
const title = richContent?.title ?? titleFromUrl(request.url); // A title typed on the create form wins over the one scraped from the page:
// the poster has seen the preview and is correcting it.
const title = request.title?.trim() || richContent?.title ||
titleFromUrl(request.url);
const isPrivate = request.isPrivate ?? false; const isPrivate = request.isPrivate ?? false;
const slug = makeSlug(title, dumpId); const slug = makeSlug(title, dumpId);
db.prepare( db.prepare(
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, rich_content, is_private) `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
).run( ).run(
dumpId, dumpId,
"url", "url",
@@ -75,6 +84,7 @@ export async function createUrlDump(
userId, userId,
createdAt.toISOString(), createdAt.toISOString(),
request.url, request.url,
canonicalizeUrl(request.url),
richContent ? JSON.stringify(richContent) : null, richContent ? JSON.stringify(richContent) : null,
isPrivate ? 1 : 0, isPrivate ? 1 : 0,
); );
@@ -290,6 +300,54 @@ export function listDumps(
return { items, total: totalRow?.count ?? 0 }; return { items, total: totalRow?.count ?? 0 };
} }
/**
* Dumps that already point at the same thing as `url`, newest first.
*
* Matched on the canonical key rather than the raw URL, so a link reposted with
* different tracking parameters, scheme, `www.`, or (for YouTube) in a
* different share shape still counts as the same dump. Private dumps are
* visible only to their owner, exactly as everywhere else.
*
* Returns a small display-only projection: this feeds a hint on the create
* form, not a feed.
*/
export function findDumpsByUrl(
url: string,
requestingUserId?: string,
limit = 3,
): DumpUrlMatch[] {
const canonical = canonicalizeUrl(url);
if (!canonical) return [];
const rows = db.prepare(
`SELECT d.id, d.slug, d.title, d.created_at, d.vote_count, u.username,
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
FROM dumps d
JOIN users u ON u.id = d.user_id
WHERE d.url_canonical = ? AND (d.is_private = 0 OR d.user_id = ?)
ORDER BY d.created_at DESC
LIMIT ?;`,
).all(canonical, requestingUserId ?? null, limit) as {
id: string;
slug: string | null;
title: string;
created_at: string;
vote_count: number;
username: string;
comment_count: number;
}[];
return rows.map((row) => ({
id: row.id,
slug: row.slug ?? undefined,
title: row.title,
username: row.username,
createdAt: new Date(row.created_at),
voteCount: row.vote_count,
commentCount: row.comment_count,
}));
}
export async function updateDump( export async function updateDump(
dumpId: string, dumpId: string,
request: UpdateDumpRequest, request: UpdateDumpRequest,
@@ -383,12 +441,13 @@ export async function updateDump(
const row = dumpApiToRow(updatedDump); const row = dumpApiToRow(updatedDump);
const result = db.prepare( const result = db.prepare(
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`, `UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, url_canonical = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
).run( ).run(
row.title, row.title,
row.slug, row.slug,
row.comment, row.comment,
row.url, row.url,
row.url ? canonicalizeUrl(row.url) : null,
row.rich_content, row.rich_content,
row.is_private, row.is_private,
now.toISOString(), now.toISOString(),
@@ -596,7 +655,19 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
); );
} }
const richContent = await fetchRichContent(dump.url); // A failed fetch yields a stub with no title and no thumbnail. Writing that
// over a dump that already has good metadata would silently destroy it and
// rename the dump to its bare hostname, so bail out instead — the caller
// surfaces the error and nothing is lost.
const { ok, content: richContent } = await tryFetchRichContent(dump.url);
if (!ok) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
502,
"Could not reach the page to refresh its metadata",
);
}
const title = richContent?.title ?? titleFromUrl(dump.url); const title = richContent?.title ?? titleFromUrl(dump.url);
const updatedDump: Dump = { ...dump, title, richContent }; const updatedDump: Dump = { ...dump, title, richContent };
@@ -605,6 +676,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`, `UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
).run(row.title, row.rich_content, row.id); ).run(row.title, row.rich_content, row.id);
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
return updatedDump; return updatedDump;
} }

View File

@@ -382,6 +382,26 @@ export function getPlaylistMembershipsForDump(
}); });
} }
/**
* The playlists a dump appears in — the "in collections" counterpart to
* backlinks. Privacy-filtered like `getRelatedDumps`: public playlists for
* everyone, plus the requesting user's own private ones. Capped at 20 — a
* small, bounded list of supporting context.
*/
export function getPlaylistsForDump(
dumpId: string,
requestingUserId?: string,
): Playlist[] {
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT}
INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id
WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?)
ORDER BY p.created_at DESC LIMIT 20;`,
).all(dumpId, requestingUserId ?? "");
return rows.filter(isPlaylistRow).map(playlistRowToApi);
}
export function getPlaylistImageInfo( export function getPlaylistImageInfo(
idOrSlug: string, idOrSlug: string,
): { id: string; imageMime: string } | undefined { ): { id: string; imageMime: string } | undefined {

View File

@@ -5,10 +5,13 @@ import {
extractFirstContentImage, extractFirstContentImage,
extractJsonLd, extractJsonLd,
extractLargeImage, extractLargeImage,
extractMaskIconColor,
extractMetaName, extractMetaName,
extractOgTag, extractOgTag,
extractPageTitle, extractPageTitle,
extractThemeColor,
fetchWithTimeout, fetchWithTimeout,
normalizeCssColor,
} from "../rich-content-service.ts"; } from "../rich-content-service.ts";
export const genericProvider: RichContentProvider = { export const genericProvider: RichContentProvider = {
@@ -59,21 +62,37 @@ export const genericProvider: RichContentProvider = {
ld.description ?? ld.description ??
extractMetaName(html, "description"); extractMetaName(html, "description");
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> → first content <img> → best icon → /favicon.ico // Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> →
// first content <img>. The chain deliberately stops there: a favicon is not
// artwork, and pretending otherwise means every art-less page gets a 16×16
// icon cover-cropped into a 128×72 box. No match here means "no artwork",
// and the frontend draws a generated placeholder instead.
const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ?? const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ??
extractMetaName(html, "twitter:image") ?? extractMetaName(html, "twitter:image") ??
ld.thumbnailUrl ?? ld.thumbnailUrl ??
extractLargeImage(html, url) ?? extractLargeImage(html, url) ??
extractFirstContentImage(html, url) ?? extractFirstContentImage(html, url);
extractBestIcon(html, url) ??
// Icon and brand color are independent facts about the page, so they're
// collected whether or not there's artwork. `/favicon.ico` is a guess; when
// it 404s the placeholder falls back to the site's initial.
const faviconUrl = extractBestIcon(html, url) ??
`${new URL(url).origin}/favicon.ico`; `${new URL(url).origin}/favicon.ico`;
const accentColor = normalizeCssColor(
extractThemeColor(html) ??
extractMetaName(html, "msapplication-TileColor") ??
extractMaskIconColor(html),
);
return { return {
type: "generic", type: "generic",
url, url,
title, title,
description, description,
thumbnailUrl, thumbnailUrl,
faviconUrl,
accentColor,
siteName, siteName,
}; };
}, },

View File

@@ -3,12 +3,20 @@ import type { RichContentProvider } from "../rich-content-service.ts";
import { getDump } from "../dump-service.ts"; import { getDump } from "../dump-service.ts";
import { getUserByUsername } from "../user-service.ts"; import { getUserByUsername } from "../user-service.ts";
import { getPlaylistById } from "../playlist-service.ts"; import { getPlaylistById } from "../playlist-service.ts";
import { PUBLIC_URL } from "../../config.ts"; import { PUBLIC_URL, THEME_COLOR } from "../../config.ts";
const DUMP_RE = /^\/dumps\/([^/]+)$/; const DUMP_RE = /^\/dumps\/([^/]+)$/;
const USER_RE = /^\/users\/([^/]+)$/; const USER_RE = /^\/users\/([^/]+)$/;
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
// Internal links carry gerbeur's own branding, so an art-less one still gets a
// recognizable placeholder instead of a generic tint.
const SELF_BRANDING = {
siteName: "gerbeur",
faviconUrl: `${PUBLIC_URL}/apple-touch-icon.png`,
accentColor: THEME_COLOR,
} as const;
export const selfProvider: RichContentProvider = { export const selfProvider: RichContentProvider = {
name: "self", name: "self",
@@ -38,7 +46,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({ return Promise.resolve({
type: "generic", type: "generic",
url, url,
siteName: "gerbeur", ...SELF_BRANDING,
title: dump.title, title: dump.title,
description: dump.comment, description: dump.comment,
thumbnailUrl, thumbnailUrl,
@@ -54,7 +62,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({ return Promise.resolve({
type: "generic", type: "generic",
url, url,
siteName: "gerbeur", ...SELF_BRANDING,
title: user.username, title: user.username,
description: user.description, description: user.description,
thumbnailUrl, thumbnailUrl,
@@ -70,7 +78,7 @@ export const selfProvider: RichContentProvider = {
return Promise.resolve({ return Promise.resolve({
type: "generic", type: "generic",
url, url,
siteName: "gerbeur", ...SELF_BRANDING,
title: playlist.title, title: playlist.title,
description: playlist.description, description: playlist.description,
thumbnailUrl, thumbnailUrl,

View File

@@ -32,6 +32,30 @@ function extractPlaylistId(url: string): string | null {
} }
} }
/**
* Extract the playback offset in seconds from a YouTube URL.
* Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start`
* (plain seconds). Returns null when absent or unparseable.
*/
function extractStartSeconds(url: string): number | null {
let raw: string | null;
try {
const u = new URL(url);
raw = u.searchParams.get("t") ?? u.searchParams.get("start");
} catch {
return null;
}
if (!raw) return null;
const plain = /^\d+$/.exec(raw);
if (plain) return Number(raw);
const hms = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i.exec(raw);
if (!hms || (!hms[1] && !hms[2] && !hms[3])) return null;
return Number(hms[1] ?? 0) * 3600 + Number(hms[2] ?? 0) * 60 +
Number(hms[3] ?? 0);
}
/** Matches /channel/UC…, /@handle, /c/name, /user/name */ /** Matches /channel/UC…, /@handle, /c/name, /user/name */
function extractChannelPath(url: string): string | null { function extractChannelPath(url: string): string | null {
try { try {
@@ -134,6 +158,8 @@ export const youtubeProvider: RichContentProvider = {
const embedParams = new URLSearchParams({ rel: "0" }); const embedParams = new URLSearchParams({ rel: "0" });
if (listId) embedParams.set("list", listId); if (listId) embedParams.set("list", listId);
const start = extractStartSeconds(url);
if (start) embedParams.set("start", String(start));
return { return {
type: "youtube", type: "youtube",

View File

@@ -184,6 +184,117 @@ export function extractPageTitle(html: string): string | undefined {
return match ? decodeHtmlEntities(match[1].trim()) : undefined; return match ? decodeHtmlEntities(match[1].trim()) : undefined;
} }
// ── Brand color helpers ───────────────────────────────────────────────────────
/**
* Find the page's `theme-color`.
*
* Sites routinely ship the tag twice, scoped by `media`, and often list the
* dark-scheme one first — so unlike `extractMetaName` this can't just take the
* first match. Prefer the unscoped tag, then the light-scheme one, then any.
*/
export function extractThemeColor(html: string): string | undefined {
const tagRe = /<meta\b[^>]*>/gi;
const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i;
const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i;
let unscoped: string | undefined;
let light: string | undefined;
let any: string | undefined;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(html)) !== null) {
const tag = m[0];
if (!/\bname=["']theme-color["']/i.test(tag)) continue;
const content = contentRe.exec(tag)?.[2];
if (!content) continue;
const media = mediaRe.exec(tag)?.[2];
if (!media) unscoped ??= content;
else if (/light/i.test(media)) light ??= content;
any ??= content;
}
return unscoped ?? light ?? any;
}
/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */
export function extractMaskIconColor(html: string): string | undefined {
const linkRe = /<link[^>]+>/gi;
let m: RegExpExecArray | null;
while ((m = linkRe.exec(html)) !== null) {
const tag = m[0];
if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue;
const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2];
if (color) return color;
}
return undefined;
}
// The handful of named colors that actually show up in `theme-color`. The full
// 148-name table isn't worth carrying for the long tail.
const NAMED_COLORS: Record<string, string> = {
black: "#000000",
white: "#ffffff",
red: "#ff0000",
green: "#008000",
blue: "#0000ff",
yellow: "#ffff00",
orange: "#ffa500",
purple: "#800080",
gray: "#808080",
grey: "#808080",
silver: "#c0c0c0",
maroon: "#800000",
navy: "#000080",
teal: "#008080",
olive: "#808000",
lime: "#00ff00",
aqua: "#00ffff",
cyan: "#00ffff",
fuchsia: "#ff00ff",
magenta: "#ff00ff",
};
function clampByte(n: number): number {
return Math.max(0, Math.min(255, Math.round(n)));
}
/**
* Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when
* it isn't one of the forms sites actually use. Alpha is dropped — the color is
* only ever used as a tint over the app's own surface.
*/
export function normalizeCssColor(raw: string | undefined): string | undefined {
if (!raw) return undefined;
const value = raw.trim().toLowerCase();
const named = NAMED_COLORS[value];
if (named) return named;
const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1];
if (hex) {
if (hex.length === 3 || hex.length === 4) {
const [r, g, b] = [...hex.slice(0, 3)];
return `#${r}${r}${g}${g}${b}${b}`;
}
if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`;
return undefined;
}
const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1];
if (rgb) {
const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3);
if (parts.length !== 3) return undefined;
const bytes = parts.map((p) => {
const n = parseFloat(p);
if (!Number.isFinite(n)) return NaN;
return clampByte(p.endsWith("%") ? (n / 100) * 255 : n);
});
if (bytes.some(Number.isNaN)) return undefined;
return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
}
return undefined;
}
// ── JSON-LD helpers (file-private) ──────────────────────────────────────────── // ── JSON-LD helpers (file-private) ────────────────────────────────────────────
type JsonLdResult = { type JsonLdResult = {
@@ -281,6 +392,10 @@ export function extractLargeImage(
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank * Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
* them by declared size (largest wins), and return the best resolved URL. * them by declared size (largest wins), and return the best resolved URL.
* Falls back to the first match when no `sizes` attribute is present. * Falls back to the first match when no `sizes` attribute is present.
*
* SVG icons are ranked above everything: they carry no `sizes` attribute, so
* they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to
* whatever size the placeholder renders them at.
*/ */
export function extractBestIcon( export function extractBestIcon(
html: string, html: string,
@@ -302,7 +417,13 @@ export function extractBestIcon(
if (!href) continue; if (!href) continue;
const sizesStr = sizesRe.exec(tag)?.[1] ?? ""; const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
const sm = sizesStr.match(/(\d+)x(\d+)/i); const sm = sizesStr.match(/(\d+)x(\d+)/i);
const area = sm ? parseInt(sm[1]) * parseInt(sm[2]) : 0; const isSvg = /\.svg(\?|$)/i.test(href) ||
/\btype=["']image\/svg\+xml["']/i.test(tag);
const area = isSvg
? Number.MAX_SAFE_INTEGER
: sm
? parseInt(sm[1]) * parseInt(sm[2])
: 0;
try { try {
candidates.push({ href: new URL(href, baseUrl).toString(), area }); candidates.push({ href: new URL(href, baseUrl).toString(), area });
} catch { } catch {
@@ -431,24 +552,46 @@ export function isValidHttpUrl(raw: string): boolean {
} }
} }
export async function fetchRichContent( export interface FetchRichContentResult {
/** False when the page couldn't be reached at all, as opposed to reached and
* found to carry no metadata. Callers that already hold good metadata must
* not overwrite it with a failure stub. */
ok: boolean;
content?: RichContent;
}
/**
* Fetch metadata for `url`, reporting whether the fetch itself succeeded.
*
* On failure it still yields a minimal stub so a *new* dump has something
* displayable, but `ok: false` lets callers with existing metadata keep it.
*/
export async function tryFetchRichContent(
url: string, url: string,
): Promise<RichContent | undefined> { ): Promise<FetchRichContentResult> {
try { try {
const provider = providers.find((p) => p.matches(url))!; const provider = providers.find((p) => p.matches(url))!;
return await provider.fetch(url); return { ok: true, content: await provider.fetch(url) };
} catch (err) { } catch (err) {
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err); console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
// Return a minimal stub so the caller always gets something displayable
// (e.g. when the site has a bad TLS cert or the fetch times out).
try { try {
return { return {
type: "generic", ok: false,
url, content: {
siteName: new URL(url).hostname.replace(/^www\./, ""), type: "generic",
url,
siteName: new URL(url).hostname.replace(/^www\./, ""),
},
}; };
} catch { } catch {
return undefined; return { ok: false };
} }
} }
} }
/** Metadata for `url`, or a minimal stub when it can't be fetched. */
export async function fetchRichContent(
url: string,
): Promise<RichContent | undefined> {
return (await tryFetchRichContent(url)).content;
}

View File

@@ -15,6 +15,7 @@
<meta name="theme-color" content="#111827" /> <meta name="theme-color" content="#111827" />
<meta name="site-name" content="__SITE_NAME__" /> <meta name="site-name" content="__SITE_NAME__" />
<meta name="site-emoji" content="__SITE_EMOJI__" /> <meta name="site-emoji" content="__SITE_EMOJI__" />
<meta name="bandcamp-player" content="__BANDCAMP_PLAYER__" />
<link rel="manifest" href="/manifest.webmanifest?v=__ICON_VERSION__" /> <link rel="manifest" href="/manifest.webmanifest?v=__ICON_VERSION__" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" />
<title>__SITE_NAME__</title> <title>__SITE_NAME__</title>

View File

@@ -438,6 +438,222 @@
font-weight: 700; font-weight: 700;
} }
/* ── Create-dump modal ── */
/* A file dropped anywhere in the modal is accepted, so the whole body lights up. */
.dump-create--drag {
outline: 2px dashed var(--color-accent);
outline-offset: 6px;
border-radius: 10px;
}
.dump-create-draft,
.dump-create-confirm {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.9rem;
padding: 0.55rem 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
}
.dump-create-draft {
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
color: var(--color-text-secondary);
}
.dump-create-draft-discard {
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
font-weight: 600;
color: var(--color-link);
text-decoration: underline;
}
.dump-create-confirm {
background: var(--color-danger-bg);
border: 1px solid var(--color-danger);
}
.dump-create-confirm p {
margin: 0;
flex: 1 1 14rem;
}
.dump-create-confirm-actions {
display: flex;
gap: 0.5rem;
}
/* Not reached, or reached with nothing worth showing. */
.preview-note {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
}
/* ── "Already dumped" hint ── */
.dump-duplicate {
display: flex;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border-radius: 9px;
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.dump-duplicate-icon {
font-size: 1rem;
line-height: 1.3;
opacity: 0.8;
}
.dump-duplicate-body {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.dump-duplicate-lead {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
}
.dump-duplicate-link {
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dump-duplicate-more,
.dump-duplicate-hint {
margin: 0;
font-size: 0.78rem;
color: var(--color-text-secondary);
}
/* ── Step indicator ── */
.dump-steps {
display: flex;
align-items: center;
gap: 0.5rem;
list-style: none;
margin: 0 0 1.1rem;
padding: 0;
}
.dump-step {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
font-size: 0.82rem;
color: var(--color-text-muted);
}
/* Connector between steps — drawn on the gap, not on the labels. */
.dump-step + .dump-step::before {
content: "";
width: 1.1rem;
height: 1px;
background: var(--color-border-subtle);
flex-shrink: 0;
}
.dump-step-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35rem;
height: 1.35rem;
flex-shrink: 0;
border-radius: 999px;
border: 1.5px solid var(--color-border-subtle);
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
}
.dump-step-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dump-step--current {
color: var(--color-text);
font-weight: 600;
}
.dump-step--current .dump-step-marker {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-on-accent);
}
.dump-step--done {
color: var(--color-text-secondary);
}
.dump-step--done .dump-step-marker {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* Only the current step keeps its label once the modal gets narrow. */
@media (max-width: 30rem) {
.dump-step:not(.dump-step--current) .dump-step-label {
display: none;
}
}
/* ── Panels ── */
.dump-panel {
display: flex;
flex-direction: column;
gap: 1rem; /* matches .form, so panels and plain forms space identically */
animation: dump-panel-in 0.18s ease-out;
}
.dump-panel--back {
animation-name: dump-panel-in-back;
}
@keyframes dump-panel-in {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: none; }
}
@keyframes dump-panel-in-back {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.dump-panel {
animation: none;
}
}
/* One line naming the thing the second panel's choices apply to. */
.dump-panel-recap {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Mode toggle — segmented control ── */ /* ── Mode toggle — segmented control ── */
.visibility-toggle { .visibility-toggle {
display: flex; display: flex;
@@ -592,6 +808,41 @@
background: var(--color-border-subtle); background: var(--color-border-subtle);
} }
/* ── Upload progress (second panel, while the file is being sent) ── */
.dump-upload-progress {
height: 4px;
border-radius: 999px;
background: var(--color-border-subtle);
overflow: hidden;
}
.dump-upload-progress-fill {
height: 100%;
width: 0;
border-radius: 999px;
background: var(--color-accent);
transition: width 0.2s ease-out;
}
/* Length unknown, or the body is sent and the server is still thinking. */
.dump-upload-progress--indeterminate .dump-upload-progress-fill {
width: 40%;
animation: dump-upload-slide 1.1s ease-in-out infinite;
}
@keyframes dump-upload-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
@media (prefers-reduced-motion: reduce) {
.dump-upload-progress--indeterminate .dump-upload-progress-fill {
width: 100%;
animation: none;
opacity: 0.5;
}
}
/* ── Local file / URL preview (DumpCreate) ── */ /* ── Local file / URL preview (DumpCreate) ── */
.local-preview-image { .local-preview-image {
width: 100%; width: 100%;
@@ -832,6 +1083,37 @@
transition: width 0.1s linear; transition: width 0.1s linear;
} }
/* Audio whose peaks can't be decoded (cross-origin streams). Boxed to the
waveform's height so the player doesn't resize between item kinds, with the
bar itself centred inside. */
.audio-player-track--stream {
height: 48px;
background: none;
display: flex;
align-items: center;
}
.audio-player-track--stream::before {
content: "";
position: absolute;
inset: 50% 0 auto 0;
transform: translateY(-50%);
height: 6px;
border-radius: 3px;
background: color-mix(
in srgb,
var(--color-accent) 12%,
var(--color-border) 88%
);
}
.audio-player-track--stream .audio-player-fill {
top: 50%;
bottom: auto;
transform: translateY(-50%);
height: 6px;
border-radius: 3px;
z-index: 1;
}
.audio-player-track--volume { .audio-player-track--volume {
flex: 1 1 100px; flex: 1 1 100px;
max-width: 120px; max-width: 120px;
@@ -1063,6 +1345,105 @@ a.global-player-title:hover {
.global-player.global-player--bandcamp { .global-player.global-player--bandcamp {
max-width: 600px; max-width: 600px;
} }
/* ── Global player: queue ── */
/* The title/artist pair takes the space the bare title used to claim. */
.global-player-heading {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.global-player-heading .global-player-title {
flex: none;
}
.global-player-subtitle {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-player-artwork {
width: 36px;
height: 36px;
flex: none;
object-fit: cover;
border-radius: 4px;
}
.global-player-transport {
display: flex;
align-items: center;
gap: 0.25rem;
flex: none;
}
.global-player-position {
font-size: 0.75rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.global-player-transport .btn--ghost:disabled {
opacity: 0.35;
cursor: default;
}
.global-player-status {
margin: 0;
padding: 0 1rem 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.global-player-tracks {
list-style: none;
margin: 0;
padding: 0 0.5rem 0.5rem;
max-height: 40vh;
overflow-y: auto;
}
.global-player-track > button {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.35rem 0.5rem;
background: none;
border: none;
border-left: 3px solid transparent;
color: inherit;
font: inherit;
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.global-player-track > button:hover {
background: var(--color-surface-alt, rgba(127, 127, 127, 0.12));
}
/* An accent border rather than a filled pill, so no theme has to un-round it. */
.global-player-track.is-current > button {
border-left-color: var(--color-accent);
font-weight: 600;
}
.global-player-track-num {
flex: none;
min-width: 1.5em;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.global-player-track-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.global-player-track-dur {
flex: none;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.feed-loading-more { .feed-loading-more {
text-align: center; text-align: center;
padding: 1rem; padding: 1rem;
@@ -2534,6 +2915,12 @@ body.has-player .chat-fab {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.dump-edit-refresh-error {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--color-danger);
}
.dump-edit-thumbnail-row { .dump-edit-thumbnail-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3009,6 +3396,76 @@ body.has-player .chat-fab {
line-height: 1; line-height: 1;
} }
/* ── Generated thumbnail placeholder ──
Stands in for pages that offer no preview image. The site's own color is
mixed *into* the theme surface rather than painted raw, so an arbitrary
third-party accent can never break contrast against --color-text — in any
style, in either color scheme. Themes tune --thumb-tint-strength alone. */
/* Zero-specificity sizing: the placeholder fills its box by default, but any
class it shares with the <img> it replaces (.rich-content-thumbnail's 180px,
.rich-content-compact-thumbnail's 36px) still wins, so it lands exactly where
the image would have. */
:where(.thumb-placeholder) {
width: 100%;
height: 100%;
}
.thumb-placeholder {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
user-select: none;
color: var(--color-text);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.12)),
color-mix(
in srgb,
var(--thumb-tint, var(--color-accent)) var(--thumb-tint-strength, 34%),
var(--color-surface)
);
}
.thumb-placeholder-icon {
max-width: 44%;
max-height: 44%;
object-fit: contain;
display: block;
}
.thumb-placeholder-initials {
font-size: 1.25rem;
font-weight: 800;
line-height: 1;
opacity: 0.75;
letter-spacing: 0.02em;
}
/* The initial has no intrinsic size, so scale it per surface. */
.dump-card-preview .thumb-placeholder-initials,
.playlist-card-preview .thumb-placeholder-initials {
font-size: 1.9rem;
}
.journal-card-image .thumb-placeholder-initials {
font-size: 2.6rem;
}
.rich-content-thumbnail.thumb-placeholder .thumb-placeholder-initials {
font-size: 3rem;
}
/* `.rich-content-thumbnail` sizes an <img> by width alone and lets the natural
aspect supply the height, which a <div> has none of. Stretching to the card's
height matches the image; the floor keeps it from collapsing when there's
little text, or once the card stacks on narrow screens. */
.rich-content-thumbnail.thumb-placeholder {
height: auto;
align-self: stretch;
min-height: 110px;
}
/* Fill the 48×48 preview box and center content for media buttons */ /* Fill the 48×48 preview box and center content for media buttons */
.dump-card-preview .rich-content-thumbnail-btn { .dump-card-preview .rich-content-thumbnail-btn {
width: 100%; width: 100%;
@@ -4155,6 +4612,53 @@ body.has-player .chat-fab {
} }
} }
/* "In collections" reuses the related-section chrome (and its per-theme
overrides), so the playlist cards inside it get the same subordinate,
tightened treatment the backlink dump cards do. */
.collections-section .playlist-card {
border-width: 1px;
border-radius: 8px;
background: transparent;
opacity: 0.78;
transition: border-color 0.15s, opacity 0.15s;
}
.collections-section .playlist-card:hover {
opacity: 1;
}
.collections-section .playlist-card-inner {
padding: 0.45rem 0.65rem;
gap: 0.6rem;
}
.collections-section .playlist-card-preview {
width: 48px;
height: 48px;
align-self: center;
}
.collections-section .playlist-card-icon {
font-size: 1.15rem;
}
.collections-section .playlist-card-title {
font-size: 0.88rem;
}
.collections-section .playlist-card-description {
font-size: 0.78rem;
-webkit-line-clamp: 1;
line-clamp: 1;
}
@media (max-width: 512px) {
.collections-section .playlist-card-preview {
width: 40px;
height: 40px;
}
}
.comment-list { .comment-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@@ -4,6 +4,7 @@ import {
Navigate, Navigate,
Route, Route,
Routes, Routes,
useLocation,
useParams, useParams,
} from "react-router"; } from "react-router";
@@ -85,14 +86,21 @@ function useResolvedDefaultTab() {
return preferredTab === "followed" && !user ? "hot" : preferredTab; return preferredTab === "followed" && !user ? "hot" : preferredTab;
} }
// The query string has to survive the hop: the Web Share Target posts to "/",
// so an Android share arrives here as `/?share_url=…` and the feed below is the
// only thing that can act on it.
function IndexRedirect() { function IndexRedirect() {
return <Navigate to={`/~/${useResolvedDefaultTab()}`} replace />; const { search } = useLocation();
return <Navigate to={`/~/${useResolvedDefaultTab()}${search}`} replace />;
} }
// Bare `/<slug>` lands on that category's default feed tab. // Bare `/<slug>` lands on that category's default feed tab.
function CategoryRedirect() { function CategoryRedirect() {
const { categorySlug } = useParams(); const { categorySlug } = useParams();
return <Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}`} replace />; const { search } = useLocation();
return (
<Navigate to={`/${categorySlug}/${useResolvedDefaultTab()}${search}`} replace />
);
} }
// Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed. // Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed.

View File

@@ -8,6 +8,7 @@ import { useCategories } from "../hooks/useCategories.ts";
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
import FilePreview from "./FilePreview.tsx"; import FilePreview from "./FilePreview.tsx";
import RichContentCard from "./RichContentCard.tsx"; import RichContentCard from "./RichContentCard.tsx";
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
import { VoteButton } from "./VoteButton.tsx"; import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx"; import { Tooltip } from "./Tooltip.tsx";
@@ -65,7 +66,7 @@ export function DumpCard(
: undefined} : undefined}
/> />
) )
: <span className="dump-card-preview-icon">🔗</span>} : <ThumbnailPlaceholder url={dump.url} />}
</div> </div>
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}> <div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx"; import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
import { import {
BAR_GAP, BAR_GAP,
BAR_W, BAR_W,
@@ -20,11 +20,39 @@ interface FilePreviewProps {
global?: boolean; global?: boolean;
} }
/**
* Build the global-player item for an uploaded file, so every entry point
* (compact cards, the detail page, the waveform) puts the same title, artwork
* and subtitle in the player header.
*
* Artwork is only claimed when one actually exists: videos get their generated
* still and any dump can carry an uploaded thumbnail, but a bare audio file has
* neither, and pointing at the endpoint anyway would just render a placeholder.
*/
function filePlayerItem(
dump: Dump,
fileUrl: string,
mime: string,
token?: string | null,
): PlayerItem {
const hasArtwork = !!dump.thumbnailMime || mime.startsWith("video/");
return {
kind: "file",
fileUrl,
mimeType: mime,
title: dump.title,
dumpHref: dumpUrl(dump),
artworkUrl: hasArtwork ? dumpThumbnailUrl(dump, token) : undefined,
subtitle: dump.fileName,
};
}
// Waveform preview for the dump detail page — routes to global player, // Waveform preview for the dump detail page — routes to global player,
// reflects live play state and position from PlayerContext. // reflects live play state and position from PlayerContext.
function AudioFilePreview( function AudioFilePreview(
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump }, { fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
) { ) {
const { token } = useAuth();
const { current, playing, currentTime, duration, play, togglePlay, seekTo } = const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
useContext(PlayerContext); useContext(PlayerContext);
const [peaks, setPeaks] = useState<Float32Array | null>(null); const [peaks, setPeaks] = useState<Float32Array | null>(null);
@@ -45,7 +73,7 @@ function AudioFilePreview(
const handlePlayBtn = () => { const handlePlayBtn = () => {
if (isActive) togglePlay(); if (isActive) togglePlay();
else play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); else play(filePlayerItem(dump, fileUrl, mime, token));
}; };
const handleWaveformClick = (e: React.MouseEvent<Element>) => { const handleWaveformClick = (e: React.MouseEvent<Element>) => {
@@ -59,7 +87,7 @@ function AudioFilePreview(
} else { } else {
// Start playing and seek once it loads — seekTo after play() is a no-op // Start playing and seek once it loads — seekTo after play() is a no-op
// until MediaPlayer mounts; the fraction is best-effort on first click // until MediaPlayer mounts; the fraction is best-effort on first click
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); play(filePlayerItem(dump, fileUrl, mime, token));
} }
}; };
@@ -178,7 +206,7 @@ export default function FilePreview(
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); play(filePlayerItem(dump, fileUrl, mime, token));
}} }}
> >
<VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} /> <VideoThumb src={thumbUrl} fallback={mimeIcon(mime)} />
@@ -196,7 +224,7 @@ export default function FilePreview(
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) }); play(filePlayerItem(dump, fileUrl, mime, token));
}} }}
> >
{thumbOverride {thumbOverride
@@ -230,12 +258,9 @@ export default function FilePreview(
type="button" type="button"
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`} className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
onClick={() => onClick={() =>
videoActive ? togglePlay() : play({ videoActive
kind: "file", ? togglePlay()
fileUrl, : play(filePlayerItem(dump, fileUrl, mime, token))}
mimeType: mime,
title: dump.title,
})}
> >
<video <video
src={fileUrl} src={fileUrl}

View File

@@ -1,32 +1,68 @@
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import {
PlayerContext,
type PlayerItem,
playerItemKey,
} from "../contexts/PlayerContext.ts";
import { MediaPlayer } from "./MediaPlayer.tsx"; import { MediaPlayer } from "./MediaPlayer.tsx";
import { fmt } from "../utils/duration.ts";
import Thumbnail from "./Thumbnail.tsx";
function itemKey( type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
item: { kind: string; embedUrl?: string; fileUrl?: string } | null,
) { // The stored embedUrl is the canonical, non-playing form — it is also rendered
if (!item) return null; // outside the player, so the autoplay parameter is added here at playback time
return item.kind === "embed" ? item.embedUrl : item.fileUrl; // rather than baked into rich_content. Only a fresh play() sets autoplay: a
// session restored from localStorage has no user gesture behind it, so it stays
// paused, matching how MediaPlayer treats file items.
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
function playbackUrl(item: EmbedItem, autoplay: boolean) {
if (!autoplay) return item.embedUrl;
try {
const url = new URL(item.embedUrl);
if (item.type === "youtube") url.searchParams.set("autoplay", "1");
else if (item.type === "soundcloud") {
url.searchParams.set("auto_play", "true");
} else return item.embedUrl;
return url.toString();
} catch {
return item.embedUrl; // malformed stored URL — hand it to the iframe as-is
}
} }
export function GlobalPlayer() { export function GlobalPlayer() {
const { const {
current, current,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime, startTime,
autoplay, autoplay,
stop, stop,
next,
previous,
playAt,
seekRef, seekRef,
toggleRef, toggleRef,
onPlayStateChange, onPlayStateChange,
onTimeUpdate, onTimeUpdate,
onEnded,
onError,
} = useContext(PlayerContext); } = useContext(PlayerContext);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const currentRowRef = useRef<HTMLLIElement>(null);
const [reduced, setReduced] = useState(false); const [reduced, setReduced] = useState(false);
const [prevKey, setPrevKey] = useState(itemKey(current)); const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null);
if (prevKey !== itemKey(current)) { const currentKey = current ? playerItemKey(current) : null;
setPrevKey(itemKey(current)); if (prevKey !== currentKey) {
setPrevKey(currentKey);
if (current) setReduced(false); if (current) setReduced(false);
} }
@@ -57,16 +93,27 @@ export function GlobalPlayer() {
}; };
}, [current]); }, [current]);
// Keep the playing row visible as the queue advances on its own.
useEffect(() => {
currentRowRef.current?.scrollIntoView({ block: "nearest" });
}, [queueIndex]);
if (!current) return null; if (!current) return null;
const typeClass = current.kind === "embed" // Files are classed by their media kind; everything else carries a brand key,
? current.type // so a native Bandcamp stream keeps the same styling as the Bandcamp embed.
: current.mimeType.startsWith("video/") const typeClass = current.kind === "file"
? "file-video" ? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
: "file-audio"; : current.type;
const title = current.title ?? const title = current.title ??
(current.kind === "embed" ? current.embedUrl : current.fileUrl); (current.kind === "embed"
? current.embedUrl
: current.kind === "file"
? current.fileUrl
: current.streamUrl);
const showQueue = queue.length > 1;
return ( return (
<div <div
@@ -76,21 +123,66 @@ export function GlobalPlayer() {
ref={ref} ref={ref}
> >
<div className="global-player-header"> <div className="global-player-header">
{current.dumpHref {current.artworkUrl && (
? ( <Thumbnail
<Link to={current.dumpHref} className="global-player-title"> src={current.artworkUrl}
{title} className="global-player-artwork"
</Link> placeholder={{ siteName: current.subtitle }}
) placeholderClassName="global-player-artwork"
: <span className="global-player-title">{title}</span>} loading="eager"
/>
)}
<div className="global-player-heading">
{current.dumpHref
? (
<Link to={current.dumpHref} className="global-player-title">
{title}
</Link>
)
: <span className="global-player-title">{title}</span>}
{current.subtitle && (
<span className="global-player-subtitle">{current.subtitle}</span>
)}
</div>
{showQueue && (
<div className="global-player-transport">
<button
type="button"
className="btn btn--ghost"
onClick={previous}
disabled={!hasPrevious}
aria-label={t`Previous track`}
>
</button>
<span className="global-player-position">
{queueIndex + 1} / {queue.length}
</span>
<button
type="button"
className="btn btn--ghost"
onClick={next}
disabled={!hasNext}
aria-label={t`Next track`}
>
</button>
</div>
)}
<button <button
type="button" type="button"
className="btn btn--ghost" className="btn btn--ghost"
onClick={() => setReduced((r) => !r)} onClick={() => setReduced((r) => !r)}
aria-label={reduced ? t`Expand player` : t`Collapse player`}
> >
{reduced ? "▲" : "▼"} {reduced ? "▲" : "▼"}
</button> </button>
<button type="button" className="btn btn--ghost" onClick={stop}> <button
type="button"
className="btn btn--ghost"
onClick={stop}
aria-label={t`Close player`}
>
</button> </button>
</div> </div>
@@ -99,14 +191,15 @@ export function GlobalPlayer() {
? ( ? (
<div className="global-player-iframe-wrap"> <div className="global-player-iframe-wrap">
<iframe <iframe
src={current.embedUrl} src={playbackUrl(current, autoplay)}
className={`global-player-iframe--${current.type}`} className={`global-player-iframe--${current.type}`}
allow="autoplay; encrypted-media" allow="autoplay; encrypted-media"
allowFullScreen allowFullScreen
/> />
</div> </div>
) )
: ( : current.kind === "file"
? (
<div className="global-player-media-wrap"> <div className="global-player-media-wrap">
<MediaPlayer <MediaPlayer
key={current.fileUrl} key={current.fileUrl}
@@ -117,11 +210,71 @@ export function GlobalPlayer() {
startTime={startTime} startTime={startTime}
onPlayStateChange={onPlayStateChange} onPlayStateChange={onPlayStateChange}
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
seekRef={seekRef} seekRef={seekRef}
toggleRef={toggleRef} toggleRef={toggleRef}
/> />
</div> </div>
)
: (
<div className="global-player-media-wrap">
<MediaPlayer
// Keyed on the signed URL so a re-resolve remounts the element
// and MediaPlayer's mount-only startTime effect resumes it.
key={current.streamUrl}
src={current.streamUrl}
kind="audio"
mime="audio/mpeg"
trackStyle="progress"
autoplay={autoplay}
startTime={startTime}
onPlayStateChange={onPlayStateChange}
onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
onError={onError}
seekRef={seekRef}
toggleRef={toggleRef}
/>
{resolving && (
<p className="global-player-status">
<Trans>Refreshing stream</Trans>
</p>
)}
</div>
)} )}
{showQueue && !reduced && (
<ol className="global-player-tracks">
{queue.map((item, i) => {
const isCurrent = i === queueIndex;
const num = item.kind === "stream" ? item.trackNum : undefined;
const dur = item.kind === "stream" ? item.duration : undefined;
return (
<li
key={playerItemKey(item)}
ref={isCurrent ? currentRowRef : undefined}
className={`global-player-track${
isCurrent ? " is-current" : ""
}`}
>
<button type="button" onClick={() => playAt(i)}>
<span className="global-player-track-num">
{num ?? i + 1}
</span>
<span className="global-player-track-title">
{item.title}
</span>
{dur !== undefined && (
<span className="global-player-track-dur">
{fmt(dur)}
</span>
)}
</button>
</li>
);
})}
</ol>
)}
</div> </div>
</div> </div>
); );

View File

@@ -47,6 +47,7 @@ export function ImagePicker({
alt={alt} alt={alt}
className="img-picker-img" className="img-picker-img"
style={{ borderRadius }} style={{ borderRadius }}
referrerPolicy="no-referrer"
/> />
) )
: ( : (

View File

@@ -1,8 +1,6 @@
import { useContext } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { Plural, Trans } from "@lingui/react/macro"; import { Plural, Trans } from "@lingui/react/macro";
import type { Dump } from "../model.ts"; import type { Dump } from "../model.ts";
import { API_URL } from "../config/api.ts";
import { relativeTime } from "../utils/relativeTime.ts"; import { relativeTime } from "../utils/relativeTime.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
@@ -11,7 +9,11 @@ import { hasQuote, hasThumbnail, type JournalShape } from "../utils/journalLayou
import { VoteButton } from "./VoteButton.tsx"; import { VoteButton } from "./VoteButton.tsx";
import { Markdown } from "./Markdown.tsx"; import { Markdown } from "./Markdown.tsx";
import { Tooltip } from "./Tooltip.tsx"; import { Tooltip } from "./Tooltip.tsx";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import Thumbnail from "./Thumbnail.tsx";
import {
canPlayRichContent,
usePlayRichContent,
} from "../hooks/usePlayRichContent.ts";
export type { JournalShape }; export type { JournalShape };
@@ -32,7 +34,7 @@ export function JournalCard(
) { ) {
const navigate = useNavigate(); const navigate = useNavigate();
const { token } = useAuth(); const { token } = useAuth();
const { play } = useContext(PlayerContext); const { playRichContent } = usePlayRichContent();
const unread = !isOwner && isRecent(dump.createdAt) && const unread = !isOwner && isRecent(dump.createdAt) &&
!isDumpVisited(dump.id); !isDumpVisited(dump.id);
@@ -41,30 +43,24 @@ export function JournalCard(
navigate(dumpUrl(dump)); navigate(dumpUrl(dump));
} }
const rawThumbnail = // A playable card plays. If playback turns out to be impossible — a Bandcamp
dump.kind === "file" && dump.fileMime?.startsWith("image/") // page with no streams and no embed to fall back to — the card must still do
? dumpFileUrl(dump, token) // what an unplayable one does rather than swallowing the click.
: dump.thumbnailMime async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
? dumpThumbnailUrl(dump, token) if (!await playRichContent(rc, dumpUrl(dump))) handleNavigate();
: (dump.richContent?.thumbnailUrl ?? null); }
// Route external HTTP thumbnails through the server proxy to avoid // Mirrors FilePreview (the hot/new feeds) so a video shows its generated
// mixed-content blocks when the frontend is served over HTTPS. // still here too, rather than degrading to a text card with a 🎬.
const thumbnailUrl = (() => { const thumbnailUrl = dump.thumbnailMime
if (!rawThumbnail) return null; ? dumpThumbnailUrl(dump, token)
try { : dump.kind === "file"
const u = new URL(rawThumbnail); ? (dump.fileMime?.startsWith("image/")
if ( ? dumpFileUrl(dump, token)
u.protocol === "http:" && u.hostname !== "localhost" && : dump.fileMime?.startsWith("video/")
u.hostname !== "127.0.0.1" ? dumpThumbnailUrl(dump, token)
) { : null)
return `${API_URL}/api/proxy-image?url=${ : (dump.richContent?.thumbnailUrl ?? null);
encodeURIComponent(rawThumbnail)
}`;
}
} catch { /* relative URL */ }
return rawThumbnail;
})();
// Content mode is independent of grid footprint: a thumbnailed dump reads as // Content mode is independent of grid footprint: a thumbnailed dump reads as
// an image card, a thumbnail-less dump with a note becomes a pull-quote, and // an image card, a thumbnail-less dump with a note becomes a pull-quote, and
@@ -84,7 +80,13 @@ export function JournalCard(
})() })()
: "🔗"; : "🔗";
const embedUrl = dump.richContent?.embedUrl; const richContent = dump.richContent;
// In native mode a Bandcamp page is playable even with no stored embedUrl.
// The card's own thumbnail (dump upload or provider) becomes the player's
// header artwork.
const playable = richContent && canPlayRichContent(richContent)
? { ...richContent, thumbnailUrl: thumbnailUrl ?? undefined }
: null;
const titleLink = ( const titleLink = (
<Link <Link
@@ -155,27 +157,25 @@ export function JournalCard(
return ( return (
<li <li
className={className} className={className}
onClick={embedUrl onClick={playable
? () => ? () => void handlePlayOrNavigate(playable)
play({
kind: "embed",
embedUrl,
title: dump.richContent?.title,
type: dump.richContent?.type ?? "unknown",
dumpHref: dumpUrl(dump),
})
: handleNavigate} : handleNavigate}
> >
<div className="journal-card-image"> <div className="journal-card-image">
<img <Thumbnail
src={thumbnailUrl ?? undefined} src={thumbnailUrl ?? undefined}
alt="" placeholder={dump.kind === "file"
loading="lazy" // No ffmpeg on the host means no still — the mime glyph says more
onError={(e) => { // about the dump than an initial taken from its filename.
(e.target as HTMLImageElement).style.visibility = "hidden"; ? { seed: dump.fileName ?? dump.id, glyph: fallbackIcon }
}} : {
url: dump.url,
accentColor: dump.richContent?.accentColor,
faviconUrl: dump.richContent?.faviconUrl,
siteName: dump.richContent?.siteName,
}}
/> />
{embedUrl && ( {playable && (
<span className="rich-content-play-overlay" aria-hidden="true"> <span className="rich-content-play-overlay" aria-hidden="true">
</span> </span>

View File

@@ -16,10 +16,10 @@ function preprocessMentions(text: string): string {
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)"); return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
} }
// Static components object — defined once at module scope to avoid recreation on every render type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
const MARKDOWN_COMPONENTS: React.ComponentProps<
typeof ReactMarkdown // Static components objects — defined once at module scope to avoid recreation on every render
>["components"] = { const MARKDOWN_COMPONENTS: Components = {
a: ({ href, children: linkChildren }) => { a: ({ href, children: linkChildren }) => {
if (href?.startsWith("/users/")) { if (href?.startsWith("/users/")) {
return <Link to={href}>{linkChildren}</Link>; return <Link to={href}>{linkChildren}</Link>;
@@ -32,6 +32,14 @@ const MARKDOWN_COMPONENTS: React.ComponentProps<
}, },
}; };
// Inline renderings are line-clamped teasers inside cards, where an embedded
// image blows the layout apart. Drop images there — the full description is
// one click away on the detail page.
const INLINE_MARKDOWN_COMPONENTS: Components = {
...MARKDOWN_COMPONENTS,
img: () => null,
};
export function Markdown( export function Markdown(
{ children, className, inline = false }: MarkdownProps, { children, className, inline = false }: MarkdownProps,
) { ) {
@@ -45,7 +53,9 @@ export function Markdown(
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={REMARK_PLUGINS} remarkPlugins={REMARK_PLUGINS}
components={MARKDOWN_COMPONENTS} components={inline
? INLINE_MARKDOWN_COMPONENTS
: MARKDOWN_COMPONENTS}
> >
{processed} {processed}
</ReactMarkdown> </ReactMarkdown>

View File

@@ -7,13 +7,7 @@ import {
VIEWBOX_W, VIEWBOX_W,
WAVEFORM_H, WAVEFORM_H,
} from "../utils/waveform.ts"; } from "../utils/waveform.ts";
import { fmt } from "../utils/duration.ts";
function fmt(s: number): string {
if (!isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
}
export const IconPlay = () => ( export const IconPlay = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}> <svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
@@ -119,6 +113,72 @@ function Waveform(
); );
} }
// ── Volume ────────────────────────────────────────────────────────────────────
// Each queue advance remounts MediaPlayer (the element is keyed on the source),
// which would otherwise snap the volume back to 100% on every track. Hold it
// outside the component and mirror it to localStorage so it also survives a
// reload.
const VOLUME_KEY = "player-volume";
let lastVolume = 1;
let lastMuted = false;
try {
const stored = JSON.parse(localStorage.getItem(VOLUME_KEY) ?? "null");
if (stored && typeof stored.volume === "number") {
lastVolume = Math.min(Math.max(stored.volume, 0), 1);
lastMuted = stored.muted === true;
}
} catch {
// Malformed or unavailable storage — the defaults above stand.
}
function rememberVolume(volume: number, muted: boolean) {
lastVolume = volume;
lastMuted = muted;
try {
localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
} catch {
// Private mode or blocked storage — volume just won't persist.
}
}
// ── Seek bar ──────────────────────────────────────────────────────────────────
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */
function SeekBar(
{ current, duration, onSeek, onDragChange, className }: {
current: number;
duration: number;
onSeek: (t: number) => void;
onDragChange: (dragging: boolean) => void;
className?: string;
},
) {
const progress = duration > 0 ? current / duration : 0;
return (
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
<div
className="audio-player-fill"
style={{ width: `${progress * 100}%` }}
/>
<input
type="range"
className="audio-player-range"
min={0}
max={duration || 1}
step={0.01}
value={current}
onMouseDown={() => onDragChange(true)}
onMouseUp={() => onDragChange(false)}
onChange={(e) => onSeek(Number(e.target.value))}
aria-label="Seek"
/>
</div>
);
}
// ── MediaPlayer ─────────────────────────────────────────────────────────────── // ── MediaPlayer ───────────────────────────────────────────────────────────────
const HIDE_DELAY = 2500; const HIDE_DELAY = 2500;
@@ -132,6 +192,14 @@ interface MediaPlayerProps {
startTime?: number; startTime?: number;
onPlayStateChange?: (playing: boolean) => void; onPlayStateChange?: (playing: boolean) => void;
onTimeUpdate?: (time: number, duration: number) => void; onTimeUpdate?: (time: number, duration: number) => void;
/** Fired when the media reaches its end — the queue's advance hook. */
onEnded?: () => void;
/** Fired when the element fails to load or decode. For expiring stream URLs
* this is the signal to re-resolve. */
onError?: () => void;
/** "waveform" (default) decodes peaks from the source; "progress" draws a
* slim seek bar and never fetches the media a second time. */
trackStyle?: "waveform" | "progress";
seekRef?: { current: ((t: number) => void) | null }; seekRef?: { current: ((t: number) => void) | null };
toggleRef?: { current: (() => void) | null }; toggleRef?: { current: (() => void) | null };
} }
@@ -146,6 +214,9 @@ export function MediaPlayer(
startTime, startTime,
onPlayStateChange, onPlayStateChange,
onTimeUpdate, onTimeUpdate,
onEnded,
onError,
trackStyle = "waveform",
seekRef, seekRef,
toggleRef, toggleRef,
}: MediaPlayerProps, }: MediaPlayerProps,
@@ -155,8 +226,8 @@ export function MediaPlayer(
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [volume, setVolume] = useState(1); const [volume, setVolume] = useState(lastVolume);
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(lastMuted);
const [controlsVisible, setControlsVisible] = useState(true); const [controlsVisible, setControlsVisible] = useState(true);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -166,11 +237,15 @@ export function MediaPlayer(
// and effect, acceptable since these are only called from async event handlers. // and effect, acceptable since these are only called from async event handlers.
const onPlayStateChangeRef = useRef(onPlayStateChange); const onPlayStateChangeRef = useRef(onPlayStateChange);
const onTimeUpdateRef = useRef(onTimeUpdate); const onTimeUpdateRef = useRef(onTimeUpdate);
const onEndedRef = useRef(onEnded);
const onErrorRef = useRef(onError);
// Sync prop callbacks after every render // Sync prop callbacks after every render
useEffect(() => { useEffect(() => {
onPlayStateChangeRef.current = onPlayStateChange; onPlayStateChangeRef.current = onPlayStateChange;
onTimeUpdateRef.current = onTimeUpdate; onTimeUpdateRef.current = onTimeUpdate;
onEndedRef.current = onEnded;
onErrorRef.current = onError;
}); });
// Stable function refs — updated via effects, indirected by the registration // Stable function refs — updated via effects, indirected by the registration
@@ -226,6 +301,17 @@ export function MediaPlayer(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Apply the remembered volume to the freshly-mounted element, which always
// starts at 1.0 regardless of what the last track was playing at.
useEffect(() => {
const a = mediaRef.current;
if (!a) return;
a.volume = volume;
a.muted = muted;
// Mount only: later changes go through changeVolume/toggleMute.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Autoplay on mount (e.g. triggered by play() in PlayerContext) // Autoplay on mount (e.g. triggered by play() in PlayerContext)
useEffect(() => { useEffect(() => {
if (!autoplay) return; if (!autoplay) return;
@@ -245,6 +331,8 @@ export function MediaPlayer(
a.pause(); a.pause();
onPlayStateChangeRef.current = undefined; onPlayStateChangeRef.current = undefined;
onTimeUpdateRef.current = undefined; onTimeUpdateRef.current = undefined;
onEndedRef.current = undefined;
onErrorRef.current = undefined;
}; };
}, []); }, []);
@@ -272,17 +360,24 @@ export function MediaPlayer(
setDuration(a.duration); setDuration(a.duration);
onTimeUpdateRef.current?.(a.currentTime, a.duration); onTimeUpdateRef.current?.(a.currentTime, a.duration);
}; };
const onEnded = () => { const onEndedEvent = () => {
setPlaying(false); setPlaying(false);
onPlayStateChangeRef.current?.(false); onPlayStateChangeRef.current?.(false);
onEndedRef.current?.();
};
const onErrorEvent = () => {
setPlaying(false);
onErrorRef.current?.();
}; };
a.addEventListener("timeupdate", onTime); a.addEventListener("timeupdate", onTime);
a.addEventListener("durationchange", onDuration); a.addEventListener("durationchange", onDuration);
a.addEventListener("ended", onEnded); a.addEventListener("ended", onEndedEvent);
a.addEventListener("error", onErrorEvent);
return () => { return () => {
a.removeEventListener("timeupdate", onTime); a.removeEventListener("timeupdate", onTime);
a.removeEventListener("durationchange", onDuration); a.removeEventListener("durationchange", onDuration);
a.removeEventListener("ended", onEnded); a.removeEventListener("ended", onEndedEvent);
a.removeEventListener("error", onErrorEvent);
}; };
}, [dragging]); }, [dragging]);
@@ -317,32 +412,32 @@ export function MediaPlayer(
} }
}; };
const seek = (e: React.ChangeEvent<HTMLInputElement>) =>
seekTo(Number(e.target.value));
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => { const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value); const v = Number(e.target.value);
const nextMuted = v > 0 && muted ? false : muted;
setVolume(v); setVolume(v);
mediaRef.current!.volume = v; mediaRef.current!.volume = v;
if (v > 0 && muted) { if (nextMuted !== muted) {
setMuted(false); setMuted(nextMuted);
mediaRef.current!.muted = false; mediaRef.current!.muted = nextMuted;
} }
rememberVolume(v, nextMuted);
}; };
const toggleMute = () => { const toggleMute = () => {
const next = !muted; const next = !muted;
setMuted(next); setMuted(next);
mediaRef.current!.muted = next; mediaRef.current!.muted = next;
rememberVolume(volume, next);
}; };
const goFullscreen = () => { const goFullscreen = () => {
(mediaRef.current as HTMLVideoElement).requestFullscreen?.(); (mediaRef.current as HTMLVideoElement).requestFullscreen?.();
}; };
const progress = duration > 0 ? current / duration : 0; // A waveform needs the raw bytes, which cross-origin streams won't hand over.
// Those get an honest progress bar rather than a permanent loading skeleton.
const track = kind === "audio" const track = kind === "audio" && trackStyle === "waveform"
? ( ? (
<Waveform <Waveform
src={src} src={src}
@@ -352,24 +447,13 @@ export function MediaPlayer(
/> />
) )
: ( : (
<div className="audio-player-track"> <SeekBar
<div current={current}
className="audio-player-fill" duration={duration}
style={{ width: `${progress * 100}%` }} onSeek={seekTo}
/> onDragChange={setDragging}
<input className={kind === "audio" ? "audio-player-track--stream" : undefined}
type="range" />
className="audio-player-range"
min={0}
max={duration || 1}
step={0.01}
value={current}
onMouseDown={() => setDragging(true)}
onMouseUp={() => setDragging(false)}
onChange={seek}
aria-label="Seek"
/>
</div>
); );
const controls = ( const controls = (

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useRef } from "react"; import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
@@ -7,11 +7,24 @@ interface ModalProps {
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
wide?: boolean; wide?: boolean;
/**
* Runs before every dismissal the user did not aim at the content — Escape,
* the backdrop, the ✕. Returning `false` cancels it, which lets a modal
* holding an unsaved draft ask first instead of throwing the work away.
*/
onBeforeClose?: () => boolean;
} }
export function Modal({ title, onClose, children, wide = false }: ModalProps) { export function Modal(
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
) {
const backdropRef = useRef<HTMLDivElement>(null); const backdropRef = useRef<HTMLDivElement>(null);
const requestClose = useCallback(() => {
if (onBeforeClose?.() === false) return;
onClose();
}, [onBeforeClose, onClose]);
useEffect(() => { useEffect(() => {
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
@@ -21,18 +34,18 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose(); if (e.key === "Escape" && !e.defaultPrevented) requestClose();
}; };
document.addEventListener("keydown", handler); document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler);
}, [onClose]); }, [requestClose]);
return createPortal( return createPortal(
<div <div
className="modal-backdrop" className="modal-backdrop"
ref={backdropRef} ref={backdropRef}
onClick={(e) => { onClick={(e) => {
if (e.target === backdropRef.current) onClose(); if (e.target === backdropRef.current) requestClose();
}} }}
> >
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}> <div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
@@ -41,7 +54,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
<button <button
type="button" type="button"
className="modal-close-btn" className="modal-close-btn"
onClick={onClose} onClick={requestClose}
aria-label={t`Close`} aria-label={t`Close`}
> >

View File

@@ -1,21 +1,11 @@
import { useContext } from "react"; import { useContext } from "react";
import type { RichContent } from "../model.ts"; import type { RichContent } from "../model.ts";
import { PlayerContext } from "../contexts/PlayerContext.ts"; import { PlayerContext } from "../contexts/PlayerContext.ts";
import { API_URL } from "../config/api.ts"; import {
canPlayRichContent,
/** Route HTTP thumbnail URLs through the server proxy to avoid mixed-content blocks. */ usePlayRichContent,
function proxyIfHttp(url: string): string { } from "../hooks/usePlayRichContent.ts";
try { import Thumbnail from "./Thumbnail.tsx";
const u = new URL(url);
if (
u.protocol === "http:" && u.hostname !== "localhost" &&
u.hostname !== "127.0.0.1"
) {
return `${API_URL}/api/proxy-image?url=${encodeURIComponent(url)}`;
}
} catch { /* relative URL — leave as-is */ }
return url;
}
interface RichContentCardProps { interface RichContentCardProps {
richContent: RichContent; richContent: RichContent;
@@ -30,48 +20,68 @@ export default function RichContentCard(
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }: { richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
RichContentCardProps, RichContentCardProps,
) { ) {
const { play, current, playing } = useContext(PlayerContext); const { current, playing, togglePlay } = useContext(PlayerContext);
const thumbnailSrc = thumbnailOverrideUrl ?? const { playRichContent, pending } = usePlayRichContent();
(richContent.thumbnailUrl
? proxyIfHttp(richContent.thumbnailUrl) const canPlay = canPlayRichContent(richContent);
: undefined); const thumbnailSrc = thumbnailOverrideUrl ?? richContent.thumbnailUrl;
// The dump's own thumbnail overrides the provider's, in the player header too.
const playable = { ...richContent, thumbnailUrl: thumbnailSrc };
// Nothing to play and no embed to fall back to: the card body is a link to
// the source, so the thumbnail behaves like one rather than going dead.
const playOrOpen = async () => {
if (!await playRichContent(playable, dumpHref)) {
globalThis.open(richContent.url, "_blank", "noopener,noreferrer");
}
};
const placeholder = {
url: richContent.url,
accentColor: richContent.accentColor,
faviconUrl: richContent.faviconUrl,
siteName: richContent.siteName,
};
if (compact) { if (compact) {
if (richContent.embedUrl) { const thumbnail = (
const isActive = current?.kind === "embed" && <Thumbnail
current.embedUrl === richContent.embedUrl; src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
placeholder={placeholder}
placeholderClassName="rich-content-compact-thumbnail"
/>
);
if (canPlay) {
// A native Bandcamp queue is the same card as its embed, matched on the
// source page rather than a stream URL that changes on every resolve.
const isActive = current != null &&
((current.kind === "embed" &&
current.embedUrl === richContent.embedUrl) ||
(current.kind === "stream" &&
current.resolveUrl === richContent.url));
const isPlaying = isActive && playing; const isPlaying = isActive && playing;
return ( return (
<button <button
type="button" type="button"
className={`rich-content-thumbnail-btn${ className={`rich-content-thumbnail-btn${
isActive ? " is-playing" : "" isActive ? " is-playing" : ""
}`} }${pending ? " is-pending" : ""}`}
disabled={pending}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
play({ // The button says "Pause" once this card is the one playing, so it
kind: "embed", // has to pause. Re-playing would re-resolve the album and lose the
embedUrl: richContent.embedUrl!, // queue position. Embeds have no transport of their own, so they
title: richContent.title, // keep restarting, as before.
type: richContent.type, if (isActive && current?.kind === "stream") togglePlay();
dumpHref, else void playOrOpen();
});
}} }}
aria-label={isPlaying ? "Pause" : "Play"} aria-label={isPlaying ? "Pause" : "Play"}
> >
{thumbnailSrc {thumbnail}
? (
<img
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)
: <span className="rich-content-compact-icon"></span>}
<span className="rich-content-play-overlay"> <span className="rich-content-play-overlay">
{isPlaying ? "⏸" : "▶"} {isPlaying ? "⏸" : "▶"}
</span> </span>
@@ -87,63 +97,36 @@ export default function RichContentCard(
className="rich-content-compact" className="rich-content-compact"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{thumbnailSrc {thumbnail}
? (
<img
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-compact-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)
: <span className="rich-content-compact-icon">🔗</span>}
</a> </a>
); );
} }
const canPlay = !!richContent.embedUrl; const thumbnailImg = (
<Thumbnail
const thumbnail = thumbnailSrc && ( src={thumbnailSrc}
canPlay alt={richContent.title ?? ""}
? ( className="rich-content-thumbnail"
<button placeholder={placeholder}
type="button" placeholderClassName="rich-content-thumbnail"
className="rich-content-thumbnail-btn" />
onClick={() =>
play({
kind: "embed",
embedUrl: richContent.embedUrl!,
title: richContent.title,
type: richContent.type,
dumpHref,
})}
aria-label="Play"
>
<img
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<span className="rich-content-play-overlay"></span>
</button>
)
: (
<img
src={thumbnailSrc}
alt={richContent.title ?? ""}
className="rich-content-thumbnail"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)
); );
const thumbnail = canPlay
? (
<button
type="button"
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
disabled={pending}
onClick={() => void playOrOpen()}
aria-label="Play"
>
{thumbnailImg}
<span className="rich-content-play-overlay"></span>
</button>
)
: thumbnailImg;
return ( return (
<div className={`rich-content-card rich-content-card--${richContent.type}`}> <div className={`rich-content-card rich-content-card--${richContent.type}`}>
{thumbnail} {thumbnail}

View File

@@ -0,0 +1,73 @@
import { useState } from "react";
import { canProxyImage, proxyImageUrl } from "../utils/proxyImage.ts";
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
interface ThumbnailProps {
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */
src?: string;
alt?: string;
className?: string;
loading?: "lazy" | "eager";
/** Passed through to the placeholder when there's nothing to show. */
placeholder: {
url?: string;
accentColor?: string;
faviconUrl?: string;
siteName?: string;
};
/** Extra class for the placeholder element only. */
placeholderClassName?: string;
}
/**
* An external thumbnail that always renders *something*.
*
* Three attempts, in order:
* 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
* one click) answers a cross-site referrer with 403, and an empty one with
* 200;
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
* that reject an empty referrer too;
* 3. fall back to the generated placeholder.
*
* Before this existed each of these failures ended at `display: none`, leaving
* an empty framed box and no signal anywhere that a load had failed.
*/
export default function Thumbnail(
{ src, alt = "", className, loading = "lazy", placeholder, placeholderClassName }:
ThumbnailProps,
) {
const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">(
"direct",
);
// A new src is a fresh thing to try — reset during render rather than in an
// effect (same shape as Avatar.tsx).
const [prevSrc, setPrevSrc] = useState(src);
if (prevSrc !== src) {
setPrevSrc(src);
setAttempt("direct");
}
if (!src || attempt === "failed") {
return (
<ThumbnailPlaceholder {...placeholder} className={placeholderClassName} />
);
}
return (
<img
src={attempt === "proxied"
? proxyImageUrl(src, { force: true })
: proxyImageUrl(src)}
alt={alt}
className={className}
loading={loading}
referrerPolicy="no-referrer"
onError={() =>
setAttempt(
attempt === "direct" && canProxyImage(src) ? "proxied" : "failed",
)}
/>
);
}

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import { proxyImageUrl } from "../utils/proxyImage.ts";
import { initialsFor, tintFor } from "../utils/thumbnailTint.ts";
interface ThumbnailPlaceholderProps {
/** The dumped URL — seeds the fallback hue and the initial. */
url?: string;
/** Explicit hue seed for dumps with no URL to hash (file dumps). */
seed?: string;
/** The target page's declared brand color, if it had one. */
accentColor?: string;
/** The target page's icon, drawn contained over the tint. */
faviconUrl?: string;
/** Preferred source for the initial when there's no favicon. */
siteName?: string;
/** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */
glyph?: string;
className?: string;
}
/**
* Stand-in artwork for pages that offer no preview image: the site's own color
* washed over the theme surface, with its favicon centered on top — or its
* initial when the favicon is missing or fails to load.
*
* Fills whatever box it's placed in, so the same component serves the 36px
* compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
*/
export default function ThumbnailPlaceholder(
{ url, seed, accentColor, faviconUrl, siteName, glyph, className }:
ThumbnailPlaceholderProps,
) {
const [iconFailed, setIconFailed] = useState(false);
// A different icon is a fresh thing to try — clear the prior failure during
// render rather than in an effect (same shape as Avatar.tsx).
const [prevIcon, setPrevIcon] = useState(faviconUrl);
if (prevIcon !== faviconUrl) {
setPrevIcon(faviconUrl);
setIconFailed(false);
}
const style = { "--thumb-tint": tintFor({ accentColor, url, seed }) } as
React.CSSProperties;
return (
<div
className={`thumb-placeholder${className ? ` ${className}` : ""}`}
style={style}
aria-hidden="true"
>
{faviconUrl && !iconFailed
? (
<img
src={proxyImageUrl(faviconUrl)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className="thumb-placeholder-icon"
onError={() => setIconFailed(true)}
/>
)
: (
<span className="thumb-placeholder-initials">
{glyph ?? initialsFor(siteName, url)}
</span>
)}
</div>
);
}

View File

@@ -64,14 +64,17 @@ export function SegmentedField<T extends FieldValues, V>({
*/ */
export function VisibilityToggle<T extends FieldValues>({ export function VisibilityToggle<T extends FieldValues>({
name, name,
label,
disabled, disabled,
}: { }: {
name: Path<T>; name: Path<T>;
label?: string;
disabled?: boolean; disabled?: boolean;
}) { }) {
return ( return (
<SegmentedField<T, boolean> <SegmentedField<T, boolean>
name={name} name={name}
label={label}
disabled={disabled} disabled={disabled}
options={[ options={[
{ value: true, label: <Trans>Public</Trans> }, { value: true, label: <Trans>Public</Trans> },

19
src/config/playerMode.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Which Bandcamp playback path this deployment uses.
*
* The server injects `GERBEUR_BANDCAMP_PLAYER` into a `<meta>` tag at request
* time (api/lib/static.ts and api/middleware/og.ts; mirrored by the Vite dev
* plugin), the same mechanism as site-name/site-emoji. Read once at startup.
*
* Defaults to "embed" whenever the value is missing, unsubstituted or
* unrecognised, so a misconfigured deployment keeps the iframe it has today.
*/
export type BandcampPlayer = "embed" | "native";
function readBandcampPlayer(): BandcampPlayer {
const meta = document.querySelector('meta[name="bandcamp-player"]')
?.getAttribute("content")?.trim();
return meta === "native" ? "native" : "embed";
}
export const BANDCAMP_PLAYER: BandcampPlayer = readBandcampPlayer();

View File

@@ -1,8 +1,68 @@
import { createContext } from "react"; import { createContext } from "react";
/** Fields every playable item shares, whatever its source. */
interface PlayerItemBase {
title?: string;
dumpHref?: string;
/** Square image for the player header — provider thumbnail, album art, or a
* video still. Raw URL: Thumbnail decides whether to proxy it. */
artworkUrl?: string;
/** Secondary line under the title: the artist, the site name, the filename —
* whatever identifies the source at a glance. */
subtitle?: string;
}
export type PlayerItem = export type PlayerItem =
| { kind: "embed"; embedUrl: string; title?: string; type: string; dumpHref?: string } | (PlayerItemBase & {
| { kind: "file"; fileUrl: string; mimeType: string; title?: string; dumpHref?: string }; kind: "embed";
embedUrl: string;
type: string;
})
| (PlayerItemBase & {
kind: "file";
fileUrl: string;
mimeType: string;
})
/**
* An expiring, remotely-hosted stream (today: a Bandcamp mp3).
*
* Unlike a "file" item the URL is signed and dies after ~24h, so the item
* carries everything needed to fetch a fresh one: `resolveUrl` is the page it
* came from and `resolveIndex` its position in that page's tracklist. That
* pair is also the item's stable identity, since `streamUrl` changes on every
* re-resolve.
*/
| (PlayerItemBase & {
kind: "stream";
streamUrl: string;
/** Brand key driving `global-player--${type}` styling, e.g. "bandcamp". */
type: string;
/** Known before playback from the tracklist, so rows can show a length. */
duration?: number;
trackNum?: number;
resolveUrl: string;
resolveIndex: number;
/** Epoch ms of the resolution that produced `streamUrl`. */
resolvedAt: number;
/** Iframe embed to fall back to if native playback stops working. */
embedUrl?: string;
});
/**
* Stable identity for an item, used for active-state comparisons and to decide
* when the player is showing something new. Deliberately independent of
* `streamUrl` so a re-resolved track is still recognised as the same track.
*/
export function playerItemKey(item: PlayerItem): string {
switch (item.kind) {
case "embed":
return `embed:${item.embedUrl}`;
case "file":
return `file:${item.fileUrl}`;
case "stream":
return `stream:${item.resolveUrl}#${item.resolveIndex}`;
}
}
export interface PlayerContextValue { export interface PlayerContextValue {
// Playback state — readable by any consumer // Playback state — readable by any consumer
@@ -11,14 +71,28 @@ export interface PlayerContextValue {
currentTime: number; currentTime: number;
duration: number; duration: number;
// Queue. Every playback is a queue of at least one item, so single tracks and
// albums share one code path. `current` is always `queue[queueIndex]`.
queue: PlayerItem[];
queueIndex: number;
hasNext: boolean;
hasPrevious: boolean;
/** True while a stream is being (re-)resolved, for spinner affordances. */
resolving: boolean;
// Initial seek offset for the active item (non-zero only for an item restored // Initial seek offset for the active item (non-zero only for an item restored
// from a previous session) and whether the active MediaPlayer should autoplay. // from a previous session, or resumed after a re-resolve) and whether the
// A restored item appears paused; a fresh play() autoplays on the user gesture. // active MediaPlayer should autoplay. A restored item appears paused; a fresh
// play() autoplays on the user gesture.
startTime: number; startTime: number;
autoplay: boolean; autoplay: boolean;
// Control — callable by any consumer // Control — callable by any consumer
play(item: PlayerItem): void; play(item: PlayerItem): void;
playQueue(items: PlayerItem[], startIndex?: number): void;
playAt(index: number): void;
next(): void;
previous(): void;
stop(): void; stop(): void;
seekTo(time: number): void; seekTo(time: number): void;
togglePlay(): void; togglePlay(): void;
@@ -31,6 +105,10 @@ export interface PlayerContextValue {
// Internal: GlobalPlayer calls these to push state back into the provider // Internal: GlobalPlayer calls these to push state back into the provider
onPlayStateChange(playing: boolean): void; onPlayStateChange(playing: boolean): void;
onTimeUpdate(time: number, duration: number): void; onTimeUpdate(time: number, duration: number): void;
/** Current item finished — advances the queue, or stops at the end. */
onEnded(): void;
/** The media element failed. For streams, triggers a re-resolve. */
onError(): void;
} }
export const PlayerContext = createContext<PlayerContextValue>({ export const PlayerContext = createContext<PlayerContextValue>({
@@ -38,9 +116,18 @@ export const PlayerContext = createContext<PlayerContextValue>({
playing: false, playing: false,
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
queue: [],
queueIndex: 0,
hasNext: false,
hasPrevious: false,
resolving: false,
startTime: 0, startTime: 0,
autoplay: false, autoplay: false,
play: () => {}, play: () => {},
playQueue: () => {},
playAt: () => {},
next: () => {},
previous: () => {},
stop: () => {}, stop: () => {},
seekTo: () => {}, seekTo: () => {},
togglePlay: () => {}, togglePlay: () => {},
@@ -48,4 +135,6 @@ export const PlayerContext = createContext<PlayerContextValue>({
toggleRef: { current: null }, toggleRef: { current: null },
onPlayStateChange: () => {}, onPlayStateChange: () => {},
onTimeUpdate: () => {}, onTimeUpdate: () => {},
onEnded: () => {},
onError: () => {},
}); });

View File

@@ -1,39 +1,83 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts"; import {
PlayerContext,
type PlayerItem,
playerItemKey,
} from "./PlayerContext.ts";
import { isStreamStale, resolveStreamQueue } from "../utils/streamSources.ts";
const STORAGE_KEY = "player"; const STORAGE_KEY = "player";
// Snapshot persisted across reloads: which item was playing and how far in. /**
* Snapshot persisted across reloads: the whole queue, which entry was playing
* and how far in.
*
* v1 stored a single `{ item, time }`. `readSession` still accepts that shape
* so a session written by the previous build survives the upgrade.
*/
interface StoredSession { interface StoredSession {
item: PlayerItem; v: 2;
queue: PlayerItem[];
index: number;
time: number; time: number;
} }
function readSession(): StoredSession | null { function isPlayerItem(value: unknown): value is PlayerItem {
if (!value || typeof value !== "object") return false;
const kind = (value as { kind?: unknown }).kind;
return kind === "embed" || kind === "file" || kind === "stream";
}
function readSession(): { queue: PlayerItem[]; index: number; time: number } | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredSession>; const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!parsed.item || (parsed.item.kind !== "embed" && parsed.item.kind !== "file")) { const time = Number(parsed.time) || 0;
return null;
const rawQueue = parsed.queue;
if (Array.isArray(rawQueue)) {
const queue = rawQueue.filter(isPlayerItem);
if (queue.length === 0) return null;
const index = Math.min(
Math.max(Number(parsed.index) || 0, 0),
queue.length - 1,
);
return { queue, index, time };
} }
return { item: parsed.item as PlayerItem, time: Number(parsed.time) || 0 };
// Legacy v1 single-item session.
if (isPlayerItem(parsed.item)) {
return { queue: [parsed.item], index: 0, time };
}
return null;
} catch { } catch {
return null; return null;
} }
} }
export function PlayerProvider({ children }: { children: React.ReactNode }) { /** Don't retry the same failing track more than once per window. */
const restored = useRef(readSession()).current; const RERESOLVE_COOLDOWN_MS = 30_000;
const [current, setCurrent] = useState<PlayerItem | null>(restored?.item ?? null); export function PlayerProvider({ children }: { children: React.ReactNode }) {
// Lazy initialiser: readSession runs exactly once, on first render.
const [restored] = useState(readSession);
const [queue, setQueue] = useState<PlayerItem[]>(restored?.queue ?? []);
const [queueIndex, setQueueIndex] = useState(restored?.index ?? 0);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(restored?.time ?? 0); const [currentTime, setCurrentTime] = useState(restored?.time ?? 0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
// Resume offset for the active item — only the restored item carries one. // Resume offset for the active item — carried by a restored item, and by an
// item whose stream URL was re-resolved mid-listen.
const [startTime, setStartTime] = useState(restored?.time ?? 0); const [startTime, setStartTime] = useState(restored?.time ?? 0);
// Restored items start paused (no user gesture); fresh play() autoplays. // Restored items start paused (no user gesture); fresh play() autoplays.
const [autoplay, setAutoplay] = useState(false); const [autoplay, setAutoplay] = useState(false);
const [resolving, setResolving] = useState(false);
const current = queue[queueIndex] ?? null;
const hasNext = queueIndex + 1 < queue.length;
const hasPrevious = queueIndex > 0;
// GlobalPlayer registers the active MediaPlayer's imperative handles here // GlobalPlayer registers the active MediaPlayer's imperative handles here
const seekRef = useRef<((t: number) => void) | null>(null); const seekRef = useRef<((t: number) => void) | null>(null);
@@ -43,9 +87,41 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
// MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true). // MediaPlayer's unmount cleanup. Cleared when the new media fires onPlayStateChange(true).
const suppressUpdates = useRef(false); const suppressUpdates = useRef(false);
const play = useCallback((item: PlayerItem) => { // Bumped whenever the player is moved somewhere new — a fresh queue, a queue
// step, a stop. An async re-resolve compares it against the value it captured
// to tell whether it still owns the player by the time it lands.
const playGen = useRef(0);
// Latest state, for callbacks that must not close over a stale render.
// Written in an effect (after render) rather than during it, the same
// convention MediaPlayer uses for its callback refs.
const stateRef = useRef({ current, currentTime, playing, queue, queueIndex });
useEffect(() => {
stateRef.current = { current, currentTime, playing, queue, queueIndex };
});
const playQueue = useCallback((items: PlayerItem[], startIndex = 0) => {
if (items.length === 0) return;
playGen.current++;
suppressUpdates.current = true; suppressUpdates.current = true;
setCurrent(item); setQueue(items);
setQueueIndex(Math.min(Math.max(startIndex, 0), items.length - 1));
setCurrentTime(0);
setDuration(0);
setStartTime(0);
setAutoplay(true);
setPlaying(false);
}, []);
const play = useCallback((item: PlayerItem) => {
playQueue([item], 0);
}, [playQueue]);
/** Move within the existing queue, as a fresh (autoplaying) item. */
const advanceTo = useCallback((index: number) => {
playGen.current++;
suppressUpdates.current = true;
setQueueIndex(index);
setCurrentTime(0); setCurrentTime(0);
setDuration(0); setDuration(0);
setStartTime(0); setStartTime(0);
@@ -54,7 +130,9 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
const stop = useCallback(() => { const stop = useCallback(() => {
setCurrent(null); playGen.current++;
setQueue([]);
setQueueIndex(0);
setPlaying(false); setPlaying(false);
setCurrentTime(0); setCurrentTime(0);
setDuration(0); setDuration(0);
@@ -70,6 +148,34 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
toggleRef.current?.(); toggleRef.current?.();
}, []); }, []);
const playAt = useCallback((index: number) => {
if (index < 0 || index >= queue.length) return;
// Re-selecting the playing row restarts it. Going through advanceTo would
// leave the index unchanged — so the element would never remount and never
// seek — while still flipping `playing` false against audio that's running.
if (index === queueIndex) {
seekTo(0);
// Rewinding alone would make the click look like a no-op on a row that
// has ended, or that the user paused. Start it again.
if (!stateRef.current.playing) togglePlay();
return;
}
advanceTo(index);
}, [queue.length, queueIndex, advanceTo, seekTo, togglePlay]);
const next = useCallback(() => {
if (queueIndex + 1 < queue.length) advanceTo(queueIndex + 1);
}, [queueIndex, queue.length, advanceTo]);
/** Restart the track first, like every other music player, then step back. */
const previous = useCallback(() => {
if (stateRef.current.currentTime > 3 || queueIndex === 0) {
seekTo(0);
return;
}
advanceTo(queueIndex - 1);
}, [queueIndex, advanceTo, seekTo]);
const onPlayStateChange = useCallback((p: boolean) => { const onPlayStateChange = useCallback((p: boolean) => {
if (p) suppressUpdates.current = false; if (p) suppressUpdates.current = false;
setPlaying(p); setPlaying(p);
@@ -81,25 +187,158 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
setDuration(d); setDuration(d);
}, []); }, []);
// ── Persistence ──────────────────────────────────────────────────────────────── const onEnded = useCallback(() => {
// Write the item to storage as soon as it changes (survives crashes), then refresh if (stateRef.current.current && queueIndex + 1 < queue.length) {
// the playback position on pagehide — which also fires on a normal reload — so the advanceTo(queueIndex + 1);
// resume offset is accurate without thrashing localStorage on every timeupdate. return;
}
setPlaying(false);
}, [queueIndex, queue.length, advanceTo]);
// ── Expiring streams ───────────────────────────────────────────────────────────
// Bandcamp signs its mp3 URLs for ~24h. A session restored the next day, or a
// long listen, will 403. Re-resolve the source page, swap in fresh URLs and
// resume at the same offset; if that fails too, fall back to the iframe embed.
const lastReresolve = useRef<{ key: string; at: number } | null>(null);
const fallBackToEmbed = useCallback((
item: Extract<PlayerItem, { kind: "stream" }>,
) => {
if (!item.embedUrl) {
stop();
return;
}
playQueue([{
kind: "embed",
embedUrl: item.embedUrl,
type: item.type,
title: item.title,
dumpHref: item.dumpHref,
}], 0);
}, [playQueue, stop]);
/**
* A track that stays dead after a re-resolve is one track, not one album:
* step over it and keep the queue. Only the last one falls back to the embed.
*/
const skipOrFallBack = useCallback((
item: Extract<PlayerItem, { kind: "stream" }>,
) => {
const { queue: q, queueIndex: i } = stateRef.current;
if (i + 1 < q.length) {
advanceTo(i + 1);
return;
}
fallBackToEmbed(item);
}, [advanceTo, fallBackToEmbed]);
const resolveSeq = useRef(0);
const reresolve = useCallback(async (
item: Extract<PlayerItem, { kind: "stream" }>,
{ resumeAt, shouldAutoplay }: { resumeAt: number; shouldAutoplay: boolean },
) => {
const key = playerItemKey(item);
const now = Date.now();
const last = lastReresolve.current;
if (last && last.key === key && now - last.at < RERESOLVE_COOLDOWN_MS) {
// Already tried recently — the track is genuinely dead, not just stale.
skipOrFallBack(item);
return;
}
lastReresolve.current = { key, at: now };
// Resolving is a network round-trip against Bandcamp. The user can pick
// another dump, skip on, or close the player while it's in flight — and a
// result that lands after that must not drag the player back here.
const gen = playGen.current;
const stillOurs = () => playGen.current === gen;
// Separate from `gen`: the spinner belongs to the newest resolve, whichever
// item that one is for, so a superseded resolve must not clear it.
const seq = ++resolveSeq.current;
setResolving(true);
try {
const items = await resolveStreamQueue(item);
if (!stillOurs()) return;
const found = items.findIndex((i) =>
i.kind === "stream" && i.resolveIndex === item.resolveIndex
);
// The track can be gone from the release, or no longer streamable once
// Bandcamp's free-play cap kicks in. Falling back to the first track is
// fine; carrying the old offset onto it would seek past its end.
const index = found < 0 ? 0 : found;
const resume = found < 0 ? 0 : resumeAt;
suppressUpdates.current = true;
setQueue(items);
setQueueIndex(index);
setCurrentTime(resume);
setStartTime(resume);
setDuration(0);
setAutoplay(shouldAutoplay);
setPlaying(false);
} catch {
// The resolve itself failed (offline, endpoint down) — that's the whole
// album, not one track, so the embed is the right fallback.
if (stillOurs()) fallBackToEmbed(item);
} finally {
if (resolveSeq.current === seq) setResolving(false);
}
}, [fallBackToEmbed, skipOrFallBack]);
const onError = useCallback(() => {
const item = stateRef.current.current;
if (item?.kind !== "stream") return;
void reresolve(item, {
resumeAt: stateRef.current.currentTime,
shouldAutoplay: true,
});
}, [reresolve]);
// A session restored from a previous day holds URLs that are already dead.
// Refresh them up front so the first press of play doesn't visibly stall.
useEffect(() => { useEffect(() => {
if (current) { if (!restored) return;
const item = restored.queue[restored.index];
if (item?.kind === "stream" && isStreamStale(item)) {
// Genuinely an external-system sync: the stored URLs are dead and only
// the network can replace them. The setState this triggers is the point.
// eslint-disable-next-line react-hooks/set-state-in-effect
void reresolve(item, {
resumeAt: restored.time,
shouldAutoplay: false,
});
}
// Runs once, against the session captured at mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Persistence ────────────────────────────────────────────────────────────────
// Write the queue to storage as soon as it changes (survives crashes), then
// refresh the playback position on pagehide — which also fires on a normal
// reload — so the resume offset is accurate without thrashing localStorage on
// every timeupdate.
useEffect(() => {
if (queue.length > 0) {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ item: current, time: currentTime } satisfies StoredSession), JSON.stringify(
{ v: 2, queue, index: queueIndex, time: currentTime } satisfies StoredSession,
),
); );
} else { } else {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
// currentTime intentionally omitted from deps — see pagehide writer below. // currentTime intentionally omitted from deps — see pagehide writer below.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [current]); }, [queue, queueIndex]);
const sessionRef = useRef<StoredSession | null>(null); const sessionRef = useRef<StoredSession | null>(null);
sessionRef.current = current ? { item: current, time: currentTime } : null; useEffect(() => {
sessionRef.current = queue.length > 0
? { v: 2, queue, index: queueIndex, time: currentTime }
: null;
});
useEffect(() => { useEffect(() => {
const persist = () => { const persist = () => {
@@ -118,9 +357,18 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
playing, playing,
currentTime, currentTime,
duration, duration,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime, startTime,
autoplay, autoplay,
play, play,
playQueue,
playAt,
next,
previous,
stop, stop,
seekTo, seekTo,
togglePlay, togglePlay,
@@ -128,19 +376,32 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
toggleRef, toggleRef,
onPlayStateChange, onPlayStateChange,
onTimeUpdate, onTimeUpdate,
onEnded,
onError,
}), [ }), [
current, current,
playing, playing,
currentTime, currentTime,
duration, duration,
queue,
queueIndex,
hasNext,
hasPrevious,
resolving,
startTime, startTime,
autoplay, autoplay,
play, play,
playQueue,
playAt,
next,
previous,
stop, stop,
seekTo, seekTo,
togglePlay, togglePlay,
onPlayStateChange, onPlayStateChange,
onTimeUpdate, onTimeUpdate,
onEnded,
onError,
]); ]);
return ( return (

View File

@@ -59,22 +59,77 @@ export const useAuth = () => {
return res; return res;
}, [authResponse?.token, logout]); }, [authResponse?.token, logout]);
/**
* `authFetch` for multipart uploads that need a progress bar.
*
* `fetch` reports nothing until the whole body is on the wire, which for a
* 50 MB dump on a phone is a long silence — so this goes through XHR, whose
* `upload.progress` events fire as bytes leave. The result is wrapped back
* into a real `Response` so callers keep using `expectOk` unchanged.
*
* `onProgress` receives a 01 fraction, or `null` once the body is fully sent
* and we are waiting on the server (the length is unknown for chunked bodies,
* and "100%, still waiting" reads as a stall).
*/
const authUpload = useCallback((
url: string,
body: FormData,
onProgress: (fraction: number | null) => void,
method = "POST",
): Promise<Response> => {
const token = authResponse?.token;
if (token && isTokenExpired(token)) {
logout();
return Promise.resolve(new Response(null, { status: 401 }));
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
if (token) xhr.setRequestHeader("Authorization", `Bearer ${token}`);
// Content-Type is left to the browser so the multipart boundary is set.
xhr.upload.addEventListener("progress", (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : null);
});
xhr.upload.addEventListener("load", () => onProgress(null));
xhr.addEventListener("load", () => {
if (xhr.status === 401) logout();
resolve(
new Response(xhr.responseText, {
status: xhr.status,
statusText: xhr.statusText,
}),
);
});
// Mirrors what `fetch` throws on a network failure, so the shared
// `friendlyFetchError` path renders it the same way.
xhr.addEventListener("error", () => reject(new TypeError("Failed to fetch")));
xhr.addEventListener("abort", () => reject(new TypeError("Failed to fetch")));
xhr.send(body);
});
}, [authResponse?.token, logout]);
return { return {
user: authResponse?.user ?? null, user: authResponse?.user ?? null,
token: authResponse?.token ?? null, token: authResponse?.token ?? null,
login, login,
logout, logout,
authFetch, authFetch,
authUpload,
}; };
}; };
export const useRequiredAuth = () => { export const useRequiredAuth = () => {
const { user, token, login, logout, authFetch } = useAuth(); const { user, token, login, logout, authFetch, authUpload } = useAuth();
if (!user) { if (!user) {
throw new Error( throw new Error(
"Invariant: useRequiredAuth called outside a protected route", "Invariant: useRequiredAuth called outside a protected route",
); );
} }
return { user, token, login, logout, authFetch }; return { user, token, login, logout, authFetch, authUpload };
}; };

View File

@@ -0,0 +1,88 @@
import { useCallback, useContext, useState } from "react";
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
import { BANDCAMP_PLAYER } from "../config/playerMode.ts";
import { resolveBandcampQueue } from "../utils/bandcamp.ts";
export interface PlayableRichContent {
type: string;
url: string;
title?: string;
embedUrl?: string;
/** Header artwork. Callers with a dump-level override should pass that. */
thumbnailUrl?: string;
/** Header subtitle — "YouTube", "SoundCloud", the site's own name. */
siteName?: string;
}
/**
* Start playback for a rich-content item.
*
* Everything except Bandcamp goes straight to the iframe embed, exactly as
* before. Bandcamp additionally honours GERBEUR_BANDCAMP_PLAYER: in "native"
* mode the page is resolved to its mp3 streams and played as a queue, which is
* what makes it autoplay and lets albums play through. Any failure — offline,
* preorder-only release, Bandcamp changing its markup — falls back to the embed.
*
* Returns whether playback actually started. A native-mode page with no stored
* embedUrl (an artist root, a /music index, a preorder) has nothing to fall
* back to, and the caller has to be told so the click isn't swallowed.
*/
export function usePlayRichContent() {
const { play, playQueue } = useContext(PlayerContext);
const [pending, setPending] = useState(false);
const playEmbed = useCallback(
(rc: PlayableRichContent, dumpHref?: string): boolean => {
if (!rc.embedUrl) return false;
play({
kind: "embed",
embedUrl: rc.embedUrl,
title: rc.title,
type: rc.type,
dumpHref,
artworkUrl: rc.thumbnailUrl,
subtitle: rc.siteName,
});
return true;
},
[play],
);
const playRichContent = useCallback(
async (rc: PlayableRichContent, dumpHref?: string): Promise<boolean> => {
const native = rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
if (!native) return playEmbed(rc, dumpHref);
setPending(true);
try {
const items: PlayerItem[] = await resolveBandcampQueue(rc.url, {
dumpHref,
embedUrl: rc.embedUrl,
// Bandcamp's own album art wins; this is the fallback if the page
// doesn't carry one.
fallbackArtworkUrl: rc.thumbnailUrl,
});
playQueue(items, 0);
return true;
} catch (err) {
console.warn("bandcamp: native playback unavailable, using embed", err);
return playEmbed(rc, dumpHref);
} finally {
setPending(false);
}
},
[playEmbed, playQueue],
);
return { playRichContent, pending };
}
/**
* Whether a rich-content item can be played at all. In native mode a Bandcamp
* page is playable even without a stored embedUrl, since the streams are
* resolved from the page itself.
*/
export function canPlayRichContent(rc: PlayableRichContent): boolean {
if (rc.embedUrl) return true;
return rc.type === "bandcamp" && BANDCAMP_PLAYER === "native";
}

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,8 @@ msgid "[deleted]"
msgstr "[deleted]" msgstr "[deleted]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:111 #: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:112 #: src/components/JournalCard.tsx:114
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}" msgstr "{0, plural, one {# comment} other {# comments}}"
@@ -28,14 +28,19 @@ msgstr "{0, plural, one {# comment} other {# comments}}"
msgid "{0, plural, one {# dump} other {# dumps}}" msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}"
#. placeholder {0}: rest.length
#: src/components/DumpCreateModal.tsx:246
msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
#. placeholder {0}: names[0] #. placeholder {0}: names[0]
#. placeholder {1}: names[1] #. placeholder {1}: names[1]
#: src/components/ChatModal.tsx:528 #: src/components/ChatModal.tsx:531
msgid "{0} and {1} are typing…" msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…" msgstr "{0} and {1} are typing…"
#. placeholder {0}: names[0] #. placeholder {0}: names[0]
#: src/components/ChatModal.tsx:527 #: src/components/ChatModal.tsx:530
msgid "{0} is typing…" msgid "{0} is typing…"
msgstr "{0} is typing…" msgstr "{0} is typing…"
@@ -53,14 +58,15 @@ msgstr "{label} ({count})"
msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#: src/components/DumpCreateModal.tsx:935
#: src/pages/PlaylistDetail.tsx:570 #: src/pages/PlaylistDetail.tsx:570
#: src/pages/UserPublicProfile.tsx:749 #: src/pages/UserPublicProfile.tsx:749
msgid "← Back" msgid "← Back"
msgstr "← Back" msgstr "← Back"
#: src/pages/Dump.tsx:264 #: src/pages/Dump.tsx:292
#: src/pages/Dump.tsx:494 #: src/pages/Dump.tsx:522
#: src/pages/DumpEdit.tsx:181 #: src/pages/DumpEdit.tsx:187
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Back to all dumps" msgstr "← Back to all dumps"
@@ -78,7 +84,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ New playlist" msgstr "+ New playlist"
#: src/pages/Dump.tsx:335 #: src/pages/Dump.tsx:363
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Playlist" msgstr "+ Playlist"
@@ -154,7 +160,7 @@ msgid "Add email…"
msgstr "Add email…" msgstr "Add email…"
#: src/components/AddToPlaylistModal.tsx:64 #: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:301 #: src/components/DumpCreateModal.tsx:659
msgid "Add to playlist" msgid "Add to playlist"
msgstr "Add to playlist" msgstr "Add to playlist"
@@ -167,6 +173,12 @@ msgstr "Admin"
msgid "All" msgid "All"
msgstr "All" msgstr "All"
#. placeholder {0}: first.username
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/DumpCreateModal.tsx:233
msgid "Already dumped by {0} {1}"
msgstr "Already dumped by {0} {1}"
#: src/pages/UserRegister.tsx:156 #: src/pages/UserRegister.tsx:156
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>" msgstr "Already have an account? <0>Log in</0>"
@@ -201,9 +213,10 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/ChatModal.tsx:229 #: src/components/ChatModal.tsx:229
#: src/components/CommentThread.tsx:124 #: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/DumpCreateModal.tsx:945
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:376 #: src/pages/Dump.tsx:404
#: src/pages/DumpEdit.tsx:460 #: src/pages/DumpEdit.tsx:469
#: src/pages/PlaylistDetail.tsx:920 #: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674 #: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1744 #: src/pages/UserPublicProfile.tsx:1744
@@ -214,8 +227,8 @@ msgstr "Cancel"
msgid "Cancel removal" msgid "Cancel removal"
msgstr "Cancel removal" msgstr "Cancel removal"
#: src/components/ChatModal.tsx:619 #: src/components/ChatModal.tsx:622
#: src/components/ChatModal.tsx:620 #: src/components/ChatModal.tsx:623
msgid "Cancel reply" msgid "Cancel reply"
msgstr "Cancel reply" msgstr "Cancel reply"
@@ -248,7 +261,7 @@ msgstr "Change password…"
#: src/components/ChatButton.tsx:17 #: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49 #: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50 #: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:533 #: src/components/ChatModal.tsx:536
msgid "Chat" msgid "Chat"
msgstr "Chat" msgstr "Chat"
@@ -262,10 +275,26 @@ msgid "Checking invite…"
msgstr "Checking invite…" msgstr "Checking invite…"
#: src/components/ChangePasswordModal.tsx:56 #: src/components/ChangePasswordModal.tsx:56
#: src/components/Modal.tsx:45 #: src/components/Modal.tsx:58
msgid "Close" msgid "Close"
msgstr "Close" msgstr "Close"
#: src/components/DumpCreateModal.tsx:690
msgid "Close anyway"
msgstr "Close anyway"
#: src/components/GlobalPlayer.tsx:184
msgid "Close player"
msgstr "Close player"
#: src/components/DumpCreateModal.tsx:680
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Close without posting? The attached file won't be kept — everything you typed will."
#: src/components/GlobalPlayer.tsx:176
msgid "Collapse player"
msgstr "Collapse player"
#: src/pages/UserPublicProfile.tsx:1190 #: src/pages/UserPublicProfile.tsx:1190
msgid "Color scheme" msgid "Color scheme"
msgstr "Color scheme" msgstr "Color scheme"
@@ -291,10 +320,14 @@ msgstr "Could not change password"
msgid "Could not load." msgid "Could not load."
msgstr "Could not load." msgstr "Could not load."
#: src/pages/DumpEdit.tsx:361 #: src/pages/DumpEdit.tsx:370
msgid "Could not save" msgid "Could not save"
msgstr "Could not save" msgstr "Could not save"
#: src/components/DumpCreateModal.tsx:802
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
#: src/components/PlaylistCreateForm.tsx:87 #: src/components/PlaylistCreateForm.tsx:87
msgid "Create" msgid "Create"
msgstr "Create" msgstr "Create"
@@ -346,8 +379,8 @@ msgstr "Delete category"
msgid "Delete category \"{0}\"? This cannot be undone." msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Delete category \"{0}\"? This cannot be undone." msgstr "Delete category \"{0}\"? This cannot be undone."
#: src/pages/DumpEdit.tsx:255 #: src/pages/DumpEdit.tsx:264
#: src/pages/DumpEdit.tsx:456 #: src/pages/DumpEdit.tsx:465
msgid "Delete dump" msgid "Delete dump"
msgstr "Delete dump" msgstr "Delete dump"
@@ -361,7 +394,7 @@ msgstr "Delete playlist"
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Delete this comment?" msgstr "Delete this comment?"
#: src/pages/DumpEdit.tsx:254 #: src/pages/DumpEdit.tsx:263
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone."
@@ -383,7 +416,7 @@ msgstr "deleted message"
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (optional)" msgstr "Description (optional)"
#: src/components/DumpCreateModal.tsx:488 #: src/components/DumpCreateModal.tsx:741
msgid "Done" msgid "Done"
msgstr "Done" msgstr "Done"
@@ -391,7 +424,11 @@ msgstr "Done"
msgid "Drop a file here" msgid "Drop a file here"
msgstr "Drop a file here" msgstr "Drop a file here"
#: src/pages/DumpEdit.tsx:428 #: src/components/DumpCreateModal.tsx:847
msgid "Drop a file here, or paste one"
msgstr "Drop a file here, or paste one"
#: src/pages/DumpEdit.tsx:437
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Drop a replacement here" msgstr "Drop a replacement here"
@@ -399,11 +436,11 @@ msgstr "Drop a replacement here"
msgid "Dump" msgid "Dump"
msgstr "Dump" msgstr "Dump"
#: src/components/DumpCreateModal.tsx:458 #: src/components/DumpCreateModal.tsx:958
msgid "Dump it" msgid "Dump it"
msgstr "Dump it" msgstr "Dump it"
#: src/components/DumpCreateModal.tsx:469 #: src/components/DumpCreateModal.tsx:718
msgid "Dumped!" msgid "Dumped!"
msgstr "Dumped!" msgstr "Dumped!"
@@ -426,7 +463,7 @@ msgstr "Earlier"
#: src/components/ChatModal.tsx:172 #: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173 #: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:490 #: src/pages/Dump.tsx:518
#: src/pages/PlaylistDetail.tsx:625 #: src/pages/PlaylistDetail.tsx:625
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@@ -436,7 +473,7 @@ msgstr "Edit"
msgid "Edit {0}" msgid "Edit {0}"
msgstr "Edit {0}" msgstr "Edit {0}"
#: src/components/DumpCreateModal.tsx:420 #: src/components/DumpCreateModal.tsx:197
msgid "Edit title" msgid "Edit title"
msgstr "Edit title" msgstr "Edit title"
@@ -445,7 +482,7 @@ msgstr "Edit title"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:152 #: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:429 #: src/pages/Dump.tsx:457
#: src/pages/PlaylistDetail.tsx:664 #: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -455,12 +492,12 @@ msgstr "edited {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:150 #: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:427 #: src/pages/Dump.tsx:455
#: src/pages/PlaylistDetail.tsx:661 #: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Edited {0}" msgstr "Edited {0}"
#: src/pages/DumpEdit.tsx:206 #: src/pages/DumpEdit.tsx:212
msgid "Editing" msgid "Editing"
msgstr "Editing" msgstr "Editing"
@@ -477,6 +514,10 @@ msgstr "Email address"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Enter a query to search." msgstr "Enter a query to search."
#: src/components/GlobalPlayer.tsx:176
msgid "Expand player"
msgstr "Expand player"
#: src/components/CategoryManager.tsx:230 #: src/components/CategoryManager.tsx:230
msgid "Failed to create category" msgid "Failed to create category"
msgstr "Failed to create category" msgstr "Failed to create category"
@@ -506,7 +547,7 @@ msgstr "Failed to generate invite"
msgid "Failed to load" msgid "Failed to load"
msgstr "Failed to load" msgstr "Failed to load"
#: src/components/DumpCreateModal.tsx:337 #: src/components/DumpCreateModal.tsx:750
msgid "Failed to post" msgid "Failed to post"
msgstr "Failed to post" msgstr "Failed to post"
@@ -544,19 +585,11 @@ msgstr "Failed to update role"
msgid "Feeds" msgid "Feeds"
msgstr "Feeds" msgstr "Feeds"
#: src/components/DumpCreateModal.tsx:369 #: src/components/DumpCreateModal.tsx:789
msgid "Fetching preview…" msgid "Fetching preview…"
msgstr "Fetching preview…" msgstr "Fetching preview…"
#: src/components/DumpCreateModal.tsx:455 #: src/components/DumpCreateModal.tsx:571
msgid "Fetching…"
msgstr "Fetching…"
#: src/components/DumpCreateModal.tsx:331
msgid "File"
msgstr "File"
#: src/components/DumpCreateModal.tsx:239
msgid "File too large (max 50 MB)." msgid "File too large (max 50 MB)."
msgstr "File too large (max 50 MB)." msgstr "File too large (max 50 MB)."
@@ -632,6 +665,10 @@ msgstr "Hot"
msgid "If that address is registered you'll receive a reset link shortly." msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly." msgstr "If that address is registered you'll receive a reset link shortly."
#: src/pages/Dump.tsx:552
msgid "In collections"
msgstr "In collections"
#: src/pages/UserRegister.tsx:96 #: src/pages/UserRegister.tsx:96
msgid "Invalid invite" msgid "Invalid invite"
msgstr "Invalid invite" msgstr "Invalid invite"
@@ -654,6 +691,10 @@ msgstr "Invitees"
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
#: src/components/DumpCreateModal.tsx:687
msgid "Keep editing"
msgstr "Keep editing"
#: src/pages/UserPublicProfile.tsx:1205 #: src/pages/UserPublicProfile.tsx:1205
msgid "Light" msgid "Light"
msgstr "Light" msgstr "Light"
@@ -662,6 +703,14 @@ msgstr "Light"
msgid "Like" msgid "Like"
msgstr "Like" msgstr "Like"
#: src/components/DumpCreateModal.tsx:764
msgid "Link"
msgstr "Link"
#: src/components/DumpCreateModal.tsx:66
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/WSProvider.tsx:575 #: src/contexts/WSProvider.tsx:575
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -675,12 +724,12 @@ msgstr "Live updates unavailable."
msgid "Load more" msgid "Load more"
msgstr "Load more" msgstr "Load more"
#: src/components/ChatModal.tsx:546 #: src/components/ChatModal.tsx:549
msgid "Load older messages" msgid "Load older messages"
msgstr "Load older messages" msgstr "Load older messages"
#: src/pages/Dump.tsx:240 #: src/pages/Dump.tsx:268
#: src/pages/DumpEdit.tsx:157 #: src/pages/DumpEdit.tsx:163
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Loading dump…" msgstr "Loading dump…"
@@ -705,7 +754,7 @@ msgid "Loading profile…"
msgstr "Loading profile…" msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:52 #: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:545 #: src/components/ChatModal.tsx:548
#: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289 #: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192 #: src/components/UserListPopover.tsx:192
@@ -779,7 +828,7 @@ msgstr "new"
msgid "New" msgid "New"
msgstr "New" msgstr "New"
#: src/components/DumpCreateModal.tsx:301 #: src/components/DumpCreateModal.tsx:659
#: src/components/DumpFab.tsx:65 #: src/components/DumpFab.tsx:65
#: src/components/DumpFab.tsx:66 #: src/components/DumpFab.tsx:66
#: src/pages/UserDumps.tsx:88 #: src/pages/UserDumps.tsx:88
@@ -797,6 +846,14 @@ msgstr "New password"
msgid "New playlist" msgid "New playlist"
msgstr "New playlist" msgstr "New playlist"
#: src/components/DumpCreateModal.tsx:957
msgid "Next →"
msgstr "Next →"
#: src/components/GlobalPlayer.tsx:166
msgid "Next track"
msgstr "Next track"
#: src/pages/PlaylistDetail.tsx:680 #: src/pages/PlaylistDetail.tsx:680
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet." msgstr "No dumps in this playlist yet."
@@ -824,7 +881,7 @@ msgstr "No followed playlists yet."
msgid "No invitees yet." msgid "No invitees yet."
msgstr "No invitees yet." msgstr "No invitees yet."
#: src/components/ChatModal.tsx:553 #: src/components/ChatModal.tsx:556
msgid "No messages yet. Say hello!" msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!" msgstr "No messages yet. Say hello!"
@@ -903,11 +960,20 @@ msgstr "Password updated"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Passwords do not match" msgstr "Passwords do not match"
#: src/components/DumpCreateModal.tsx:782
msgid "Paste a link…"
msgstr "Paste a link…"
#: src/components/DumpCreateModal.tsx:699
msgid "Picked up where you left off."
msgstr "Picked up where you left off."
#: src/pages/PlaylistDetail.tsx:863 #: src/pages/PlaylistDetail.tsx:863
msgid "Playlist title" msgid "Playlist title"
msgstr "Playlist title" msgstr "Playlist title"
#: src/components/AppHeader.tsx:86 #: src/components/AppHeader.tsx:86
#: src/components/DumpCreateModal.tsx:68
#: src/components/UserMenu.tsx:62 #: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177 #: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371 #: src/pages/UserPlaylists.tsx:371
@@ -921,10 +987,6 @@ msgstr "Playlists"
msgid "Playlists ({0}{1})" msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})" msgstr "Playlists ({0}{1})"
#: src/components/DumpCreateModal.tsx:236
msgid "Please select a file."
msgstr "Please select a file."
#: src/components/CommentThread.tsx:472 #: src/components/CommentThread.tsx:472
msgid "Post comment" msgid "Post comment"
msgstr "Post comment" msgstr "Post comment"
@@ -935,19 +997,24 @@ msgstr "Post reply"
#: src/components/CommentThread.tsx:396 #: src/components/CommentThread.tsx:396
#: src/components/CommentThread.tsx:473 #: src/components/CommentThread.tsx:473
#: src/components/DumpCreateModal.tsx:951
msgid "Posting…" msgid "Posting…"
msgstr "Posting…" msgstr "Posting…"
#: src/components/DumpCard.tsx:120 #: src/components/GlobalPlayer.tsx:154
#: src/components/JournalCard.tsx:121 msgid "Previous track"
msgstr "Previous track"
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:123
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:435 #: src/pages/Dump.tsx:463
#: src/pages/PlaylistDetail.tsx:644 #: src/pages/PlaylistDetail.tsx:644
msgid "private" msgid "private"
msgstr "private" msgstr "private"
#: src/components/form/SegmentedField.tsx:78 #: src/components/form/SegmentedField.tsx:81
#: src/pages/PlaylistDetail.tsx:902 #: src/pages/PlaylistDetail.tsx:902
msgid "Private" msgid "Private"
msgstr "Private" msgstr "Private"
@@ -957,16 +1024,20 @@ msgstr "Private"
msgid "public" msgid "public"
msgstr "public" msgstr "public"
#: src/components/form/SegmentedField.tsx:77 #: src/components/form/SegmentedField.tsx:80
#: src/pages/PlaylistDetail.tsx:895 #: src/pages/PlaylistDetail.tsx:895
msgid "Public" msgid "Public"
msgstr "Public" msgstr "Public"
#: src/pages/DumpEdit.tsx:235 #: src/pages/DumpEdit.tsx:241
msgid "Refresh metadata" msgid "Refresh metadata"
msgstr "Refresh metadata" msgstr "Refresh metadata"
#: src/pages/DumpEdit.tsx:234 #: src/components/GlobalPlayer.tsx:240
msgid "Refreshing stream…"
msgstr "Refreshing stream…"
#: src/pages/DumpEdit.tsx:240
msgid "Refreshing…" msgid "Refreshing…"
msgstr "Refreshing…" msgstr "Refreshing…"
@@ -984,7 +1055,7 @@ msgstr "Registering…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Registration failed" msgstr "Registration failed"
#: src/pages/Dump.tsx:502 #: src/pages/Dump.tsx:530
msgid "Related" msgid "Related"
msgstr "Related" msgstr "Related"
@@ -1004,7 +1075,7 @@ msgstr "Remove like"
msgid "Remove vote" msgid "Remove vote"
msgstr "Remove vote" msgstr "Remove vote"
#: src/pages/DumpEdit.tsx:420 #: src/pages/DumpEdit.tsx:429
msgid "Replace file" msgid "Replace file"
msgstr "Replace file" msgstr "Replace file"
@@ -1014,7 +1085,7 @@ msgstr "Replace file"
msgid "Reply" msgid "Reply"
msgstr "Reply" msgstr "Reply"
#: src/components/ChatModal.tsx:609 #: src/components/ChatModal.tsx:612
msgid "Replying to" msgid "Replying to"
msgstr "Replying to" msgstr "Replying to"
@@ -1030,13 +1101,18 @@ msgstr "Reset failed"
msgid "Reset password" msgid "Reset password"
msgstr "Reset password" msgstr "Reset password"
#: src/pages/DumpEdit.tsx:381 #: src/pages/DumpEdit.tsx:390
#: src/pages/DumpEdit.tsx:399 #: src/pages/DumpEdit.tsx:408
msgid "Reset to default" msgid "Reset to default"
msgstr "Reset to default" msgstr "Reset to default"
#: src/pages/Dump.tsx:257 #: src/components/DumpCreateModal.tsx:208
#: src/pages/DumpEdit.tsx:174 #: src/components/DumpCreateModal.tsx:209
msgid "Reset to the suggested title"
msgstr "Reset to the suggested title"
#: src/pages/Dump.tsx:285
#: src/pages/DumpEdit.tsx:180
msgid "Retry" msgid "Retry"
msgstr "Retry" msgstr "Retry"
@@ -1046,8 +1122,8 @@ msgstr "Role"
#: src/components/ChatModal.tsx:222 #: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:396
#: src/pages/DumpEdit.tsx:463 #: src/pages/DumpEdit.tsx:472
#: src/pages/PlaylistDetail.tsx:927 #: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666 #: src/pages/UserPublicProfile.tsx:1666
#: src/pages/UserPublicProfile.tsx:1736 #: src/pages/UserPublicProfile.tsx:1736
@@ -1056,7 +1132,7 @@ msgstr "Save"
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:367 #: src/pages/Dump.tsx:395
#: src/pages/PlaylistDetail.tsx:923 #: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126 #: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663 #: src/pages/UserPublicProfile.tsx:1663
@@ -1082,7 +1158,7 @@ msgstr "Search failed"
msgid "Searching…" msgid "Searching…"
msgstr "Searching…" msgstr "Searching…"
#: src/components/ChatModal.tsx:655 #: src/components/ChatModal.tsx:658
msgid "Send" msgid "Send"
msgstr "Send" msgstr "Send"
@@ -1107,7 +1183,7 @@ msgstr "Set new password"
msgid "Settings" msgid "Settings"
msgstr "Settings" msgstr "Settings"
#: src/components/ChatModal.tsx:529 #: src/components/ChatModal.tsx:532
msgid "Several people are typing…" msgid "Several people are typing…"
msgstr "Several people are typing…" msgstr "Several people are typing…"
@@ -1120,6 +1196,10 @@ msgstr "slug"
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Something went wrong" msgstr "Something went wrong"
#: src/components/DumpCreateModal.tsx:706
msgid "Start fresh"
msgstr "Start fresh"
#: src/pages/UserPublicProfile.tsx:1155 #: src/pages/UserPublicProfile.tsx:1155
msgid "Style" msgid "Style"
msgstr "Style" msgstr "Style"
@@ -1148,17 +1228,17 @@ msgstr "This page does not exist."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "This reset link is missing or malformed." msgstr "This reset link is missing or malformed."
#: src/pages/DumpEdit.tsx:365 #: src/pages/DumpEdit.tsx:374
msgid "Thumbnail" msgid "Thumbnail"
msgstr "Thumbnail" msgstr "Thumbnail"
#: src/components/DumpCreateModal.tsx:389 #: src/components/DumpCreateModal.tsx:174
#: src/components/PlaylistCreateForm.tsx:70 #: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:389 #: src/pages/DumpEdit.tsx:398
msgid "Title" msgid "Title"
msgstr "Title" msgstr "Title"
#: src/components/DumpCreateModal.tsx:237 #: src/components/DumpCreateModal.tsx:569
msgid "Title is required." msgid "Title is required."
msgstr "Title is required." msgstr "Title is required."
@@ -1167,7 +1247,7 @@ msgstr "Title is required."
msgid "Today" msgid "Today"
msgstr "Today" msgstr "Today"
#: src/components/ChatModal.tsx:632 #: src/components/ChatModal.tsx:635
msgid "Type a message…" msgid "Type a message…"
msgstr "Type a message…" msgstr "Type a message…"
@@ -1187,7 +1267,12 @@ msgstr "Unfollow playlist"
msgid "Upload failed" msgid "Upload failed"
msgstr "Upload failed" msgstr "Upload failed"
#: src/components/DumpCreateModal.tsx:456 #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
#: src/components/DumpCreateModal.tsx:642
msgid "Uploading {0}%"
msgstr "Uploading {0}%"
#: src/components/DumpCreateModal.tsx:954
msgid "Uploading…" msgid "Uploading…"
msgstr "Uploading…" msgstr "Uploading…"
@@ -1205,12 +1290,11 @@ msgstr "Upvoted"
msgid "Upvoted ({0}{1})" msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})"
#: src/components/DumpCreateModal.tsx:344 #: src/pages/DumpEdit.tsx:421
#: src/pages/DumpEdit.tsx:412
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
#: src/components/DumpCreateModal.tsx:223 #: src/components/DumpCreateModal.tsx:553
msgid "URL is required." msgid "URL is required."
msgstr "URL is required." msgstr "URL is required."
@@ -1247,12 +1331,12 @@ msgstr "Users"
msgid "View all →" msgid "View all →"
msgstr "View all →" msgstr "View all →"
#: src/components/DumpCreateModal.tsx:471 #: src/components/DumpCreateModal.tsx:720
msgid "View dump →" msgid "View dump →"
msgstr "View dump →" msgstr "View dump →"
#: src/components/DumpCreateModal.tsx:434 #: src/components/DumpCreateModal.tsx:904
#: src/pages/DumpEdit.tsx:437 #: src/pages/DumpEdit.tsx:446
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "What makes it worth it?" msgstr "What makes it worth it?"
@@ -1261,8 +1345,16 @@ msgstr "What makes it worth it?"
msgid "Who am I?" msgid "Who am I?"
msgstr "Who am I?" msgstr "Who am I?"
#: src/components/DumpCreateModal.tsx:433 #: src/components/DumpCreateModal.tsx:920
#: src/pages/DumpEdit.tsx:436 msgid "Who can see it"
msgstr "Who can see it"
#: src/components/DumpCreateModal.tsx:67
msgid "Why & where"
msgstr "Why & where"
#: src/components/DumpCreateModal.tsx:903
#: src/pages/DumpEdit.tsx:445
msgid "Why?" msgid "Why?"
msgstr "Why?" msgstr "Why?"
@@ -1275,6 +1367,10 @@ msgstr "Write a reply…"
msgid "Yesterday" msgid "Yesterday"
msgstr "Yesterday" msgstr "Yesterday"
#: src/components/DumpCreateModal.tsx:254
msgid "You can post it anyway."
msgstr "You can post it anyway."
#: src/pages/Notifications.tsx:384 #: src/pages/Notifications.tsx:384
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."

File diff suppressed because one or more lines are too long

View File

@@ -18,8 +18,8 @@ msgid "[deleted]"
msgstr "[supprimé]" msgstr "[supprimé]"
#. placeholder {0}: dump.commentCount #. placeholder {0}: dump.commentCount
#: src/components/DumpCard.tsx:111 #: src/components/DumpCard.tsx:112
#: src/components/JournalCard.tsx:112 #: src/components/JournalCard.tsx:114
msgid "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}" msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
@@ -28,14 +28,19 @@ msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
msgid "{0, plural, one {# dump} other {# dumps}}" msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}" msgstr "{0, plural, one {# reco} other {# recos}}"
#. placeholder {0}: rest.length
#: src/components/DumpCreateModal.tsx:246
msgid "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus anciennes}}"
#. placeholder {0}: names[0] #. placeholder {0}: names[0]
#. placeholder {1}: names[1] #. placeholder {1}: names[1]
#: src/components/ChatModal.tsx:528 #: src/components/ChatModal.tsx:531
msgid "{0} and {1} are typing…" msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…" msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: names[0] #. placeholder {0}: names[0]
#: src/components/ChatModal.tsx:527 #: src/components/ChatModal.tsx:530
msgid "{0} is typing…" msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…" msgstr "{0} est en train d'écrire…"
@@ -53,14 +58,15 @@ msgstr "{label} ({count})"
msgid "{visibleCount, plural, one {# comment} other {# comments}}" msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#: src/components/DumpCreateModal.tsx:935
#: src/pages/PlaylistDetail.tsx:570 #: src/pages/PlaylistDetail.tsx:570
#: src/pages/UserPublicProfile.tsx:749 #: src/pages/UserPublicProfile.tsx:749
msgid "← Back" msgid "← Back"
msgstr "← Retour" msgstr "← Retour"
#: src/pages/Dump.tsx:264 #: src/pages/Dump.tsx:292
#: src/pages/Dump.tsx:494 #: src/pages/Dump.tsx:522
#: src/pages/DumpEdit.tsx:181 #: src/pages/DumpEdit.tsx:187
msgid "← Back to all dumps" msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos" msgstr "← Retour à toutes les recos"
@@ -78,7 +84,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist" msgid "+ New playlist"
msgstr "+ Nouvelle collection" msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:335 #: src/pages/Dump.tsx:363
msgid "+ Playlist" msgid "+ Playlist"
msgstr "+ Collection" msgstr "+ Collection"
@@ -154,7 +160,7 @@ msgid "Add email…"
msgstr "Ajouter un e-mail…" msgstr "Ajouter un e-mail…"
#: src/components/AddToPlaylistModal.tsx:64 #: src/components/AddToPlaylistModal.tsx:64
#: src/components/DumpCreateModal.tsx:301 #: src/components/DumpCreateModal.tsx:659
msgid "Add to playlist" msgid "Add to playlist"
msgstr "Ajouter à la collection" msgstr "Ajouter à la collection"
@@ -167,6 +173,12 @@ msgstr "Administrateur"
msgid "All" msgid "All"
msgstr "Tout" msgstr "Tout"
#. placeholder {0}: first.username
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/DumpCreateModal.tsx:233
msgid "Already dumped by {0} {1}"
msgstr "Déjà recommandé par {0} {1}"
#: src/pages/UserRegister.tsx:156 #: src/pages/UserRegister.tsx:156
msgid "Already have an account? <0>Log in</0>" msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>" msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
@@ -201,9 +213,10 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/ChatModal.tsx:229 #: src/components/ChatModal.tsx:229
#: src/components/CommentThread.tsx:124 #: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32 #: src/components/ConfirmModal.tsx:32
#: src/components/DumpCreateModal.tsx:945
#: src/components/form/FormActions.tsx:32 #: src/components/form/FormActions.tsx:32
#: src/pages/Dump.tsx:376 #: src/pages/Dump.tsx:404
#: src/pages/DumpEdit.tsx:460 #: src/pages/DumpEdit.tsx:469
#: src/pages/PlaylistDetail.tsx:920 #: src/pages/PlaylistDetail.tsx:920
#: src/pages/UserPublicProfile.tsx:1674 #: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1744 #: src/pages/UserPublicProfile.tsx:1744
@@ -214,8 +227,8 @@ msgstr "Annuler"
msgid "Cancel removal" msgid "Cancel removal"
msgstr "Annuler la suppression" msgstr "Annuler la suppression"
#: src/components/ChatModal.tsx:619 #: src/components/ChatModal.tsx:622
#: src/components/ChatModal.tsx:620 #: src/components/ChatModal.tsx:623
msgid "Cancel reply" msgid "Cancel reply"
msgstr "Annuler la réponse" msgstr "Annuler la réponse"
@@ -248,7 +261,7 @@ msgstr "Changer le mot de passe…"
#: src/components/ChatButton.tsx:17 #: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49 #: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50 #: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:533 #: src/components/ChatModal.tsx:536
msgid "Chat" msgid "Chat"
msgstr "Tribune" msgstr "Tribune"
@@ -262,10 +275,26 @@ msgid "Checking invite…"
msgstr "Vérification de l'invitation…" msgstr "Vérification de l'invitation…"
#: src/components/ChangePasswordModal.tsx:56 #: src/components/ChangePasswordModal.tsx:56
#: src/components/Modal.tsx:45 #: src/components/Modal.tsx:58
msgid "Close" msgid "Close"
msgstr "Fermer" msgstr "Fermer"
#: src/components/DumpCreateModal.tsx:690
msgid "Close anyway"
msgstr "Fermer quand même"
#: src/components/GlobalPlayer.tsx:184
msgid "Close player"
msgstr "Fermer le lecteur"
#: src/components/DumpCreateModal.tsx:680
msgid "Close without posting? The attached file won't be kept — everything you typed will."
msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez écrit sera conservé."
#: src/components/GlobalPlayer.tsx:176
msgid "Collapse player"
msgstr "Réduire le lecteur"
#: src/pages/UserPublicProfile.tsx:1190 #: src/pages/UserPublicProfile.tsx:1190
msgid "Color scheme" msgid "Color scheme"
msgstr "Thème de couleur" msgstr "Thème de couleur"
@@ -291,10 +320,14 @@ msgstr "Impossible de changer le mot de passe"
msgid "Could not load." msgid "Could not load."
msgstr "Impossible de charger." msgstr "Impossible de charger."
#: src/pages/DumpEdit.tsx:361 #: src/pages/DumpEdit.tsx:370
msgid "Could not save" msgid "Could not save"
msgstr "Sauvegarde impossible" msgstr "Sauvegarde impossible"
#: src/components/DumpCreateModal.tsx:802
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
#: src/components/PlaylistCreateForm.tsx:87 #: src/components/PlaylistCreateForm.tsx:87
msgid "Create" msgid "Create"
msgstr "Créer" msgstr "Créer"
@@ -346,8 +379,8 @@ msgstr "Supprimer la catégorie"
msgid "Delete category \"{0}\"? This cannot be undone." msgid "Delete category \"{0}\"? This cannot be undone."
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible." msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
#: src/pages/DumpEdit.tsx:255 #: src/pages/DumpEdit.tsx:264
#: src/pages/DumpEdit.tsx:456 #: src/pages/DumpEdit.tsx:465
msgid "Delete dump" msgid "Delete dump"
msgstr "Supprimer la reco" msgstr "Supprimer la reco"
@@ -361,7 +394,7 @@ msgstr "Supprimer la collection"
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?" msgstr "Supprimer ce commentaire ?"
#: src/pages/DumpEdit.tsx:254 #: src/pages/DumpEdit.tsx:263
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible." msgstr "Supprimer cette reco ? Cette action est irréversible."
@@ -383,7 +416,7 @@ msgstr "message supprimé"
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (facultatif)" msgstr "Description (facultatif)"
#: src/components/DumpCreateModal.tsx:488 #: src/components/DumpCreateModal.tsx:741
msgid "Done" msgid "Done"
msgstr "Terminé" msgstr "Terminé"
@@ -391,7 +424,11 @@ msgstr "Terminé"
msgid "Drop a file here" msgid "Drop a file here"
msgstr "Déposez un fichier ici" msgstr "Déposez un fichier ici"
#: src/pages/DumpEdit.tsx:428 #: src/components/DumpCreateModal.tsx:847
msgid "Drop a file here, or paste one"
msgstr "Déposez un fichier ici, ou collez-en un"
#: src/pages/DumpEdit.tsx:437
msgid "Drop a replacement here" msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici" msgstr "Déposez un fichier de remplacement ici"
@@ -399,11 +436,11 @@ msgstr "Déposez un fichier de remplacement ici"
msgid "Dump" msgid "Dump"
msgstr "Reco" msgstr "Reco"
#: src/components/DumpCreateModal.tsx:458 #: src/components/DumpCreateModal.tsx:958
msgid "Dump it" msgid "Dump it"
msgstr "Recommander" msgstr "Recommander"
#: src/components/DumpCreateModal.tsx:469 #: src/components/DumpCreateModal.tsx:718
msgid "Dumped!" msgid "Dumped!"
msgstr "Recommandé !" msgstr "Recommandé !"
@@ -426,7 +463,7 @@ msgstr "Plus tôt"
#: src/components/ChatModal.tsx:172 #: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173 #: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367 #: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:490 #: src/pages/Dump.tsx:518
#: src/pages/PlaylistDetail.tsx:625 #: src/pages/PlaylistDetail.tsx:625
msgid "Edit" msgid "Edit"
msgstr "Modifier" msgstr "Modifier"
@@ -436,7 +473,7 @@ msgstr "Modifier"
msgid "Edit {0}" msgid "Edit {0}"
msgstr "Modifier {0}" msgstr "Modifier {0}"
#: src/components/DumpCreateModal.tsx:420 #: src/components/DumpCreateModal.tsx:197
msgid "Edit title" msgid "Edit title"
msgstr "Modifier le titre" msgstr "Modifier le titre"
@@ -445,7 +482,7 @@ msgstr "Modifier le titre"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:152 #: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317 #: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:429 #: src/pages/Dump.tsx:457
#: src/pages/PlaylistDetail.tsx:664 #: src/pages/PlaylistDetail.tsx:664
msgid "edited {0}" msgid "edited {0}"
msgstr "modifié {0}" msgstr "modifié {0}"
@@ -455,12 +492,12 @@ msgstr "modifié {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:150 #: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315 #: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:427 #: src/pages/Dump.tsx:455
#: src/pages/PlaylistDetail.tsx:661 #: src/pages/PlaylistDetail.tsx:661
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Modifié le {0}" msgstr "Modifié le {0}"
#: src/pages/DumpEdit.tsx:206 #: src/pages/DumpEdit.tsx:212
msgid "Editing" msgid "Editing"
msgstr "Modification" msgstr "Modification"
@@ -477,6 +514,10 @@ msgstr "Adresse e-mail"
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Saisissez une recherche." msgstr "Saisissez une recherche."
#: src/components/GlobalPlayer.tsx:176
msgid "Expand player"
msgstr "Agrandir le lecteur"
#: src/components/CategoryManager.tsx:230 #: src/components/CategoryManager.tsx:230
msgid "Failed to create category" msgid "Failed to create category"
msgstr "Échec de la création de la catégorie" msgstr "Échec de la création de la catégorie"
@@ -506,7 +547,7 @@ msgstr "Impossible de générer une invitation"
msgid "Failed to load" msgid "Failed to load"
msgstr "Chargement échoué" msgstr "Chargement échoué"
#: src/components/DumpCreateModal.tsx:337 #: src/components/DumpCreateModal.tsx:750
msgid "Failed to post" msgid "Failed to post"
msgstr "Publication échouée" msgstr "Publication échouée"
@@ -544,19 +585,11 @@ msgstr "Erreur lors de la mise à jour du rôle"
msgid "Feeds" msgid "Feeds"
msgstr "Flux" msgstr "Flux"
#: src/components/DumpCreateModal.tsx:369 #: src/components/DumpCreateModal.tsx:789
msgid "Fetching preview…" msgid "Fetching preview…"
msgstr "Récupération de l'aperçu…" msgstr "Récupération de l'aperçu…"
#: src/components/DumpCreateModal.tsx:455 #: src/components/DumpCreateModal.tsx:571
msgid "Fetching…"
msgstr "Récupération…"
#: src/components/DumpCreateModal.tsx:331
msgid "File"
msgstr "Fichier"
#: src/components/DumpCreateModal.tsx:239
msgid "File too large (max 50 MB)." msgid "File too large (max 50 MB)."
msgstr "Fichier trop volumineux (max 50 Mo)." msgstr "Fichier trop volumineux (max 50 Mo)."
@@ -632,6 +665,10 @@ msgstr "Tendances"
msgid "If that address is registered you'll receive a reset link shortly." msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu." msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
#: src/pages/Dump.tsx:552
msgid "In collections"
msgstr "Dans les collections"
#: src/pages/UserRegister.tsx:96 #: src/pages/UserRegister.tsx:96
msgid "Invalid invite" msgid "Invalid invite"
msgstr "Invitation invalide" msgstr "Invitation invalide"
@@ -654,6 +691,10 @@ msgstr "Invités"
msgid "Journal" msgid "Journal"
msgstr "Journal" msgstr "Journal"
#: src/components/DumpCreateModal.tsx:687
msgid "Keep editing"
msgstr "Continuer l'édition"
#: src/pages/UserPublicProfile.tsx:1205 #: src/pages/UserPublicProfile.tsx:1205
msgid "Light" msgid "Light"
msgstr "Clair" msgstr "Clair"
@@ -662,6 +703,14 @@ msgstr "Clair"
msgid "Like" msgid "Like"
msgstr "Aimer" msgstr "Aimer"
#: src/components/DumpCreateModal.tsx:764
msgid "Link"
msgstr "Lien"
#: src/components/DumpCreateModal.tsx:66
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/WSProvider.tsx:575 #: src/contexts/WSProvider.tsx:575
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -675,12 +724,12 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more" msgid "Load more"
msgstr "Charger plus" msgstr "Charger plus"
#: src/components/ChatModal.tsx:546 #: src/components/ChatModal.tsx:549
msgid "Load older messages" msgid "Load older messages"
msgstr "Charger les messages plus anciens" msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:240 #: src/pages/Dump.tsx:268
#: src/pages/DumpEdit.tsx:157 #: src/pages/DumpEdit.tsx:163
msgid "Loading dump…" msgid "Loading dump…"
msgstr "Chargement de la reco…" msgstr "Chargement de la reco…"
@@ -705,7 +754,7 @@ msgid "Loading profile…"
msgstr "Chargement du profil…" msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:52 #: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:545 #: src/components/ChatModal.tsx:548
#: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289 #: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192 #: src/components/UserListPopover.tsx:192
@@ -779,7 +828,7 @@ msgstr "nouveau"
msgid "New" msgid "New"
msgstr "Nouveau" msgstr "Nouveau"
#: src/components/DumpCreateModal.tsx:301 #: src/components/DumpCreateModal.tsx:659
#: src/components/DumpFab.tsx:65 #: src/components/DumpFab.tsx:65
#: src/components/DumpFab.tsx:66 #: src/components/DumpFab.tsx:66
#: src/pages/UserDumps.tsx:88 #: src/pages/UserDumps.tsx:88
@@ -797,6 +846,14 @@ msgstr "Nouveau mot de passe"
msgid "New playlist" msgid "New playlist"
msgstr "Nouvelle collection" msgstr "Nouvelle collection"
#: src/components/DumpCreateModal.tsx:957
msgid "Next →"
msgstr "Suivant →"
#: src/components/GlobalPlayer.tsx:166
msgid "Next track"
msgstr "Piste suivante"
#: src/pages/PlaylistDetail.tsx:680 #: src/pages/PlaylistDetail.tsx:680
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant." msgstr "Aucune reco dans cette collection pour l'instant."
@@ -824,7 +881,7 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet." msgid "No invitees yet."
msgstr "Aucun invité pour le moment." msgstr "Aucun invité pour le moment."
#: src/components/ChatModal.tsx:553 #: src/components/ChatModal.tsx:556
msgid "No messages yet. Say hello!" msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !" msgstr "Aucun message pour l'instant. Dites bonjour !"
@@ -903,11 +960,20 @@ msgstr "Mot de passe mis à jour"
msgid "Passwords do not match" msgid "Passwords do not match"
msgstr "Les mots de passe ne correspondent pas" msgstr "Les mots de passe ne correspondent pas"
#: src/components/DumpCreateModal.tsx:782
msgid "Paste a link…"
msgstr "Collez un lien…"
#: src/components/DumpCreateModal.tsx:699
msgid "Picked up where you left off."
msgstr "Reprise de votre brouillon."
#: src/pages/PlaylistDetail.tsx:863 #: src/pages/PlaylistDetail.tsx:863
msgid "Playlist title" msgid "Playlist title"
msgstr "Titre de la collection" msgstr "Titre de la collection"
#: src/components/AppHeader.tsx:86 #: src/components/AppHeader.tsx:86
#: src/components/DumpCreateModal.tsx:68
#: src/components/UserMenu.tsx:62 #: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177 #: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371 #: src/pages/UserPlaylists.tsx:371
@@ -921,10 +987,6 @@ msgstr "Collections"
msgid "Playlists ({0}{1})" msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})" msgstr "Collections ({0}{1})"
#: src/components/DumpCreateModal.tsx:236
msgid "Please select a file."
msgstr "Veuillez sélectionner un fichier."
#: src/components/CommentThread.tsx:472 #: src/components/CommentThread.tsx:472
msgid "Post comment" msgid "Post comment"
msgstr "Publier le commentaire" msgstr "Publier le commentaire"
@@ -935,19 +997,24 @@ msgstr "Publier la réponse"
#: src/components/CommentThread.tsx:396 #: src/components/CommentThread.tsx:396
#: src/components/CommentThread.tsx:473 #: src/components/CommentThread.tsx:473
#: src/components/DumpCreateModal.tsx:951
msgid "Posting…" msgid "Posting…"
msgstr "Publication…" msgstr "Publication…"
#: src/components/DumpCard.tsx:120 #: src/components/GlobalPlayer.tsx:154
#: src/components/JournalCard.tsx:121 msgid "Previous track"
msgstr "Piste précédente"
#: src/components/DumpCard.tsx:121
#: src/components/JournalCard.tsx:123
#: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistCard.tsx:73
#: src/components/PlaylistMembershipPanel.tsx:55 #: src/components/PlaylistMembershipPanel.tsx:55
#: src/pages/Dump.tsx:435 #: src/pages/Dump.tsx:463
#: src/pages/PlaylistDetail.tsx:644 #: src/pages/PlaylistDetail.tsx:644
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
#: src/components/form/SegmentedField.tsx:78 #: src/components/form/SegmentedField.tsx:81
#: src/pages/PlaylistDetail.tsx:902 #: src/pages/PlaylistDetail.tsx:902
msgid "Private" msgid "Private"
msgstr "Privé" msgstr "Privé"
@@ -957,16 +1024,20 @@ msgstr "Privé"
msgid "public" msgid "public"
msgstr "public" msgstr "public"
#: src/components/form/SegmentedField.tsx:77 #: src/components/form/SegmentedField.tsx:80
#: src/pages/PlaylistDetail.tsx:895 #: src/pages/PlaylistDetail.tsx:895
msgid "Public" msgid "Public"
msgstr "Public" msgstr "Public"
#: src/pages/DumpEdit.tsx:235 #: src/pages/DumpEdit.tsx:241
msgid "Refresh metadata" msgid "Refresh metadata"
msgstr "Actualiser les métadonnées" msgstr "Actualiser les métadonnées"
#: src/pages/DumpEdit.tsx:234 #: src/components/GlobalPlayer.tsx:240
msgid "Refreshing stream…"
msgstr "Actualisation du flux…"
#: src/pages/DumpEdit.tsx:240
msgid "Refreshing…" msgid "Refreshing…"
msgstr "Actualisation…" msgstr "Actualisation…"
@@ -984,7 +1055,7 @@ msgstr "Inscription…"
msgid "Registration failed" msgid "Registration failed"
msgstr "Inscription échouée" msgstr "Inscription échouée"
#: src/pages/Dump.tsx:502 #: src/pages/Dump.tsx:530
msgid "Related" msgid "Related"
msgstr "Connexe" msgstr "Connexe"
@@ -1004,7 +1075,7 @@ msgstr "Retirer le j'aime"
msgid "Remove vote" msgid "Remove vote"
msgstr "Retirer le vote" msgstr "Retirer le vote"
#: src/pages/DumpEdit.tsx:420 #: src/pages/DumpEdit.tsx:429
msgid "Replace file" msgid "Replace file"
msgstr "Remplacer le fichier" msgstr "Remplacer le fichier"
@@ -1014,7 +1085,7 @@ msgstr "Remplacer le fichier"
msgid "Reply" msgid "Reply"
msgstr "Répondre" msgstr "Répondre"
#: src/components/ChatModal.tsx:609 #: src/components/ChatModal.tsx:612
msgid "Replying to" msgid "Replying to"
msgstr "En réponse à" msgstr "En réponse à"
@@ -1030,13 +1101,18 @@ msgstr "Échec de la réinitialisation"
msgid "Reset password" msgid "Reset password"
msgstr "Réinitialiser le mot de passe" msgstr "Réinitialiser le mot de passe"
#: src/pages/DumpEdit.tsx:381 #: src/pages/DumpEdit.tsx:390
#: src/pages/DumpEdit.tsx:399 #: src/pages/DumpEdit.tsx:408
msgid "Reset to default" msgid "Reset to default"
msgstr "Réinitialiser par défaut" msgstr "Réinitialiser par défaut"
#: src/pages/Dump.tsx:257 #: src/components/DumpCreateModal.tsx:208
#: src/pages/DumpEdit.tsx:174 #: src/components/DumpCreateModal.tsx:209
msgid "Reset to the suggested title"
msgstr "Rétablir le titre suggéré"
#: src/pages/Dump.tsx:285
#: src/pages/DumpEdit.tsx:180
msgid "Retry" msgid "Retry"
msgstr "Réessayer" msgstr "Réessayer"
@@ -1046,8 +1122,8 @@ msgstr "Rôle"
#: src/components/ChatModal.tsx:222 #: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328 #: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:368 #: src/pages/Dump.tsx:396
#: src/pages/DumpEdit.tsx:463 #: src/pages/DumpEdit.tsx:472
#: src/pages/PlaylistDetail.tsx:927 #: src/pages/PlaylistDetail.tsx:927
#: src/pages/UserPublicProfile.tsx:1666 #: src/pages/UserPublicProfile.tsx:1666
#: src/pages/UserPublicProfile.tsx:1736 #: src/pages/UserPublicProfile.tsx:1736
@@ -1056,7 +1132,7 @@ msgstr "Enregistrer"
#: src/components/ChangePasswordModal.tsx:100 #: src/components/ChangePasswordModal.tsx:100
#: src/components/CommentThread.tsx:329 #: src/components/CommentThread.tsx:329
#: src/pages/Dump.tsx:367 #: src/pages/Dump.tsx:395
#: src/pages/PlaylistDetail.tsx:923 #: src/pages/PlaylistDetail.tsx:923
#: src/pages/ResetPassword.tsx:126 #: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1663 #: src/pages/UserPublicProfile.tsx:1663
@@ -1082,7 +1158,7 @@ msgstr "Recherche échouée"
msgid "Searching…" msgid "Searching…"
msgstr "Recherche…" msgstr "Recherche…"
#: src/components/ChatModal.tsx:655 #: src/components/ChatModal.tsx:658
msgid "Send" msgid "Send"
msgstr "Envoyer" msgstr "Envoyer"
@@ -1107,7 +1183,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "Settings" msgid "Settings"
msgstr "Paramètres" msgstr "Paramètres"
#: src/components/ChatModal.tsx:529 #: src/components/ChatModal.tsx:532
msgid "Several people are typing…" msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…" msgstr "Plusieurs personnes écrivent…"
@@ -1120,6 +1196,10 @@ msgstr "identifiant"
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Une erreur est survenue" msgstr "Une erreur est survenue"
#: src/components/DumpCreateModal.tsx:706
msgid "Start fresh"
msgstr "Repartir de zéro"
#: src/pages/UserPublicProfile.tsx:1155 #: src/pages/UserPublicProfile.tsx:1155
msgid "Style" msgid "Style"
msgstr "Style" msgstr "Style"
@@ -1148,17 +1228,17 @@ msgstr "Rien à voir, circulez."
msgid "This reset link is missing or malformed." msgid "This reset link is missing or malformed."
msgstr "Ce lien de réinitialisation est absent ou malformé." msgstr "Ce lien de réinitialisation est absent ou malformé."
#: src/pages/DumpEdit.tsx:365 #: src/pages/DumpEdit.tsx:374
msgid "Thumbnail" msgid "Thumbnail"
msgstr "Miniature" msgstr "Miniature"
#: src/components/DumpCreateModal.tsx:389 #: src/components/DumpCreateModal.tsx:174
#: src/components/PlaylistCreateForm.tsx:70 #: src/components/PlaylistCreateForm.tsx:70
#: src/pages/DumpEdit.tsx:389 #: src/pages/DumpEdit.tsx:398
msgid "Title" msgid "Title"
msgstr "Titre" msgstr "Titre"
#: src/components/DumpCreateModal.tsx:237 #: src/components/DumpCreateModal.tsx:569
msgid "Title is required." msgid "Title is required."
msgstr "Un titre est requis." msgstr "Un titre est requis."
@@ -1167,7 +1247,7 @@ msgstr "Un titre est requis."
msgid "Today" msgid "Today"
msgstr "Aujourd'hui" msgstr "Aujourd'hui"
#: src/components/ChatModal.tsx:632 #: src/components/ChatModal.tsx:635
msgid "Type a message…" msgid "Type a message…"
msgstr "Écrivez un message…" msgstr "Écrivez un message…"
@@ -1187,7 +1267,12 @@ msgstr "Ne plus suivre la collection"
msgid "Upload failed" msgid "Upload failed"
msgstr "Envoi échoué" msgstr "Envoi échoué"
#: src/components/DumpCreateModal.tsx:456 #. placeholder {0}: Math.round((uploadProgress ?? 0) * 100)
#: src/components/DumpCreateModal.tsx:642
msgid "Uploading {0}%"
msgstr "Envoi {0} %"
#: src/components/DumpCreateModal.tsx:954
msgid "Uploading…" msgid "Uploading…"
msgstr "Envoi…" msgstr "Envoi…"
@@ -1205,12 +1290,11 @@ msgstr "Voté"
msgid "Upvoted ({0}{1})" msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})" msgstr "Votés ({0}{1})"
#: src/components/DumpCreateModal.tsx:344 #: src/pages/DumpEdit.tsx:421
#: src/pages/DumpEdit.tsx:412
msgid "URL" msgid "URL"
msgstr "URL" msgstr "URL"
#: src/components/DumpCreateModal.tsx:223 #: src/components/DumpCreateModal.tsx:553
msgid "URL is required." msgid "URL is required."
msgstr "L'URL est obligatoire." msgstr "L'URL est obligatoire."
@@ -1247,12 +1331,12 @@ msgstr "Utilisateurs"
msgid "View all →" msgid "View all →"
msgstr "Tout voir →" msgstr "Tout voir →"
#: src/components/DumpCreateModal.tsx:471 #: src/components/DumpCreateModal.tsx:720
msgid "View dump →" msgid "View dump →"
msgstr "Voir la reco →" msgstr "Voir la reco →"
#: src/components/DumpCreateModal.tsx:434 #: src/components/DumpCreateModal.tsx:904
#: src/pages/DumpEdit.tsx:437 #: src/pages/DumpEdit.tsx:446
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?" msgstr "Pourquoi on en voudrait ?"
@@ -1261,8 +1345,16 @@ msgstr "Pourquoi on en voudrait ?"
msgid "Who am I?" msgid "Who am I?"
msgstr "Qui suis-je ?" msgstr "Qui suis-je ?"
#: src/components/DumpCreateModal.tsx:433 #: src/components/DumpCreateModal.tsx:920
#: src/pages/DumpEdit.tsx:436 msgid "Who can see it"
msgstr "Qui peut la voir"
#: src/components/DumpCreateModal.tsx:67
msgid "Why & where"
msgstr "Pourquoi & où"
#: src/components/DumpCreateModal.tsx:903
#: src/pages/DumpEdit.tsx:445
msgid "Why?" msgid "Why?"
msgstr "Pourquoi ?" msgstr "Pourquoi ?"
@@ -1275,6 +1367,10 @@ msgstr "Écrire une réponse…"
msgid "Yesterday" msgid "Yesterday"
msgstr "Hier" msgstr "Hier"
#: src/components/DumpCreateModal.tsx:254
msgid "You can post it anyway."
msgstr "Vous pouvez la publier quand même."
#: src/pages/Notifications.tsx:384 #: src/pages/Notifications.tsx:384
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."

View File

@@ -41,7 +41,12 @@ export interface RichContent {
siteName?: string; siteName?: string;
title?: string; title?: string;
description?: string; description?: string;
/** A real preview image — never a favicon. Absent means "no artwork". */
thumbnailUrl?: string; thumbnailUrl?: string;
/** The page's own icon, used as the placeholder's glyph. */
faviconUrl?: string;
/** The page's declared brand color, normalized to `#rrggbb`. */
accentColor?: string;
videoId?: string; videoId?: string;
embedUrl?: string; embedUrl?: string;
} }
@@ -69,6 +74,32 @@ export interface Dump {
export type RawDump = WithStringDate<Dump>; export type RawDump = WithStringDate<Dump>;
/**
* `GET /api/preview`. `reached` is false when the page could not be fetched, in
* which case `richContent` is a hostname-only stub rather than real metadata.
*/
export interface UrlPreviewResponse {
reached: boolean;
richContent: RichContent | null;
}
/** An existing dump on the same URL, as returned by `GET /api/dumps/by-url`. */
export interface DumpUrlMatch {
id: string;
slug?: string;
title: string;
username: string;
createdAt: Date;
voteCount: number;
commentCount: number;
}
export type RawDumpUrlMatch = WithStringDate<DumpUrlMatch>;
export function deserializeDumpUrlMatch(raw: RawDumpUrlMatch): DumpUrlMatch {
return { ...raw, createdAt: new Date(raw.createdAt) };
}
export function deserializeDump(raw: RawDump): Dump { export function deserializeDump(raw: RawDump): Dump {
return { return {
...raw, ...raw,
@@ -666,6 +697,8 @@ export interface RegisterRequest {
export interface CreateUrlDumpRequest { export interface CreateUrlDumpRequest {
url: string; url: string;
/** Overrides the title scraped from the page — the poster edited the preview. */
title?: string;
comment?: string; comment?: string;
isPrivate?: boolean; isPrivate?: boolean;
categoryIds?: string[]; categoryIds?: string[];

View File

@@ -11,14 +11,17 @@ import { API_URL, VALIDATION } from "../config/api.ts";
import type { import type {
Comment, Comment,
Dump, Dump,
Playlist,
PublicUser, PublicUser,
RawComment, RawComment,
RawDump, RawDump,
RawPlaylist,
UpdateDumpRequest, UpdateDumpRequest,
} from "../model.ts"; } from "../model.ts";
import { import {
deserializeComment, deserializeComment,
deserializeDump, deserializeDump,
deserializePlaylist,
deserializePublicUser, deserializePublicUser,
parseAPIResponse, parseAPIResponse,
} from "../model.ts"; } from "../model.ts";
@@ -33,6 +36,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx"; import FilePreview from "../components/FilePreview.tsx";
import { VoteButton } from "../components/VoteButton.tsx"; import { VoteButton } from "../components/VoteButton.tsx";
import { DumpCard } from "../components/DumpCard.tsx"; import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
import { Markdown } from "../components/Markdown.tsx"; import { Markdown } from "../components/Markdown.tsx";
@@ -67,6 +71,7 @@ export function Dump() {
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]); const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
const [collections, setCollections] = useState<Playlist[]>([]);
const [titleEditing, setTitleEditing] = useState(false); const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState(""); const [titleDraft, setTitleDraft] = useState("");
@@ -82,6 +87,7 @@ export function Dump() {
removeVote, removeVote,
lastDumpEvent, lastDumpEvent,
lastCommentEvent, lastCommentEvent,
lastPlaylistEvent,
connectionEpoch, connectionEpoch,
} = useWS(); } = useWS();
@@ -180,6 +186,28 @@ export function Dump() {
return () => controller.abort(); return () => controller.abort();
}, [selectedDump, token]); }, [selectedDump, token]);
// Fetch the collections this dump appears in (public ones, plus the
// viewer's own private ones — the server does the filtering)
useEffect(() => {
if (!selectedDump) return;
const controller = new AbortController();
fetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
if (body.success) {
setCollections((body.data as RawPlaylist[]).map(deserializePlaylist));
}
})
.catch(() => {});
return () => controller.abort();
// lastPlaylistEvent: adding/removing this dump, flipping a collection's
// visibility or deleting it all change the list — playlist events are rare
// enough to just refetch on any of them.
}, [selectedDump, token, lastPlaylistEvent]);
// Scroll to and highlight a comment when navigating to #comment-{id} // Scroll to and highlight a comment when navigating to #comment-{id}
useEffect(() => { useEffect(() => {
if (!location.hash.startsWith("#comment-")) return; if (!location.hash.startsWith("#comment-")) return;
@@ -517,6 +545,20 @@ export function Dump() {
</section> </section>
)} )}
{/* In collections (playlists containing this dump) */}
{collections.length > 0 && (
<section className="related-section collections-section">
<h2 className="related-section-title">
<Trans>In collections</Trans>
</h2>
<ul className="dump-feed">
{collections.map((collection) => (
<PlaylistCard key={collection.id} playlist={collection} />
))}
</ul>
</section>
)}
{/* Comments */} {/* Comments */}
<CommentThread <CommentThread
dumpId={dump.id} dumpId={dump.id}

View File

@@ -53,6 +53,7 @@ export function DumpEdit() {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const [thumbUploading, setThumbUploading] = useState(false); const [thumbUploading, setThumbUploading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -85,15 +86,20 @@ export function DumpEdit() {
if (state.status !== "loaded" || state.dump.kind !== "url") return; if (state.status !== "loaded" || state.dump.kind !== "url") return;
setRefreshing(true); setRefreshing(true);
setRefreshError(null);
try { try {
const res = await authFetch( const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`, `${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
{ method: "POST" }, { method: "POST" },
); );
const apiResponse = await res.json(); const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) { if (apiResponse.success) {
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) }); setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
} else {
setRefreshError(apiResponse.error.message);
} }
} catch (err) {
setRefreshError(friendlyFetchError(err));
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
@@ -235,6 +241,9 @@ export function DumpEdit() {
: <Trans>Refresh metadata</Trans>} : <Trans>Refresh metadata</Trans>}
</button> </button>
)} )}
{refreshError && (
<p className="dump-edit-refresh-error">{refreshError}</p>
)}
</div> </div>
<DumpEditForm <DumpEditForm

View File

@@ -424,6 +424,13 @@
border-radius: 0; border-radius: 0;
} }
/* Queue UI: square off the artwork and the stream progress bar. */
[data-style="brutalist"] .global-player-artwork,
[data-style="brutalist"] .audio-player-track--stream::before,
[data-style="brutalist"] .audio-player-track--stream .audio-player-fill {
border-radius: 0;
}
[data-style="brutalist"] .global-player .audio-player-btn { [data-style="brutalist"] .global-player .audio-player-btn {
background: transparent; background: transparent;
color: var(--color-text); color: var(--color-text);

View File

@@ -315,6 +315,20 @@
[data-style="geocities"] .playlist-card-delete-btn, [data-style="geocities"] .playlist-card-delete-btn,
[data-style="geocities"] .emoji-picker-float [frimousse-emoji], [data-style="geocities"] .emoji-picker-float [frimousse-emoji],
[data-style="geocities"] .emoji-picker-close-btn, [data-style="geocities"] .emoji-picker-close-btn,
/* Turn the tint up and give it hard diagonal bands — a flat wash reads as an
unstyled gap in a theme built on saturation. */
[data-style="geocities"] {
--thumb-tint-strength: 55%;
}
[data-style="geocities"] .thumb-placeholder {
background-image: repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.14) 0 8px,
rgba(0, 0, 0, 0.14) 8px 16px
);
}
[data-style="geocities"] .btn--ghost, [data-style="geocities"] .btn--ghost,
[data-style="geocities"] .new-item-toggle, [data-style="geocities"] .new-item-toggle,
[data-style="geocities"] .audio-player-btn, [data-style="geocities"] .audio-player-btn,
@@ -482,6 +496,13 @@
border-radius: 0; border-radius: 0;
} }
/* Queue UI: square off the artwork and the stream progress bar. */
[data-style="geocities"] .global-player-artwork,
[data-style="geocities"] .audio-player-track--stream::before,
[data-style="geocities"] .audio-player-track--stream .audio-player-fill {
border-radius: 0;
}
[data-style="geocities"] .global-player .audio-player-btn { [data-style="geocities"] .global-player .audio-player-btn {
color: var(--color-on-accent); color: var(--color-on-accent);
} }

View File

@@ -169,6 +169,9 @@
[data-style="nyt"] .global-player, [data-style="nyt"] .global-player,
[data-style="nyt"] .global-player-media-wrap, [data-style="nyt"] .global-player-media-wrap,
[data-style="nyt"] .global-player-iframe-wrap, [data-style="nyt"] .global-player-iframe-wrap,
[data-style="nyt"] .global-player-artwork,
[data-style="nyt"] .audio-player-track--stream::before,
[data-style="nyt"] .audio-player-track--stream .audio-player-fill,
[data-style="nyt"] .fdz, [data-style="nyt"] .fdz,
[data-style="nyt"] .visibility-toggle, [data-style="nyt"] .visibility-toggle,
[data-style="nyt"] .feed-tab, [data-style="nyt"] .feed-tab,
@@ -587,10 +590,17 @@
[data-style="nyt"] .rich-content-compact-icon, [data-style="nyt"] .rich-content-compact-icon,
[data-style="nyt"] .journal-card-glyph, [data-style="nyt"] .journal-card-glyph,
[data-style="nyt"] .fdz__file-icon, [data-style="nyt"] .fdz__file-icon,
[data-style="nyt"] .thumb-placeholder-icon,
[data-style="nyt"] .empty-state { [data-style="nyt"] .empty-state {
filter: grayscale(1); filter: grayscale(1);
} }
/* Monochrome by design — a dumped site's brand color doesn't get to tint the
page, so the placeholder is the theme's own surface with a gray glyph. */
[data-style="nyt"] {
--thumb-tint-strength: 0%;
}
/* ── Tighter, continuous ruled feed (no inter-item gap; the hairline /* ── Tighter, continuous ruled feed (no inter-item gap; the hairline
bottom rules read as one column). Even vertical padding per item. ── */ bottom rules read as one column). Even vertical padding per item. ── */
[data-style="nyt"] .dump-feed { [data-style="nyt"] .dump-feed {

78
src/utils/bandcamp.ts Normal file
View File

@@ -0,0 +1,78 @@
import { API_URL } from "../config/api.ts";
import type { PlayerItem } from "../contexts/PlayerContext.ts";
/** Mirrors the `Tralbum` shape returned by GET /api/bandcamp/tracks. */
interface BandcampTrack {
index: number;
trackNum?: number;
title: string;
duration?: number;
streamUrl: string | null;
streamable: boolean;
capped: boolean;
}
interface BandcampAlbum {
sourceUrl: string;
itemType: "album" | "track";
title?: string;
artist?: string;
artworkUrl?: string;
tracks: BandcampTrack[];
resolvedAt: number;
}
export interface BandcampContext {
dumpHref?: string;
/** Kept on every item so playback can fall back to the iframe later. */
embedUrl?: string;
/** Used only if the page carries no album art of its own. */
fallbackArtworkUrl?: string;
}
/**
* Resolve a Bandcamp page into a playable queue.
*
* Throws on any failure — callers treat that as "fall back to the embed".
* `force` skips the server's cache, for re-resolving after a signature expires.
*/
export async function resolveBandcampQueue(
pageUrl: string,
ctx: BandcampContext = {},
{ force = false }: { force?: boolean } = {},
): Promise<PlayerItem[]> {
const res = await fetch(
`${API_URL}/api/bandcamp/tracks?url=${encodeURIComponent(pageUrl)}${
force ? "&force=1" : ""
}`,
);
if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
const body = await res.json() as { success: boolean; data?: BandcampAlbum };
const album = body.data;
if (!body.success || !album) throw new Error("resolve returned no data");
const items = album.tracks
.filter((t): t is BandcampTrack & { streamUrl: string } =>
t.streamable && typeof t.streamUrl === "string"
)
.map((t): PlayerItem => ({
kind: "stream",
streamUrl: t.streamUrl,
type: "bandcamp",
// The row names the track; the artist rides along on the subtitle line.
title: t.title,
subtitle: album.artist,
duration: t.duration,
trackNum: t.trackNum,
artworkUrl: album.artworkUrl ?? ctx.fallbackArtworkUrl,
dumpHref: ctx.dumpHref,
resolveUrl: album.sourceUrl,
resolveIndex: t.index,
resolvedAt: album.resolvedAt,
embedUrl: ctx.embedUrl,
}));
if (items.length === 0) throw new Error("no streamable tracks");
return items;
}

85
src/utils/dumpDraft.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Persistence for the half-written dump in the create modal.
*
* The modal is dismissable by Escape, a backdrop click and the ✕, and it holds
* a URL, a title, a rich-text "why", categories and a visibility choice — so a
* stray keystroke used to destroy a few minutes of writing. Everything except
* the attached file (a `File` cannot survive a reload) is mirrored here on each
* change and restored the next time the modal opens; a successful post clears
* it.
*
* Deliberately a single draft rather than one per tab: someone who closed the
* modal by accident wants the thing they were just writing, wherever they
* reopen it.
*/
const STORAGE_KEY = "gerbeur.dumpDraft";
export interface DumpDraft {
url: string;
title: string;
/** Whether `title` was typed by the poster, rather than taken from the preview. */
titleFromUser: boolean;
comment: string;
isPublic: boolean;
categoryIds: string[];
}
export const EMPTY_DUMP_DRAFT: DumpDraft = {
url: "",
title: "",
titleFromUser: false,
comment: "",
isPublic: true,
categoryIds: [],
};
/** Whether a draft holds anything worth restoring or warning about. */
export function isDumpDraftEmpty(draft: DumpDraft): boolean {
return !draft.url.trim() && !draft.comment.trim() &&
!(draft.titleFromUser && draft.title.trim()) &&
draft.categoryIds.length === 0 && draft.isPublic;
}
export function loadDumpDraft(): DumpDraft | null {
let raw: string | null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
return null; // Storage disabled (private mode, blocked cookies) — no drafts.
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<DumpDraft>;
const draft: DumpDraft = {
url: typeof parsed.url === "string" ? parsed.url : "",
title: typeof parsed.title === "string" ? parsed.title : "",
titleFromUser: parsed.titleFromUser === true,
comment: typeof parsed.comment === "string" ? parsed.comment : "",
isPublic: parsed.isPublic !== false,
categoryIds: Array.isArray(parsed.categoryIds)
? parsed.categoryIds.filter((id): id is string => typeof id === "string")
: [],
};
return isDumpDraftEmpty(draft) ? null : draft;
} catch {
return null;
}
}
export function saveDumpDraft(draft: DumpDraft): void {
try {
if (isDumpDraftEmpty(draft)) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
} catch {
// Storage full or unavailable — the draft is a convenience, never a
// precondition for posting, so a failure here is silent by design.
}
}
export function clearDumpDraft(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch { /* see saveDumpDraft */ }
}

7
src/utils/duration.ts Normal file
View File

@@ -0,0 +1,7 @@
/** Format seconds as m:ss. Shared by the media player and the player queue. */
export function fmt(s: number): string {
if (!isFinite(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
}

View File

@@ -3,3 +3,14 @@ export function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
/**
* A file's name as a readable dump title: the extension goes (it is noise in a
* feed, and the dump already carries its MIME type), and separator runs become
* spaces, so `holiday_photo-01.final.jpg` reads as `holiday photo-01.final`.
* Names that are *only* an extension (`.gitignore`) are left alone.
*/
export function titleFromFileName(name: string): string {
const withoutExt = name.replace(/(?!^)\.[^.]+$/, "");
return withoutExt.replace(/_+/g, " ").trim() || name;
}

View File

@@ -17,7 +17,12 @@ export interface JournalEntry {
/** A dump that can carry a real preview image (file image or rich thumbnail). */ /** A dump that can carry a real preview image (file image or rich thumbnail). */
export function hasThumbnail(dump: Dump): boolean { export function hasThumbnail(dump: Dump): boolean {
if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) return true; if (dump.kind === "file") {
const mime = dump.fileMime ?? "";
// Videos count: GET /api/thumbnails/:dumpId grabs a still with ffmpeg on
// first request and caches it, so there's art to show without any upload.
if (mime.startsWith("image/") || mime.startsWith("video/")) return true;
}
if (dump.thumbnailMime) return true; if (dump.thumbnailMime) return true;
return !!dump.richContent?.thumbnailUrl; return !!dump.richContent?.thumbnailUrl;
} }

57
src/utils/proxyImage.ts Normal file
View File

@@ -0,0 +1,57 @@
import { API_URL } from "../config/api.ts";
/** Our own API origin — `API_URL` is relative ("") when we're same-origin. */
function apiOrigin(): string {
try {
return new URL(API_URL, location.href).origin;
} catch {
return location.origin;
}
}
/**
* Whether `url` points at a third party, i.e. whether proxying it would
* actually change anything. Our own files and thumbnails are served with no
* `Referer` checks, so retrying them through the proxy is pure waste.
*/
export function canProxyImage(url: string): boolean {
try {
const u = new URL(url, location.href);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
return u.origin !== apiOrigin() && u.origin !== location.origin;
} catch {
return false;
}
}
interface ProxyImageOptions {
/**
* Proxy even when the URL is already HTTPS. Used as a retry after a direct
* load fails — typically hotlink protection rejecting our cross-site
* `Referer`, which the server-side fetch doesn't send.
*/
force?: boolean;
}
/**
* Route an external image through `GET /api/proxy-image`.
*
* By default only HTTP URLs are proxied, so they don't trigger mixed-content
* blocks when the frontend is served over HTTPS. `force` opts an HTTPS URL in
* as well.
*/
export function proxyImageUrl(url: string, opts: ProxyImageOptions = {}): string {
let u: URL;
try {
u = new URL(url);
} catch {
return url; // relative or same-origin path — nothing to proxy
}
if (u.protocol !== "http:" && u.protocol !== "https:") return url;
const isLocal = u.hostname === "localhost" || u.hostname === "127.0.0.1";
if (isLocal) return url;
if (!opts.force && u.protocol !== "http:") return url;
return `${API_URL}/api/proxy-image?url=${encodeURIComponent(url)}`;
}

View File

@@ -0,0 +1,34 @@
import type { PlayerItem } from "../contexts/PlayerContext.ts";
import { resolveBandcampQueue } from "./bandcamp.ts";
/**
* Re-resolve the queue a stream item belongs to, for when its signed URL
* expires. Keeping the dispatch here is what lets PlayerProvider handle
* expiry without knowing that Bandcamp exists.
*/
export function resolveStreamQueue(
item: Extract<PlayerItem, { kind: "stream" }>,
): Promise<PlayerItem[]> {
if (item.type === "bandcamp") {
return resolveBandcampQueue(
item.resolveUrl,
{
dumpHref: item.dumpHref,
embedUrl: item.embedUrl,
// Carry the current artwork so a re-resolve can't blank the header.
fallbackArtworkUrl: item.artworkUrl,
},
{ force: true },
);
}
return Promise.reject(new Error(`no resolver for stream type ${item.type}`));
}
/** Signed URLs live 24h; re-resolve before that to avoid a stall mid-play. */
export const STREAM_STALE_MS = 20 * 60 * 60 * 1000;
export function isStreamStale(
item: Extract<PlayerItem, { kind: "stream" }>,
): boolean {
return Date.now() - item.resolvedAt > STREAM_STALE_MS;
}

View File

@@ -0,0 +1,68 @@
/**
* Color and lettering for generated thumbnail placeholders.
*
* The tint is never painted on its own — it's mixed into the theme's surface
* color by CSS (`--thumb-tint`), so contrast against `--color-text` holds for
* any site color, in every style, in both light and dark.
*/
/**
* `accentColor` comes from a dumped page's `theme-color`, so it's
* attacker-controlled. Custom properties bypass CSS value parsing and get
* substituted verbatim wherever the var is used, so the canonical form is
* re-checked here before it ever reaches a style object.
*/
export function isSafeHex(value: string | undefined): value is string {
return !!value && /^#[0-9a-f]{6}$/.test(value);
}
/** FNV-1a — small, stable, and well spread across the hue circle. */
function hashString(seed: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) {
hash ^= seed.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
/** A stable hue for a string, so the same site always gets the same tint. */
export function hueFromString(seed: string): string {
return `hsl(${hashString(seed) % 360} 55% 55%)`;
}
function hostnameOf(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return undefined;
}
}
/**
* The page's own brand color when it declared a usable one, otherwise a hue
* derived from its hostname — so every card gets a tint, including rows saved
* before accent extraction existed. File dumps have no hostname to hash and
* pass `seed` (their filename) instead.
*/
export function tintFor(
{ accentColor, url, seed }: {
accentColor?: string;
url?: string;
seed?: string;
},
): string {
if (isSafeHex(accentColor)) return accentColor;
return hueFromString(seed ?? hostnameOf(url) ?? url ?? "");
}
/** The letter drawn when there's no favicon to show. */
export function initialsFor(
siteName: string | undefined,
url: string | undefined,
): string {
const source = siteName?.trim() || hostnameOf(url) || url || "";
const letter = [...source].find((ch) => /\p{L}|\p{N}/u.test(ch));
return (letter ?? "?").toUpperCase();
}

View File

@@ -4,6 +4,7 @@ import { lingui } from "@lingui/vite-plugin";
const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur"; const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur";
const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚"; const SITE_EMOJI = process.env.GERBEUR_SITE_EMOJI || "🚚";
const BANDCAMP_PLAYER = process.env.GERBEUR_BANDCAMP_PLAYER?.trim() === "native" ? "native" : "embed";
// Cache-busting token for the favicon URLs (see index.html). Changes when the // Cache-busting token for the favicon URLs (see index.html). Changes when the
// emoji changes, so browsers don't keep serving a stale cached icon. Mirrors // emoji changes, so browsers don't keep serving a stale cached icon. Mirrors
@@ -49,6 +50,7 @@ export default defineConfig({
.replaceAll("__SITE_NAME__", SITE_NAME) .replaceAll("__SITE_NAME__", SITE_NAME)
.replaceAll("__SITE_EMOJI__", SITE_EMOJI) .replaceAll("__SITE_EMOJI__", SITE_EMOJI)
.replaceAll("__ICON_VERSION__", ICON_VERSION) .replaceAll("__ICON_VERSION__", ICON_VERSION)
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
: html, : html,
}, },
}, },