Compare commits
8 Commits
0e138be6df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8364e24d | ||
|
|
d76154d15d | ||
|
|
79b7adce8f | ||
|
|
2606ef4ccc | ||
|
|
eb323a8ba8 | ||
|
|
89314b1b6e | ||
|
|
7303509b6f | ||
|
|
1cb904d2cf |
@@ -14,6 +14,15 @@ GERBEUR_SITE_NAME=gerbeur
|
||||
# (server startup), so changing it only needs a restart — no rebuild.
|
||||
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).
|
||||
GERBEUR_PORT=8000
|
||||
|
||||
|
||||
@@ -98,6 +98,17 @@ export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
|
||||
// only needs a restart — no rebuild.
|
||||
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
|
||||
// hard-coded theme-color in index.html.
|
||||
export const THEME_COLOR = "#111827";
|
||||
|
||||
@@ -9,6 +9,9 @@ import { up as up0007PasswordResetTokens } from "./migrations/0007_password_rese
|
||||
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.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 {
|
||||
name: string;
|
||||
@@ -29,6 +32,12 @@ const MIGRATIONS: Migration[] = [
|
||||
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
||||
{ 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 {
|
||||
|
||||
74
api/db/migrations/0011_split_favicon_thumbnail.ts
Normal file
74
api/db/migrations/0011_split_favicon_thumbnail.ts
Normal 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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
100
api/db/migrations/0012_fix_favicon_reclassification.ts
Normal file
100
api/db/migrations/0012_fix_favicon_reclassification.ts
Normal 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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
119
api/db/migrations/0013_dump_url_canonical.ts
Normal file
119
api/db/migrations/0013_dump_url_canonical.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ CREATE TABLE dumps (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
url TEXT,
|
||||
-- Lossy lookup key for "has this been dumped already?" — see api/lib/canonical-url.ts
|
||||
url_canonical TEXT,
|
||||
slug TEXT,
|
||||
rich_content 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_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_playlists_user ON playlists(user_id);
|
||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
||||
|
||||
104
api/lib/canonical-url.ts
Normal file
104
api/lib/canonical-url.ts
Normal 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}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
@@ -13,7 +13,8 @@ async function serveIndexHtml(
|
||||
const html = raw
|
||||
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
|
||||
.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.body = html;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import usersRouter from "./routes/users.ts";
|
||||
import avatarsRouter from "./routes/avatars.ts";
|
||||
import wsRouter from "./routes/ws.ts";
|
||||
import previewRouter from "./routes/preview.ts";
|
||||
import bandcampRouter from "./routes/bandcamp.ts";
|
||||
import playlistsRouter from "./routes/playlists.ts";
|
||||
import commentsRouter from "./routes/comments.ts";
|
||||
import chatRouter from "./routes/chat.ts";
|
||||
@@ -71,6 +72,10 @@ app.use(
|
||||
previewRouter.routes(),
|
||||
previewRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
bandcampRouter.routes(),
|
||||
bandcampRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
playlistsRouter.routes(),
|
||||
playlistsRouter.allowedMethods(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Context, Next } from "@oak/oak";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
import { getUserByUsername } from "../services/user-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";
|
||||
|
||||
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
@@ -11,6 +11,8 @@ interface OGMeta {
|
||||
title: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
/** True only for real artwork — a square site icon must not claim a wide card. */
|
||||
imageIsWide?: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@@ -23,7 +25,9 @@ function escapeAttr(s: string): 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 = [
|
||||
`<title>${escapeAttr(meta.title)}</title>`,
|
||||
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
|
||||
@@ -65,7 +69,8 @@ async function loadIndexHtml(): Promise<string | null> {
|
||||
cachedHtml = (await Deno.readTextFile(path))
|
||||
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
|
||||
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION);
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
|
||||
return cachedHtml;
|
||||
} catch {
|
||||
continue;
|
||||
@@ -105,10 +110,14 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
||||
} else if (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 = {
|
||||
title: dump.title,
|
||||
description: dump.comment,
|
||||
imageUrl,
|
||||
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||
imageIsWide: !!imageUrl,
|
||||
url: pageUrl,
|
||||
};
|
||||
} catch { /* not found or private — serve default */ }
|
||||
@@ -121,7 +130,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
||||
meta = {
|
||||
title: user.username,
|
||||
description: user.description,
|
||||
imageUrl,
|
||||
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||
url: pageUrl,
|
||||
};
|
||||
} catch { /* not found */ }
|
||||
@@ -135,7 +144,7 @@ export async function ogMiddleware(ctx: Context, next: Next) {
|
||||
meta = {
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
imageUrl,
|
||||
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
|
||||
url: pageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,16 @@ export interface RichContent {
|
||||
siteName?: string;
|
||||
title?: 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;
|
||||
/** 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;
|
||||
embedUrl?: string;
|
||||
}
|
||||
@@ -470,11 +479,27 @@ function isStringArray(value: unknown): value is string[] {
|
||||
|
||||
export interface CreateUrlDumpRequest {
|
||||
url: string;
|
||||
/** Overrides the title scraped from the page — the poster edited the preview. */
|
||||
title?: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
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(
|
||||
obj: unknown,
|
||||
): obj is CreateUrlDumpRequest {
|
||||
@@ -490,6 +515,13 @@ export function isCreateUrlDumpRequest(
|
||||
typeof o.comment === "string" &&
|
||||
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
|
||||
) 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 ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
|
||||
return true;
|
||||
|
||||
26
api/routes/bandcamp.ts
Normal file
26
api/routes/bandcamp.ts
Normal 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;
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type Dump,
|
||||
type DumpUrlMatch,
|
||||
isCreateUrlDumpRequest,
|
||||
isUpdateDumpRequest,
|
||||
type PaginatedData,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
createFileDump,
|
||||
createUrlDump,
|
||||
deleteDump,
|
||||
findDumpsByUrl,
|
||||
getDump,
|
||||
listDumps,
|
||||
refreshDumpMetadata,
|
||||
@@ -100,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) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import {
|
||||
fetchRichContent,
|
||||
fetchWithTimeout,
|
||||
isValidHttpUrl,
|
||||
tryFetchRichContent,
|
||||
} from "../services/rich-content-service.ts";
|
||||
import { APIErrorCode } from "../model/interfaces.ts";
|
||||
|
||||
@@ -18,8 +18,15 @@ previewRouter.get("/api/preview", async (ctx) => {
|
||||
};
|
||||
return;
|
||||
}
|
||||
const data = await fetchRichContent(url);
|
||||
ctx.response.body = { success: true, data: data ?? null };
|
||||
// `reached` is reported separately because a failed fetch still yields a
|
||||
// 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 },
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
138
api/services/bandcamp-stream-service.ts
Normal file
138
api/services/bandcamp-stream-service.ts
Normal 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;
|
||||
}
|
||||
149
api/services/bandcamp-tralbum.ts
Normal file
149
api/services/bandcamp-tralbum.ts
Normal 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 `&` before `"`, so an escaped-ampersand-then-quot sequence
|
||||
* (`&quot;`, i.e. the literal text `"` 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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
APIException,
|
||||
type CreateUrlDumpRequest,
|
||||
type Dump,
|
||||
type DumpUrlMatch,
|
||||
type UpdateDumpRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
@@ -13,7 +14,11 @@ import {
|
||||
dumpRowToApi,
|
||||
isDumpRow,
|
||||
} from "../model/db.ts";
|
||||
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
|
||||
import {
|
||||
fetchRichContent,
|
||||
isValidHttpUrl,
|
||||
tryFetchRichContent,
|
||||
} from "./rich-content-service.ts";
|
||||
import {
|
||||
broadcastDumpDeleted,
|
||||
broadcastDumpUpdated,
|
||||
@@ -24,6 +29,7 @@ import {
|
||||
notifyUserFollowersNewDump,
|
||||
} from "./notification-service.ts";
|
||||
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import {
|
||||
DUMP_ALLOWED_MIME_PREFIXES,
|
||||
DUMP_ALLOWED_MIME_TYPES,
|
||||
@@ -59,13 +65,16 @@ export async function createUrlDump(
|
||||
const dumpId = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
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 slug = makeSlug(title, dumpId);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, rich_content, is_private)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, is_private)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
dumpId,
|
||||
"url",
|
||||
@@ -75,6 +84,7 @@ export async function createUrlDump(
|
||||
userId,
|
||||
createdAt.toISOString(),
|
||||
request.url,
|
||||
canonicalizeUrl(request.url),
|
||||
richContent ? JSON.stringify(richContent) : null,
|
||||
isPrivate ? 1 : 0,
|
||||
);
|
||||
@@ -290,6 +300,54 @@ export function listDumps(
|
||||
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(
|
||||
dumpId: string,
|
||||
request: UpdateDumpRequest,
|
||||
@@ -383,12 +441,13 @@ export async function updateDump(
|
||||
|
||||
const row = dumpApiToRow(updatedDump);
|
||||
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(
|
||||
row.title,
|
||||
row.slug,
|
||||
row.comment,
|
||||
row.url,
|
||||
row.url ? canonicalizeUrl(row.url) : null,
|
||||
row.rich_content,
|
||||
row.is_private,
|
||||
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 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 = ?;`,
|
||||
).run(row.title, row.rich_content, row.id);
|
||||
|
||||
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||
|
||||
return updatedDump;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
extractFirstContentImage,
|
||||
extractJsonLd,
|
||||
extractLargeImage,
|
||||
extractMaskIconColor,
|
||||
extractMetaName,
|
||||
extractOgTag,
|
||||
extractPageTitle,
|
||||
extractThemeColor,
|
||||
fetchWithTimeout,
|
||||
normalizeCssColor,
|
||||
} from "../rich-content-service.ts";
|
||||
|
||||
export const genericProvider: RichContentProvider = {
|
||||
@@ -59,21 +62,37 @@ export const genericProvider: RichContentProvider = {
|
||||
ld.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) ??
|
||||
extractMetaName(html, "twitter:image") ??
|
||||
ld.thumbnailUrl ??
|
||||
extractLargeImage(html, url) ??
|
||||
extractFirstContentImage(html, url) ??
|
||||
extractBestIcon(html, url) ??
|
||||
extractFirstContentImage(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`;
|
||||
|
||||
const accentColor = normalizeCssColor(
|
||||
extractThemeColor(html) ??
|
||||
extractMetaName(html, "msapplication-TileColor") ??
|
||||
extractMaskIconColor(html),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "generic",
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
thumbnailUrl,
|
||||
faviconUrl,
|
||||
accentColor,
|
||||
siteName,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -3,12 +3,20 @@ import type { RichContentProvider } from "../rich-content-service.ts";
|
||||
import { getDump } from "../dump-service.ts";
|
||||
import { getUserByUsername } from "../user-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 USER_RE = /^\/users\/([^/]+)$/;
|
||||
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 = {
|
||||
name: "self",
|
||||
|
||||
@@ -38,7 +46,7 @@ export const selfProvider: RichContentProvider = {
|
||||
return Promise.resolve({
|
||||
type: "generic",
|
||||
url,
|
||||
siteName: "gerbeur",
|
||||
...SELF_BRANDING,
|
||||
title: dump.title,
|
||||
description: dump.comment,
|
||||
thumbnailUrl,
|
||||
@@ -54,7 +62,7 @@ export const selfProvider: RichContentProvider = {
|
||||
return Promise.resolve({
|
||||
type: "generic",
|
||||
url,
|
||||
siteName: "gerbeur",
|
||||
...SELF_BRANDING,
|
||||
title: user.username,
|
||||
description: user.description,
|
||||
thumbnailUrl,
|
||||
@@ -70,7 +78,7 @@ export const selfProvider: RichContentProvider = {
|
||||
return Promise.resolve({
|
||||
type: "generic",
|
||||
url,
|
||||
siteName: "gerbeur",
|
||||
...SELF_BRANDING,
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
thumbnailUrl,
|
||||
|
||||
@@ -184,6 +184,117 @@ export function extractPageTitle(html: string): string | 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) ────────────────────────────────────────────
|
||||
|
||||
type JsonLdResult = {
|
||||
@@ -281,6 +392,10 @@ export function extractLargeImage(
|
||||
* Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank
|
||||
* them by declared size (largest wins), and return the best resolved URL.
|
||||
* 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(
|
||||
html: string,
|
||||
@@ -302,7 +417,13 @@ export function extractBestIcon(
|
||||
if (!href) continue;
|
||||
const sizesStr = sizesRe.exec(tag)?.[1] ?? "";
|
||||
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 {
|
||||
candidates.push({ href: new URL(href, baseUrl).toString(), area });
|
||||
} 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,
|
||||
): Promise<RichContent | undefined> {
|
||||
): Promise<FetchRichContentResult> {
|
||||
try {
|
||||
const provider = providers.find((p) => p.matches(url))!;
|
||||
return await provider.fetch(url);
|
||||
return { ok: true, content: await provider.fetch(url) };
|
||||
} catch (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 {
|
||||
return {
|
||||
ok: false,
|
||||
content: {
|
||||
type: "generic",
|
||||
url,
|
||||
siteName: new URL(url).hostname.replace(/^www\./, ""),
|
||||
},
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<meta name="site-name" content="__SITE_NAME__" />
|
||||
<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="apple-touch-icon" href="/apple-touch-icon.png?v=__ICON_VERSION__" />
|
||||
<title>__SITE_NAME__</title>
|
||||
|
||||
457
src/App.css
457
src/App.css
@@ -438,6 +438,222 @@
|
||||
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 ── */
|
||||
.visibility-toggle {
|
||||
display: flex;
|
||||
@@ -592,6 +808,41 @@
|
||||
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-preview-image {
|
||||
width: 100%;
|
||||
@@ -832,6 +1083,37 @@
|
||||
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 {
|
||||
flex: 1 1 100px;
|
||||
max-width: 120px;
|
||||
@@ -1063,6 +1345,105 @@ a.global-player-title:hover {
|
||||
.global-player.global-player--bandcamp {
|
||||
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 {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
@@ -2534,6 +2915,12 @@ body.has-player .chat-fab {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.dump-edit-refresh-error {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.dump-edit-thumbnail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3009,6 +3396,76 @@ body.has-player .chat-fab {
|
||||
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 */
|
||||
.dump-card-preview .rich-content-thumbnail-btn {
|
||||
width: 100%;
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -4,6 +4,7 @@ import {
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from "react-router";
|
||||
|
||||
@@ -85,14 +86,21 @@ function useResolvedDefaultTab() {
|
||||
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() {
|
||||
return <Navigate to={`/~/${useResolvedDefaultTab()}`} replace />;
|
||||
const { search } = useLocation();
|
||||
return <Navigate to={`/~/${useResolvedDefaultTab()}${search}`} replace />;
|
||||
}
|
||||
|
||||
// Bare `/<slug>` lands on that category's default feed tab.
|
||||
function CategoryRedirect() {
|
||||
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.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCategories } from "../hooks/useCategories.ts";
|
||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
||||
import FilePreview from "./FilePreview.tsx";
|
||||
import RichContentCard from "./RichContentCard.tsx";
|
||||
import ThumbnailPlaceholder from "./ThumbnailPlaceholder.tsx";
|
||||
import { VoteButton } from "./VoteButton.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { Tooltip } from "./Tooltip.tsx";
|
||||
@@ -65,7 +66,7 @@ export function DumpCard(
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
: <span className="dump-card-preview-icon">🔗</span>}
|
||||
: <ThumbnailPlaceholder url={dump.url} />}
|
||||
</div>
|
||||
|
||||
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { formatBytes } from "../utils/format.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import { PlayerContext, type PlayerItem } from "../contexts/PlayerContext.ts";
|
||||
import {
|
||||
BAR_GAP,
|
||||
BAR_W,
|
||||
@@ -20,11 +20,39 @@ interface FilePreviewProps {
|
||||
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,
|
||||
// reflects live play state and position from PlayerContext.
|
||||
function AudioFilePreview(
|
||||
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
||||
) {
|
||||
const { token } = useAuth();
|
||||
const { current, playing, currentTime, duration, play, togglePlay, seekTo } =
|
||||
useContext(PlayerContext);
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
@@ -45,7 +73,7 @@ function AudioFilePreview(
|
||||
|
||||
const handlePlayBtn = () => {
|
||||
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>) => {
|
||||
@@ -59,7 +87,7 @@ function AudioFilePreview(
|
||||
} else {
|
||||
// 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
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
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)} />
|
||||
@@ -196,7 +224,7 @@ export default function FilePreview(
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({ kind: "file", fileUrl, mimeType: mime, title: dump.title, dumpHref: dumpUrl(dump) });
|
||||
play(filePlayerItem(dump, fileUrl, mime, token));
|
||||
}}
|
||||
>
|
||||
{thumbOverride
|
||||
@@ -230,12 +258,9 @@ export default function FilePreview(
|
||||
type="button"
|
||||
className={`file-preview-play-btn${videoActive ? " is-playing" : ""}`}
|
||||
onClick={() =>
|
||||
videoActive ? togglePlay() : play({
|
||||
kind: "file",
|
||||
fileUrl,
|
||||
mimeType: mime,
|
||||
title: dump.title,
|
||||
})}
|
||||
videoActive
|
||||
? togglePlay()
|
||||
: play(filePlayerItem(dump, fileUrl, mime, token))}
|
||||
>
|
||||
<video
|
||||
src={fileUrl}
|
||||
|
||||
@@ -1,32 +1,68 @@
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
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 { fmt } from "../utils/duration.ts";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
|
||||
function itemKey(
|
||||
item: { kind: string; embedUrl?: string; fileUrl?: string } | null,
|
||||
) {
|
||||
if (!item) return null;
|
||||
return item.kind === "embed" ? item.embedUrl : item.fileUrl;
|
||||
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||
|
||||
// The stored embedUrl is the canonical, non-playing form — it is also rendered
|
||||
// outside the player, so the autoplay parameter is added here at playback time
|
||||
// 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() {
|
||||
const {
|
||||
current,
|
||||
queue,
|
||||
queueIndex,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
resolving,
|
||||
startTime,
|
||||
autoplay,
|
||||
stop,
|
||||
next,
|
||||
previous,
|
||||
playAt,
|
||||
seekRef,
|
||||
toggleRef,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
onError,
|
||||
} = useContext(PlayerContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const currentRowRef = useRef<HTMLLIElement>(null);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
const [prevKey, setPrevKey] = useState(itemKey(current));
|
||||
const [prevKey, setPrevKey] = useState(current ? playerItemKey(current) : null);
|
||||
|
||||
if (prevKey !== itemKey(current)) {
|
||||
setPrevKey(itemKey(current));
|
||||
const currentKey = current ? playerItemKey(current) : null;
|
||||
if (prevKey !== currentKey) {
|
||||
setPrevKey(currentKey);
|
||||
if (current) setReduced(false);
|
||||
}
|
||||
|
||||
@@ -57,16 +93,27 @@ export function GlobalPlayer() {
|
||||
};
|
||||
}, [current]);
|
||||
|
||||
// Keep the playing row visible as the queue advances on its own.
|
||||
useEffect(() => {
|
||||
currentRowRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [queueIndex]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const typeClass = current.kind === "embed"
|
||||
? current.type
|
||||
: current.mimeType.startsWith("video/")
|
||||
? "file-video"
|
||||
: "file-audio";
|
||||
// Files are classed by their media kind; everything else carries a brand key,
|
||||
// so a native Bandcamp stream keeps the same styling as the Bandcamp embed.
|
||||
const typeClass = current.kind === "file"
|
||||
? (current.mimeType.startsWith("video/") ? "file-video" : "file-audio")
|
||||
: current.type;
|
||||
|
||||
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 (
|
||||
<div
|
||||
@@ -76,6 +123,16 @@ export function GlobalPlayer() {
|
||||
ref={ref}
|
||||
>
|
||||
<div className="global-player-header">
|
||||
{current.artworkUrl && (
|
||||
<Thumbnail
|
||||
src={current.artworkUrl}
|
||||
className="global-player-artwork"
|
||||
placeholder={{ siteName: current.subtitle }}
|
||||
placeholderClassName="global-player-artwork"
|
||||
loading="eager"
|
||||
/>
|
||||
)}
|
||||
<div className="global-player-heading">
|
||||
{current.dumpHref
|
||||
? (
|
||||
<Link to={current.dumpHref} className="global-player-title">
|
||||
@@ -83,14 +140,49 @@ export function GlobalPlayer() {
|
||||
</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
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
onClick={() => setReduced((r) => !r)}
|
||||
aria-label={reduced ? t`Expand player` : t`Collapse player`}
|
||||
>
|
||||
{reduced ? "▲" : "▼"}
|
||||
</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>
|
||||
</div>
|
||||
@@ -99,14 +191,15 @@ export function GlobalPlayer() {
|
||||
? (
|
||||
<div className="global-player-iframe-wrap">
|
||||
<iframe
|
||||
src={current.embedUrl}
|
||||
src={playbackUrl(current, autoplay)}
|
||||
className={`global-player-iframe--${current.type}`}
|
||||
allow="autoplay; encrypted-media"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
: current.kind === "file"
|
||||
? (
|
||||
<div className="global-player-media-wrap">
|
||||
<MediaPlayer
|
||||
key={current.fileUrl}
|
||||
@@ -117,10 +210,70 @@ export function GlobalPlayer() {
|
||||
startTime={startTime}
|
||||
onPlayStateChange={onPlayStateChange}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onEnded={onEnded}
|
||||
seekRef={seekRef}
|
||||
toggleRef={toggleRef}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ImagePicker({
|
||||
alt={alt}
|
||||
className="img-picker-img"
|
||||
style={{ borderRadius }}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { Dump } from "../model.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.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 { Markdown } from "./Markdown.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 };
|
||||
|
||||
@@ -32,7 +34,7 @@ export function JournalCard(
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuth();
|
||||
const { play } = useContext(PlayerContext);
|
||||
const { playRichContent } = usePlayRichContent();
|
||||
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||
!isDumpVisited(dump.id);
|
||||
|
||||
@@ -41,30 +43,24 @@ export function JournalCard(
|
||||
navigate(dumpUrl(dump));
|
||||
}
|
||||
|
||||
const rawThumbnail =
|
||||
dump.kind === "file" && dump.fileMime?.startsWith("image/")
|
||||
? dumpFileUrl(dump, token)
|
||||
: dump.thumbnailMime
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
: (dump.richContent?.thumbnailUrl ?? null);
|
||||
|
||||
// Route external HTTP thumbnails through the server proxy to avoid
|
||||
// mixed-content blocks when the frontend is served over HTTPS.
|
||||
const thumbnailUrl = (() => {
|
||||
if (!rawThumbnail) return null;
|
||||
try {
|
||||
const u = new URL(rawThumbnail);
|
||||
if (
|
||||
u.protocol === "http:" && u.hostname !== "localhost" &&
|
||||
u.hostname !== "127.0.0.1"
|
||||
) {
|
||||
return `${API_URL}/api/proxy-image?url=${
|
||||
encodeURIComponent(rawThumbnail)
|
||||
}`;
|
||||
// A playable card plays. If playback turns out to be impossible — a Bandcamp
|
||||
// page with no streams and no embed to fall back to — the card must still do
|
||||
// what an unplayable one does rather than swallowing the click.
|
||||
async function handlePlayOrNavigate(rc: NonNullable<typeof playable>) {
|
||||
if (!await playRichContent(rc, dumpUrl(dump))) handleNavigate();
|
||||
}
|
||||
} catch { /* relative URL */ }
|
||||
return rawThumbnail;
|
||||
})();
|
||||
|
||||
// Mirrors FilePreview (the hot/new feeds) so a video shows its generated
|
||||
// still here too, rather than degrading to a text card with a 🎬.
|
||||
const thumbnailUrl = dump.thumbnailMime
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
: dump.kind === "file"
|
||||
? (dump.fileMime?.startsWith("image/")
|
||||
? dumpFileUrl(dump, token)
|
||||
: dump.fileMime?.startsWith("video/")
|
||||
? dumpThumbnailUrl(dump, token)
|
||||
: null)
|
||||
: (dump.richContent?.thumbnailUrl ?? null);
|
||||
|
||||
// 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
|
||||
@@ -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 = (
|
||||
<Link
|
||||
@@ -155,27 +157,25 @@ export function JournalCard(
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
onClick={embedUrl
|
||||
? () =>
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl,
|
||||
title: dump.richContent?.title,
|
||||
type: dump.richContent?.type ?? "unknown",
|
||||
dumpHref: dumpUrl(dump),
|
||||
})
|
||||
onClick={playable
|
||||
? () => void handlePlayOrNavigate(playable)
|
||||
: handleNavigate}
|
||||
>
|
||||
<div className="journal-card-image">
|
||||
<img
|
||||
<Thumbnail
|
||||
src={thumbnailUrl ?? undefined}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.visibility = "hidden";
|
||||
placeholder={dump.kind === "file"
|
||||
// No ffmpeg on the host means no still — the mime glyph says more
|
||||
// about the dump than an initial taken from its filename.
|
||||
? { 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>
|
||||
|
||||
@@ -16,10 +16,10 @@ function preprocessMentions(text: string): string {
|
||||
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
|
||||
}
|
||||
|
||||
// Static components object — defined once at module scope to avoid recreation on every render
|
||||
const MARKDOWN_COMPONENTS: React.ComponentProps<
|
||||
typeof ReactMarkdown
|
||||
>["components"] = {
|
||||
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
|
||||
|
||||
// Static components objects — defined once at module scope to avoid recreation on every render
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
a: ({ href, children: linkChildren }) => {
|
||||
if (href?.startsWith("/users/")) {
|
||||
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(
|
||||
{ children, className, inline = false }: MarkdownProps,
|
||||
) {
|
||||
@@ -45,7 +53,9 @@ export function Markdown(
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
components={inline
|
||||
? INLINE_MARKDOWN_COMPONENTS
|
||||
: MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -7,13 +7,7 @@ import {
|
||||
VIEWBOX_W,
|
||||
WAVEFORM_H,
|
||||
} from "../utils/waveform.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")}`;
|
||||
}
|
||||
import { fmt } from "../utils/duration.ts";
|
||||
|
||||
export const IconPlay = () => (
|
||||
<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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const HIDE_DELAY = 2500;
|
||||
@@ -132,6 +192,14 @@ interface MediaPlayerProps {
|
||||
startTime?: number;
|
||||
onPlayStateChange?: (playing: boolean) => 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 };
|
||||
toggleRef?: { current: (() => void) | null };
|
||||
}
|
||||
@@ -146,6 +214,9 @@ export function MediaPlayer(
|
||||
startTime,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
onError,
|
||||
trackStyle = "waveform",
|
||||
seekRef,
|
||||
toggleRef,
|
||||
}: MediaPlayerProps,
|
||||
@@ -155,8 +226,8 @@ export function MediaPlayer(
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(lastVolume);
|
||||
const [muted, setMuted] = useState(lastMuted);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
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.
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onEndedRef = useRef(onEnded);
|
||||
const onErrorRef = useRef(onError);
|
||||
|
||||
// Sync prop callbacks after every render
|
||||
useEffect(() => {
|
||||
onPlayStateChangeRef.current = onPlayStateChange;
|
||||
onTimeUpdateRef.current = onTimeUpdate;
|
||||
onEndedRef.current = onEnded;
|
||||
onErrorRef.current = onError;
|
||||
});
|
||||
|
||||
// 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
|
||||
}, []);
|
||||
|
||||
// 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)
|
||||
useEffect(() => {
|
||||
if (!autoplay) return;
|
||||
@@ -245,6 +331,8 @@ export function MediaPlayer(
|
||||
a.pause();
|
||||
onPlayStateChangeRef.current = undefined;
|
||||
onTimeUpdateRef.current = undefined;
|
||||
onEndedRef.current = undefined;
|
||||
onErrorRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -272,17 +360,24 @@ export function MediaPlayer(
|
||||
setDuration(a.duration);
|
||||
onTimeUpdateRef.current?.(a.currentTime, a.duration);
|
||||
};
|
||||
const onEnded = () => {
|
||||
const onEndedEvent = () => {
|
||||
setPlaying(false);
|
||||
onPlayStateChangeRef.current?.(false);
|
||||
onEndedRef.current?.();
|
||||
};
|
||||
const onErrorEvent = () => {
|
||||
setPlaying(false);
|
||||
onErrorRef.current?.();
|
||||
};
|
||||
a.addEventListener("timeupdate", onTime);
|
||||
a.addEventListener("durationchange", onDuration);
|
||||
a.addEventListener("ended", onEnded);
|
||||
a.addEventListener("ended", onEndedEvent);
|
||||
a.addEventListener("error", onErrorEvent);
|
||||
return () => {
|
||||
a.removeEventListener("timeupdate", onTime);
|
||||
a.removeEventListener("durationchange", onDuration);
|
||||
a.removeEventListener("ended", onEnded);
|
||||
a.removeEventListener("ended", onEndedEvent);
|
||||
a.removeEventListener("error", onErrorEvent);
|
||||
};
|
||||
}, [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 v = Number(e.target.value);
|
||||
const nextMuted = v > 0 && muted ? false : muted;
|
||||
setVolume(v);
|
||||
mediaRef.current!.volume = v;
|
||||
if (v > 0 && muted) {
|
||||
setMuted(false);
|
||||
mediaRef.current!.muted = false;
|
||||
if (nextMuted !== muted) {
|
||||
setMuted(nextMuted);
|
||||
mediaRef.current!.muted = nextMuted;
|
||||
}
|
||||
rememberVolume(v, nextMuted);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const next = !muted;
|
||||
setMuted(next);
|
||||
mediaRef.current!.muted = next;
|
||||
rememberVolume(volume, next);
|
||||
};
|
||||
|
||||
const goFullscreen = () => {
|
||||
(mediaRef.current as HTMLVideoElement).requestFullscreen?.();
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
|
||||
const track = kind === "audio"
|
||||
// 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" && trackStyle === "waveform"
|
||||
? (
|
||||
<Waveform
|
||||
src={src}
|
||||
@@ -352,24 +447,13 @@ export function MediaPlayer(
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="audio-player-track">
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
<SeekBar
|
||||
current={current}
|
||||
duration={duration}
|
||||
onSeek={seekTo}
|
||||
onDragChange={setDragging}
|
||||
className={kind === "audio" ? "audio-player-track--stream" : undefined}
|
||||
/>
|
||||
<input
|
||||
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 = (
|
||||
|
||||
@@ -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 { t } from "@lingui/core/macro";
|
||||
|
||||
@@ -7,11 +7,24 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
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 requestClose = useCallback(() => {
|
||||
if (onBeforeClose?.() === false) return;
|
||||
onClose();
|
||||
}, [onBeforeClose, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
@@ -21,18 +34,18 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||
if (e.key === "Escape" && !e.defaultPrevented) requestClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
}, [requestClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
ref={backdropRef}
|
||||
onClick={(e) => {
|
||||
if (e.target === backdropRef.current) onClose();
|
||||
if (e.target === backdropRef.current) requestClose();
|
||||
}}
|
||||
>
|
||||
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
|
||||
@@ -41,7 +54,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-btn"
|
||||
onClick={onClose}
|
||||
onClick={requestClose}
|
||||
aria-label={t`Close`}
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
import type { RichContent } from "../model.ts";
|
||||
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
/** Route HTTP thumbnail URLs through the server proxy to avoid mixed-content blocks. */
|
||||
function proxyIfHttp(url: string): string {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
import {
|
||||
canPlayRichContent,
|
||||
usePlayRichContent,
|
||||
} from "../hooks/usePlayRichContent.ts";
|
||||
import Thumbnail from "./Thumbnail.tsx";
|
||||
|
||||
interface RichContentCardProps {
|
||||
richContent: RichContent;
|
||||
@@ -30,48 +20,68 @@ export default function RichContentCard(
|
||||
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
|
||||
RichContentCardProps,
|
||||
) {
|
||||
const { play, current, playing } = useContext(PlayerContext);
|
||||
const thumbnailSrc = thumbnailOverrideUrl ??
|
||||
(richContent.thumbnailUrl
|
||||
? proxyIfHttp(richContent.thumbnailUrl)
|
||||
: undefined);
|
||||
const { current, playing, togglePlay } = useContext(PlayerContext);
|
||||
const { playRichContent, pending } = usePlayRichContent();
|
||||
|
||||
const canPlay = canPlayRichContent(richContent);
|
||||
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 (richContent.embedUrl) {
|
||||
const isActive = current?.kind === "embed" &&
|
||||
current.embedUrl === richContent.embedUrl;
|
||||
const thumbnail = (
|
||||
<Thumbnail
|
||||
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;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rich-content-thumbnail-btn${
|
||||
isActive ? " is-playing" : ""
|
||||
}`}
|
||||
}${pending ? " is-pending" : ""}`}
|
||||
disabled={pending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl: richContent.embedUrl!,
|
||||
title: richContent.title,
|
||||
type: richContent.type,
|
||||
dumpHref,
|
||||
});
|
||||
// The button says "Pause" once this card is the one playing, so it
|
||||
// has to pause. Re-playing would re-resolve the album and lose the
|
||||
// queue position. Embeds have no transport of their own, so they
|
||||
// keep restarting, as before.
|
||||
if (isActive && current?.kind === "stream") togglePlay();
|
||||
else void playOrOpen();
|
||||
}}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{thumbnailSrc
|
||||
? (
|
||||
<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>}
|
||||
{thumbnail}
|
||||
<span className="rich-content-play-overlay">
|
||||
{isPlaying ? "⏸" : "▶"}
|
||||
</span>
|
||||
@@ -87,62 +97,35 @@ export default function RichContentCard(
|
||||
className="rich-content-compact"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{thumbnailSrc
|
||||
? (
|
||||
<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>}
|
||||
{thumbnail}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const canPlay = !!richContent.embedUrl;
|
||||
const thumbnailImg = (
|
||||
<Thumbnail
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-thumbnail"
|
||||
placeholder={placeholder}
|
||||
placeholderClassName="rich-content-thumbnail"
|
||||
/>
|
||||
);
|
||||
|
||||
const thumbnail = thumbnailSrc && (
|
||||
canPlay
|
||||
const thumbnail = canPlay
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="rich-content-thumbnail-btn"
|
||||
onClick={() =>
|
||||
play({
|
||||
kind: "embed",
|
||||
embedUrl: richContent.embedUrl!,
|
||||
title: richContent.title,
|
||||
type: richContent.type,
|
||||
dumpHref,
|
||||
})}
|
||||
className={`rich-content-thumbnail-btn${pending ? " is-pending" : ""}`}
|
||||
disabled={pending}
|
||||
onClick={() => void playOrOpen()}
|
||||
aria-label="Play"
|
||||
>
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={richContent.title ?? ""}
|
||||
className="rich-content-thumbnail"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
{thumbnailImg}
|
||||
<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";
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
: thumbnailImg;
|
||||
|
||||
return (
|
||||
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
|
||||
|
||||
73
src/components/Thumbnail.tsx
Normal file
73
src/components/Thumbnail.tsx
Normal 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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
src/components/ThumbnailPlaceholder.tsx
Normal file
70
src/components/ThumbnailPlaceholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -64,14 +64,17 @@ export function SegmentedField<T extends FieldValues, V>({
|
||||
*/
|
||||
export function VisibilityToggle<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
disabled,
|
||||
}: {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SegmentedField<T, boolean>
|
||||
name={name}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ value: true, label: <Trans>Public</Trans> },
|
||||
|
||||
19
src/config/playerMode.ts
Normal file
19
src/config/playerMode.ts
Normal 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();
|
||||
@@ -1,8 +1,68 @@
|
||||
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 =
|
||||
| { kind: "embed"; embedUrl: string; title?: string; type: string; dumpHref?: string }
|
||||
| { kind: "file"; fileUrl: string; mimeType: string; title?: string; dumpHref?: string };
|
||||
| (PlayerItemBase & {
|
||||
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 {
|
||||
// Playback state — readable by any consumer
|
||||
@@ -11,14 +71,28 @@ export interface PlayerContextValue {
|
||||
currentTime: 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
|
||||
// from a previous session) and whether the active MediaPlayer should autoplay.
|
||||
// A restored item appears paused; a fresh play() autoplays on the user gesture.
|
||||
// from a previous session, or resumed after a re-resolve) and whether the
|
||||
// active MediaPlayer should autoplay. A restored item appears paused; a fresh
|
||||
// play() autoplays on the user gesture.
|
||||
startTime: number;
|
||||
autoplay: boolean;
|
||||
|
||||
// Control — callable by any consumer
|
||||
play(item: PlayerItem): void;
|
||||
playQueue(items: PlayerItem[], startIndex?: number): void;
|
||||
playAt(index: number): void;
|
||||
next(): void;
|
||||
previous(): void;
|
||||
stop(): void;
|
||||
seekTo(time: number): void;
|
||||
togglePlay(): void;
|
||||
@@ -31,6 +105,10 @@ export interface PlayerContextValue {
|
||||
// Internal: GlobalPlayer calls these to push state back into the provider
|
||||
onPlayStateChange(playing: boolean): 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>({
|
||||
@@ -38,9 +116,18 @@ export const PlayerContext = createContext<PlayerContextValue>({
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
queue: [],
|
||||
queueIndex: 0,
|
||||
hasNext: false,
|
||||
hasPrevious: false,
|
||||
resolving: false,
|
||||
startTime: 0,
|
||||
autoplay: false,
|
||||
play: () => {},
|
||||
playQueue: () => {},
|
||||
playAt: () => {},
|
||||
next: () => {},
|
||||
previous: () => {},
|
||||
stop: () => {},
|
||||
seekTo: () => {},
|
||||
togglePlay: () => {},
|
||||
@@ -48,4 +135,6 @@ export const PlayerContext = createContext<PlayerContextValue>({
|
||||
toggleRef: { current: null },
|
||||
onPlayStateChange: () => {},
|
||||
onTimeUpdate: () => {},
|
||||
onEnded: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
@@ -1,39 +1,83 @@
|
||||
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";
|
||||
|
||||
// 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 {
|
||||
item: PlayerItem;
|
||||
v: 2;
|
||||
queue: PlayerItem[];
|
||||
index: 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 {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<StoredSession>;
|
||||
if (!parsed.item || (parsed.item.kind !== "embed" && parsed.item.kind !== "file")) {
|
||||
return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const time = Number(parsed.time) || 0;
|
||||
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
const restored = useRef(readSession()).current;
|
||||
/** Don't retry the same failing track more than once per window. */
|
||||
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 [currentTime, setCurrentTime] = useState(restored?.time ?? 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);
|
||||
// Restored items start paused (no user gesture); fresh play() autoplays.
|
||||
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
|
||||
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).
|
||||
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;
|
||||
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);
|
||||
setDuration(0);
|
||||
setStartTime(0);
|
||||
@@ -54,7 +130,9 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setCurrent(null);
|
||||
playGen.current++;
|
||||
setQueue([]);
|
||||
setQueueIndex(0);
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
@@ -70,6 +148,34 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
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) => {
|
||||
if (p) suppressUpdates.current = false;
|
||||
setPlaying(p);
|
||||
@@ -81,25 +187,158 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
setDuration(d);
|
||||
}, []);
|
||||
|
||||
// ── Persistence ────────────────────────────────────────────────────────────────
|
||||
// Write the item 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.
|
||||
const onEnded = useCallback(() => {
|
||||
if (stateRef.current.current && queueIndex + 1 < queue.length) {
|
||||
advanceTo(queueIndex + 1);
|
||||
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(() => {
|
||||
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(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ item: current, time: currentTime } satisfies StoredSession),
|
||||
JSON.stringify(
|
||||
{ v: 2, queue, index: queueIndex, time: currentTime } satisfies StoredSession,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
// currentTime intentionally omitted from deps — see pagehide writer below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [current]);
|
||||
}, [queue, queueIndex]);
|
||||
|
||||
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(() => {
|
||||
const persist = () => {
|
||||
@@ -118,9 +357,18 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
playing,
|
||||
currentTime,
|
||||
duration,
|
||||
queue,
|
||||
queueIndex,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
resolving,
|
||||
startTime,
|
||||
autoplay,
|
||||
play,
|
||||
playQueue,
|
||||
playAt,
|
||||
next,
|
||||
previous,
|
||||
stop,
|
||||
seekTo,
|
||||
togglePlay,
|
||||
@@ -128,19 +376,32 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
toggleRef,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
onError,
|
||||
}), [
|
||||
current,
|
||||
playing,
|
||||
currentTime,
|
||||
duration,
|
||||
queue,
|
||||
queueIndex,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
resolving,
|
||||
startTime,
|
||||
autoplay,
|
||||
play,
|
||||
playQueue,
|
||||
playAt,
|
||||
next,
|
||||
previous,
|
||||
stop,
|
||||
seekTo,
|
||||
togglePlay,
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
onEnded,
|
||||
onError,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -59,22 +59,77 @@ export const useAuth = () => {
|
||||
return res;
|
||||
}, [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 0–1 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 {
|
||||
user: authResponse?.user ?? null,
|
||||
token: authResponse?.token ?? null,
|
||||
login,
|
||||
logout,
|
||||
authFetch,
|
||||
authUpload,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRequiredAuth = () => {
|
||||
const { user, token, login, logout, authFetch } = useAuth();
|
||||
const { user, token, login, logout, authFetch, authUpload } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
"Invariant: useRequiredAuth called outside a protected route",
|
||||
);
|
||||
}
|
||||
return { user, token, login, logout, authFetch };
|
||||
return { user, token, login, logout, authFetch, authUpload };
|
||||
};
|
||||
|
||||
88
src/hooks/usePlayRichContent.ts
Normal file
88
src/hooks/usePlayRichContent.ts
Normal 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
@@ -18,8 +18,8 @@ msgid "[deleted]"
|
||||
msgstr "[deleted]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: src/components/DumpCard.tsx:111
|
||||
#: src/components/JournalCard.tsx:112
|
||||
#: src/components/DumpCard.tsx:112
|
||||
#: src/components/JournalCard.tsx:114
|
||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||
|
||||
@@ -28,6 +28,11 @@ msgstr "{0, plural, one {# comment} other {# comments}}"
|
||||
msgid "{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 {1}: names[1]
|
||||
#: src/components/ChatModal.tsx:531
|
||||
@@ -53,14 +58,15 @@ msgstr "{label} ({count})"
|
||||
msgid "{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/UserPublicProfile.tsx:749
|
||||
msgid "← Back"
|
||||
msgstr "← Back"
|
||||
|
||||
#: src/pages/Dump.tsx:291
|
||||
#: src/pages/Dump.tsx:521
|
||||
#: src/pages/DumpEdit.tsx:181
|
||||
#: src/pages/Dump.tsx:292
|
||||
#: src/pages/Dump.tsx:522
|
||||
#: src/pages/DumpEdit.tsx:187
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Back to all dumps"
|
||||
|
||||
@@ -78,7 +84,7 @@ msgstr "+ Invite someone"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ New playlist"
|
||||
|
||||
#: src/pages/Dump.tsx:362
|
||||
#: src/pages/Dump.tsx:363
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Playlist"
|
||||
|
||||
@@ -154,7 +160,7 @@ msgid "Add email…"
|
||||
msgstr "Add email…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:301
|
||||
#: src/components/DumpCreateModal.tsx:659
|
||||
msgid "Add to playlist"
|
||||
msgstr "Add to playlist"
|
||||
|
||||
@@ -167,6 +173,12 @@ msgstr "Admin"
|
||||
msgid "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
|
||||
msgid "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/CommentThread.tsx:124
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/DumpCreateModal.tsx:945
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:403
|
||||
#: src/pages/DumpEdit.tsx:460
|
||||
#: src/pages/Dump.tsx:404
|
||||
#: src/pages/DumpEdit.tsx:469
|
||||
#: src/pages/PlaylistDetail.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:1674
|
||||
#: src/pages/UserPublicProfile.tsx:1744
|
||||
@@ -262,10 +275,26 @@ msgid "Checking invite…"
|
||||
msgstr "Checking invite…"
|
||||
|
||||
#: src/components/ChangePasswordModal.tsx:56
|
||||
#: src/components/Modal.tsx:45
|
||||
#: src/components/Modal.tsx:58
|
||||
msgid "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
|
||||
msgid "Color scheme"
|
||||
msgstr "Color scheme"
|
||||
@@ -291,10 +320,14 @@ msgstr "Could not change password"
|
||||
msgid "Could not load."
|
||||
msgstr "Could not load."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:361
|
||||
#: src/pages/DumpEdit.tsx:370
|
||||
msgid "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
|
||||
msgid "Create"
|
||||
msgstr "Create"
|
||||
@@ -346,8 +379,8 @@ msgstr "Delete category"
|
||||
msgid "Delete category \"{0}\"? This cannot be undone."
|
||||
msgstr "Delete category \"{0}\"? This cannot be undone."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:255
|
||||
#: src/pages/DumpEdit.tsx:456
|
||||
#: src/pages/DumpEdit.tsx:264
|
||||
#: src/pages/DumpEdit.tsx:465
|
||||
msgid "Delete dump"
|
||||
msgstr "Delete dump"
|
||||
|
||||
@@ -361,7 +394,7 @@ msgstr "Delete playlist"
|
||||
msgid "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."
|
||||
msgstr "Delete this dump? This cannot be undone."
|
||||
|
||||
@@ -383,7 +416,7 @@ msgstr "deleted message"
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (optional)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:488
|
||||
#: src/components/DumpCreateModal.tsx:741
|
||||
msgid "Done"
|
||||
msgstr "Done"
|
||||
|
||||
@@ -391,7 +424,11 @@ msgstr "Done"
|
||||
msgid "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"
|
||||
msgstr "Drop a replacement here"
|
||||
|
||||
@@ -399,11 +436,11 @@ msgstr "Drop a replacement here"
|
||||
msgid "Dump"
|
||||
msgstr "Dump"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:458
|
||||
#: src/components/DumpCreateModal.tsx:958
|
||||
msgid "Dump it"
|
||||
msgstr "Dump it"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:469
|
||||
#: src/components/DumpCreateModal.tsx:718
|
||||
msgid "Dumped!"
|
||||
msgstr "Dumped!"
|
||||
|
||||
@@ -426,7 +463,7 @@ msgstr "Earlier"
|
||||
#: src/components/ChatModal.tsx:172
|
||||
#: src/components/ChatModal.tsx:173
|
||||
#: src/components/CommentThread.tsx:367
|
||||
#: src/pages/Dump.tsx:517
|
||||
#: src/pages/Dump.tsx:518
|
||||
#: src/pages/PlaylistDetail.tsx:625
|
||||
msgid "Edit"
|
||||
msgstr "Edit"
|
||||
@@ -436,7 +473,7 @@ msgstr "Edit"
|
||||
msgid "Edit {0}"
|
||||
msgstr "Edit {0}"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:420
|
||||
#: src/components/DumpCreateModal.tsx:197
|
||||
msgid "Edit title"
|
||||
msgstr "Edit title"
|
||||
|
||||
@@ -445,7 +482,7 @@ msgstr "Edit title"
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/ChatModal.tsx:152
|
||||
#: src/components/CommentThread.tsx:317
|
||||
#: src/pages/Dump.tsx:456
|
||||
#: src/pages/Dump.tsx:457
|
||||
#: src/pages/PlaylistDetail.tsx:664
|
||||
msgid "edited {0}"
|
||||
msgstr "edited {0}"
|
||||
@@ -455,12 +492,12 @@ msgstr "edited {0}"
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/ChatModal.tsx:150
|
||||
#: src/components/CommentThread.tsx:315
|
||||
#: src/pages/Dump.tsx:454
|
||||
#: src/pages/Dump.tsx:455
|
||||
#: src/pages/PlaylistDetail.tsx:661
|
||||
msgid "Edited {0}"
|
||||
msgstr "Edited {0}"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:206
|
||||
#: src/pages/DumpEdit.tsx:212
|
||||
msgid "Editing"
|
||||
msgstr "Editing"
|
||||
|
||||
@@ -477,6 +514,10 @@ msgstr "Email address"
|
||||
msgid "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
|
||||
msgid "Failed to create category"
|
||||
msgstr "Failed to create category"
|
||||
@@ -506,7 +547,7 @@ msgstr "Failed to generate invite"
|
||||
msgid "Failed to load"
|
||||
msgstr "Failed to load"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:337
|
||||
#: src/components/DumpCreateModal.tsx:750
|
||||
msgid "Failed to post"
|
||||
msgstr "Failed to post"
|
||||
|
||||
@@ -544,19 +585,11 @@ msgstr "Failed to update role"
|
||||
msgid "Feeds"
|
||||
msgstr "Feeds"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:369
|
||||
#: src/components/DumpCreateModal.tsx:789
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Fetching preview…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:455
|
||||
msgid "Fetching…"
|
||||
msgstr "Fetching…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:331
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:239
|
||||
#: src/components/DumpCreateModal.tsx:571
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "File too large (max 50 MB)."
|
||||
|
||||
@@ -632,7 +665,7 @@ msgstr "Hot"
|
||||
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."
|
||||
|
||||
#: src/pages/Dump.tsx:551
|
||||
#: src/pages/Dump.tsx:552
|
||||
msgid "In collections"
|
||||
msgstr "In collections"
|
||||
|
||||
@@ -658,6 +691,10 @@ msgstr "Invitees"
|
||||
msgid "Journal"
|
||||
msgstr "Journal"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:687
|
||||
msgid "Keep editing"
|
||||
msgstr "Keep editing"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1205
|
||||
msgid "Light"
|
||||
msgstr "Light"
|
||||
@@ -666,6 +703,14 @@ msgstr "Light"
|
||||
msgid "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
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
@@ -683,8 +728,8 @@ msgstr "Load more"
|
||||
msgid "Load older messages"
|
||||
msgstr "Load older messages"
|
||||
|
||||
#: src/pages/Dump.tsx:267
|
||||
#: src/pages/DumpEdit.tsx:157
|
||||
#: src/pages/Dump.tsx:268
|
||||
#: src/pages/DumpEdit.tsx:163
|
||||
msgid "Loading dump…"
|
||||
msgstr "Loading dump…"
|
||||
|
||||
@@ -783,7 +828,7 @@ msgstr "new"
|
||||
msgid "New"
|
||||
msgstr "New"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:301
|
||||
#: src/components/DumpCreateModal.tsx:659
|
||||
#: src/components/DumpFab.tsx:65
|
||||
#: src/components/DumpFab.tsx:66
|
||||
#: src/pages/UserDumps.tsx:88
|
||||
@@ -801,6 +846,14 @@ msgstr "New password"
|
||||
msgid "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
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "No dumps in this playlist yet."
|
||||
@@ -907,11 +960,20 @@ msgstr "Password updated"
|
||||
msgid "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
|
||||
msgid "Playlist title"
|
||||
msgstr "Playlist title"
|
||||
|
||||
#: src/components/AppHeader.tsx:86
|
||||
#: src/components/DumpCreateModal.tsx:68
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:177
|
||||
#: src/pages/UserPlaylists.tsx:371
|
||||
@@ -925,10 +987,6 @@ msgstr "Playlists"
|
||||
msgid "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
|
||||
msgid "Post comment"
|
||||
msgstr "Post comment"
|
||||
@@ -939,19 +997,24 @@ msgstr "Post reply"
|
||||
|
||||
#: src/components/CommentThread.tsx:396
|
||||
#: src/components/CommentThread.tsx:473
|
||||
#: src/components/DumpCreateModal.tsx:951
|
||||
msgid "Posting…"
|
||||
msgstr "Posting…"
|
||||
|
||||
#: src/components/DumpCard.tsx:120
|
||||
#: src/components/JournalCard.tsx:121
|
||||
#: src/components/GlobalPlayer.tsx:154
|
||||
msgid "Previous track"
|
||||
msgstr "Previous track"
|
||||
|
||||
#: src/components/DumpCard.tsx:121
|
||||
#: src/components/JournalCard.tsx:123
|
||||
#: src/components/PlaylistCard.tsx:73
|
||||
#: src/components/PlaylistMembershipPanel.tsx:55
|
||||
#: src/pages/Dump.tsx:462
|
||||
#: src/pages/Dump.tsx:463
|
||||
#: src/pages/PlaylistDetail.tsx:644
|
||||
msgid "private"
|
||||
msgstr "private"
|
||||
|
||||
#: src/components/form/SegmentedField.tsx:78
|
||||
#: src/components/form/SegmentedField.tsx:81
|
||||
#: src/pages/PlaylistDetail.tsx:902
|
||||
msgid "Private"
|
||||
msgstr "Private"
|
||||
@@ -961,16 +1024,20 @@ msgstr "Private"
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: src/components/form/SegmentedField.tsx:77
|
||||
#: src/components/form/SegmentedField.tsx:80
|
||||
#: src/pages/PlaylistDetail.tsx:895
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:235
|
||||
#: src/pages/DumpEdit.tsx:241
|
||||
msgid "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…"
|
||||
msgstr "Refreshing…"
|
||||
|
||||
@@ -988,7 +1055,7 @@ msgstr "Registering…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Registration failed"
|
||||
|
||||
#: src/pages/Dump.tsx:529
|
||||
#: src/pages/Dump.tsx:530
|
||||
msgid "Related"
|
||||
msgstr "Related"
|
||||
|
||||
@@ -1008,7 +1075,7 @@ msgstr "Remove like"
|
||||
msgid "Remove vote"
|
||||
msgstr "Remove vote"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:420
|
||||
#: src/pages/DumpEdit.tsx:429
|
||||
msgid "Replace file"
|
||||
msgstr "Replace file"
|
||||
|
||||
@@ -1034,13 +1101,18 @@ msgstr "Reset failed"
|
||||
msgid "Reset password"
|
||||
msgstr "Reset password"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:381
|
||||
#: src/pages/DumpEdit.tsx:399
|
||||
#: src/pages/DumpEdit.tsx:390
|
||||
#: src/pages/DumpEdit.tsx:408
|
||||
msgid "Reset to default"
|
||||
msgstr "Reset to default"
|
||||
|
||||
#: src/pages/Dump.tsx:284
|
||||
#: src/pages/DumpEdit.tsx:174
|
||||
#: src/components/DumpCreateModal.tsx:208
|
||||
#: 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"
|
||||
msgstr "Retry"
|
||||
|
||||
@@ -1050,8 +1122,8 @@ msgstr "Role"
|
||||
|
||||
#: src/components/ChatModal.tsx:222
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:395
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/Dump.tsx:396
|
||||
#: src/pages/DumpEdit.tsx:472
|
||||
#: src/pages/PlaylistDetail.tsx:927
|
||||
#: src/pages/UserPublicProfile.tsx:1666
|
||||
#: src/pages/UserPublicProfile.tsx:1736
|
||||
@@ -1060,7 +1132,7 @@ msgstr "Save"
|
||||
|
||||
#: src/components/ChangePasswordModal.tsx:100
|
||||
#: src/components/CommentThread.tsx:329
|
||||
#: src/pages/Dump.tsx:394
|
||||
#: src/pages/Dump.tsx:395
|
||||
#: src/pages/PlaylistDetail.tsx:923
|
||||
#: src/pages/ResetPassword.tsx:126
|
||||
#: src/pages/UserPublicProfile.tsx:1663
|
||||
@@ -1124,6 +1196,10 @@ msgstr "slug"
|
||||
msgid "Something went wrong"
|
||||
msgstr "Something went wrong"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:706
|
||||
msgid "Start fresh"
|
||||
msgstr "Start fresh"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1155
|
||||
msgid "Style"
|
||||
msgstr "Style"
|
||||
@@ -1152,17 +1228,17 @@ msgstr "This page does not exist."
|
||||
msgid "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"
|
||||
msgstr "Thumbnail"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:389
|
||||
#: src/components/DumpCreateModal.tsx:174
|
||||
#: src/components/PlaylistCreateForm.tsx:70
|
||||
#: src/pages/DumpEdit.tsx:389
|
||||
#: src/pages/DumpEdit.tsx:398
|
||||
msgid "Title"
|
||||
msgstr "Title"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:237
|
||||
#: src/components/DumpCreateModal.tsx:569
|
||||
msgid "Title is required."
|
||||
msgstr "Title is required."
|
||||
|
||||
@@ -1191,7 +1267,12 @@ msgstr "Unfollow playlist"
|
||||
msgid "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…"
|
||||
msgstr "Uploading…"
|
||||
|
||||
@@ -1209,12 +1290,11 @@ msgstr "Upvoted"
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Upvoted ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:344
|
||||
#: src/pages/DumpEdit.tsx:412
|
||||
#: src/pages/DumpEdit.tsx:421
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:223
|
||||
#: src/components/DumpCreateModal.tsx:553
|
||||
msgid "URL is required."
|
||||
msgstr "URL is required."
|
||||
|
||||
@@ -1251,12 +1331,12 @@ msgstr "Users"
|
||||
msgid "View all →"
|
||||
msgstr "View all →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:471
|
||||
#: src/components/DumpCreateModal.tsx:720
|
||||
msgid "View dump →"
|
||||
msgstr "View dump →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:434
|
||||
#: src/pages/DumpEdit.tsx:437
|
||||
#: src/components/DumpCreateModal.tsx:904
|
||||
#: src/pages/DumpEdit.tsx:446
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "What makes it worth it?"
|
||||
|
||||
@@ -1265,8 +1345,16 @@ msgstr "What makes it worth it?"
|
||||
msgid "Who am I?"
|
||||
msgstr "Who am I?"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:433
|
||||
#: src/pages/DumpEdit.tsx:436
|
||||
#: src/components/DumpCreateModal.tsx:920
|
||||
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?"
|
||||
msgstr "Why?"
|
||||
|
||||
@@ -1279,6 +1367,10 @@ msgstr "Write a reply…"
|
||||
msgid "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
|
||||
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."
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,8 +18,8 @@ msgid "[deleted]"
|
||||
msgstr "[supprimé]"
|
||||
|
||||
#. placeholder {0}: dump.commentCount
|
||||
#: src/components/DumpCard.tsx:111
|
||||
#: src/components/JournalCard.tsx:112
|
||||
#: src/components/DumpCard.tsx:112
|
||||
#: src/components/JournalCard.tsx:114
|
||||
msgid "{0, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||
|
||||
@@ -28,6 +28,11 @@ msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
|
||||
msgid "{0, plural, one {# dump} other {# dumps}}"
|
||||
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 {1}: names[1]
|
||||
#: src/components/ChatModal.tsx:531
|
||||
@@ -53,14 +58,15 @@ msgstr "{label} ({count})"
|
||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:935
|
||||
#: src/pages/PlaylistDetail.tsx:570
|
||||
#: src/pages/UserPublicProfile.tsx:749
|
||||
msgid "← Back"
|
||||
msgstr "← Retour"
|
||||
|
||||
#: src/pages/Dump.tsx:291
|
||||
#: src/pages/Dump.tsx:521
|
||||
#: src/pages/DumpEdit.tsx:181
|
||||
#: src/pages/Dump.tsx:292
|
||||
#: src/pages/Dump.tsx:522
|
||||
#: src/pages/DumpEdit.tsx:187
|
||||
msgid "← Back to all dumps"
|
||||
msgstr "← Retour à toutes les recos"
|
||||
|
||||
@@ -78,7 +84,7 @@ msgstr "+ Inviter quelqu'un"
|
||||
msgid "+ New playlist"
|
||||
msgstr "+ Nouvelle collection"
|
||||
|
||||
#: src/pages/Dump.tsx:362
|
||||
#: src/pages/Dump.tsx:363
|
||||
msgid "+ Playlist"
|
||||
msgstr "+ Collection"
|
||||
|
||||
@@ -154,7 +160,7 @@ msgid "Add email…"
|
||||
msgstr "Ajouter un e-mail…"
|
||||
|
||||
#: src/components/AddToPlaylistModal.tsx:64
|
||||
#: src/components/DumpCreateModal.tsx:301
|
||||
#: src/components/DumpCreateModal.tsx:659
|
||||
msgid "Add to playlist"
|
||||
msgstr "Ajouter à la collection"
|
||||
|
||||
@@ -167,6 +173,12 @@ msgstr "Administrateur"
|
||||
msgid "All"
|
||||
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
|
||||
msgid "Already have an account? <0>Log in</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/CommentThread.tsx:124
|
||||
#: src/components/ConfirmModal.tsx:32
|
||||
#: src/components/DumpCreateModal.tsx:945
|
||||
#: src/components/form/FormActions.tsx:32
|
||||
#: src/pages/Dump.tsx:403
|
||||
#: src/pages/DumpEdit.tsx:460
|
||||
#: src/pages/Dump.tsx:404
|
||||
#: src/pages/DumpEdit.tsx:469
|
||||
#: src/pages/PlaylistDetail.tsx:920
|
||||
#: src/pages/UserPublicProfile.tsx:1674
|
||||
#: src/pages/UserPublicProfile.tsx:1744
|
||||
@@ -262,10 +275,26 @@ msgid "Checking invite…"
|
||||
msgstr "Vérification de l'invitation…"
|
||||
|
||||
#: src/components/ChangePasswordModal.tsx:56
|
||||
#: src/components/Modal.tsx:45
|
||||
#: src/components/Modal.tsx:58
|
||||
msgid "Close"
|
||||
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
|
||||
msgid "Color scheme"
|
||||
msgstr "Thème de couleur"
|
||||
@@ -291,10 +320,14 @@ msgstr "Impossible de changer le mot de passe"
|
||||
msgid "Could not load."
|
||||
msgstr "Impossible de charger."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:361
|
||||
#: src/pages/DumpEdit.tsx:370
|
||||
msgid "Could not save"
|
||||
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
|
||||
msgid "Create"
|
||||
msgstr "Créer"
|
||||
@@ -346,8 +379,8 @@ msgstr "Supprimer la catégorie"
|
||||
msgid "Delete category \"{0}\"? This cannot be undone."
|
||||
msgstr "Supprimer la catégorie \"{0}\" ? Cette action est irréversible."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:255
|
||||
#: src/pages/DumpEdit.tsx:456
|
||||
#: src/pages/DumpEdit.tsx:264
|
||||
#: src/pages/DumpEdit.tsx:465
|
||||
msgid "Delete dump"
|
||||
msgstr "Supprimer la reco"
|
||||
|
||||
@@ -361,7 +394,7 @@ msgstr "Supprimer la collection"
|
||||
msgid "Delete this comment?"
|
||||
msgstr "Supprimer ce commentaire ?"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:254
|
||||
#: src/pages/DumpEdit.tsx:263
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||
|
||||
@@ -383,7 +416,7 @@ msgstr "message supprimé"
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (facultatif)"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:488
|
||||
#: src/components/DumpCreateModal.tsx:741
|
||||
msgid "Done"
|
||||
msgstr "Terminé"
|
||||
|
||||
@@ -391,7 +424,11 @@ msgstr "Terminé"
|
||||
msgid "Drop a file here"
|
||||
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"
|
||||
msgstr "Déposez un fichier de remplacement ici"
|
||||
|
||||
@@ -399,11 +436,11 @@ msgstr "Déposez un fichier de remplacement ici"
|
||||
msgid "Dump"
|
||||
msgstr "Reco"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:458
|
||||
#: src/components/DumpCreateModal.tsx:958
|
||||
msgid "Dump it"
|
||||
msgstr "Recommander"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:469
|
||||
#: src/components/DumpCreateModal.tsx:718
|
||||
msgid "Dumped!"
|
||||
msgstr "Recommandé !"
|
||||
|
||||
@@ -426,7 +463,7 @@ msgstr "Plus tôt"
|
||||
#: src/components/ChatModal.tsx:172
|
||||
#: src/components/ChatModal.tsx:173
|
||||
#: src/components/CommentThread.tsx:367
|
||||
#: src/pages/Dump.tsx:517
|
||||
#: src/pages/Dump.tsx:518
|
||||
#: src/pages/PlaylistDetail.tsx:625
|
||||
msgid "Edit"
|
||||
msgstr "Modifier"
|
||||
@@ -436,7 +473,7 @@ msgstr "Modifier"
|
||||
msgid "Edit {0}"
|
||||
msgstr "Modifier {0}"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:420
|
||||
#: src/components/DumpCreateModal.tsx:197
|
||||
msgid "Edit title"
|
||||
msgstr "Modifier le titre"
|
||||
|
||||
@@ -445,7 +482,7 @@ msgstr "Modifier le titre"
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/ChatModal.tsx:152
|
||||
#: src/components/CommentThread.tsx:317
|
||||
#: src/pages/Dump.tsx:456
|
||||
#: src/pages/Dump.tsx:457
|
||||
#: src/pages/PlaylistDetail.tsx:664
|
||||
msgid "edited {0}"
|
||||
msgstr "modifié {0}"
|
||||
@@ -455,12 +492,12 @@ msgstr "modifié {0}"
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/ChatModal.tsx:150
|
||||
#: src/components/CommentThread.tsx:315
|
||||
#: src/pages/Dump.tsx:454
|
||||
#: src/pages/Dump.tsx:455
|
||||
#: src/pages/PlaylistDetail.tsx:661
|
||||
msgid "Edited {0}"
|
||||
msgstr "Modifié le {0}"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:206
|
||||
#: src/pages/DumpEdit.tsx:212
|
||||
msgid "Editing"
|
||||
msgstr "Modification"
|
||||
|
||||
@@ -477,6 +514,10 @@ msgstr "Adresse e-mail"
|
||||
msgid "Enter a query to search."
|
||||
msgstr "Saisissez une recherche."
|
||||
|
||||
#: src/components/GlobalPlayer.tsx:176
|
||||
msgid "Expand player"
|
||||
msgstr "Agrandir le lecteur"
|
||||
|
||||
#: src/components/CategoryManager.tsx:230
|
||||
msgid "Failed to create category"
|
||||
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"
|
||||
msgstr "Chargement échoué"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:337
|
||||
#: src/components/DumpCreateModal.tsx:750
|
||||
msgid "Failed to post"
|
||||
msgstr "Publication échouée"
|
||||
|
||||
@@ -544,19 +585,11 @@ msgstr "Erreur lors de la mise à jour du rôle"
|
||||
msgid "Feeds"
|
||||
msgstr "Flux"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:369
|
||||
#: src/components/DumpCreateModal.tsx:789
|
||||
msgid "Fetching preview…"
|
||||
msgstr "Récupération de l'aperçu…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:455
|
||||
msgid "Fetching…"
|
||||
msgstr "Récupération…"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:331
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:239
|
||||
#: src/components/DumpCreateModal.tsx:571
|
||||
msgid "File too large (max 50 MB)."
|
||||
msgstr "Fichier trop volumineux (max 50 Mo)."
|
||||
|
||||
@@ -632,7 +665,7 @@ msgstr "Tendances"
|
||||
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."
|
||||
|
||||
#: src/pages/Dump.tsx:551
|
||||
#: src/pages/Dump.tsx:552
|
||||
msgid "In collections"
|
||||
msgstr "Dans les collections"
|
||||
|
||||
@@ -658,6 +691,10 @@ msgstr "Invités"
|
||||
msgid "Journal"
|
||||
msgstr "Journal"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:687
|
||||
msgid "Keep editing"
|
||||
msgstr "Continuer l'édition"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1205
|
||||
msgid "Light"
|
||||
msgstr "Clair"
|
||||
@@ -666,6 +703,14 @@ msgstr "Clair"
|
||||
msgid "Like"
|
||||
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
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||
@@ -683,8 +728,8 @@ msgstr "Charger plus"
|
||||
msgid "Load older messages"
|
||||
msgstr "Charger les messages plus anciens"
|
||||
|
||||
#: src/pages/Dump.tsx:267
|
||||
#: src/pages/DumpEdit.tsx:157
|
||||
#: src/pages/Dump.tsx:268
|
||||
#: src/pages/DumpEdit.tsx:163
|
||||
msgid "Loading dump…"
|
||||
msgstr "Chargement de la reco…"
|
||||
|
||||
@@ -783,7 +828,7 @@ msgstr "nouveau"
|
||||
msgid "New"
|
||||
msgstr "Nouveau"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:301
|
||||
#: src/components/DumpCreateModal.tsx:659
|
||||
#: src/components/DumpFab.tsx:65
|
||||
#: src/components/DumpFab.tsx:66
|
||||
#: src/pages/UserDumps.tsx:88
|
||||
@@ -801,6 +846,14 @@ msgstr "Nouveau mot de passe"
|
||||
msgid "New playlist"
|
||||
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
|
||||
msgid "No dumps in this playlist yet."
|
||||
msgstr "Aucune reco dans cette collection pour l'instant."
|
||||
@@ -907,11 +960,20 @@ msgstr "Mot de passe mis à jour"
|
||||
msgid "Passwords do not match"
|
||||
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
|
||||
msgid "Playlist title"
|
||||
msgstr "Titre de la collection"
|
||||
|
||||
#: src/components/AppHeader.tsx:86
|
||||
#: src/components/DumpCreateModal.tsx:68
|
||||
#: src/components/UserMenu.tsx:62
|
||||
#: src/pages/Search.tsx:177
|
||||
#: src/pages/UserPlaylists.tsx:371
|
||||
@@ -925,10 +987,6 @@ msgstr "Collections"
|
||||
msgid "Playlists ({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
|
||||
msgid "Post comment"
|
||||
msgstr "Publier le commentaire"
|
||||
@@ -939,19 +997,24 @@ msgstr "Publier la réponse"
|
||||
|
||||
#: src/components/CommentThread.tsx:396
|
||||
#: src/components/CommentThread.tsx:473
|
||||
#: src/components/DumpCreateModal.tsx:951
|
||||
msgid "Posting…"
|
||||
msgstr "Publication…"
|
||||
|
||||
#: src/components/DumpCard.tsx:120
|
||||
#: src/components/JournalCard.tsx:121
|
||||
#: src/components/GlobalPlayer.tsx:154
|
||||
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/PlaylistMembershipPanel.tsx:55
|
||||
#: src/pages/Dump.tsx:462
|
||||
#: src/pages/Dump.tsx:463
|
||||
#: src/pages/PlaylistDetail.tsx:644
|
||||
msgid "private"
|
||||
msgstr "privé"
|
||||
|
||||
#: src/components/form/SegmentedField.tsx:78
|
||||
#: src/components/form/SegmentedField.tsx:81
|
||||
#: src/pages/PlaylistDetail.tsx:902
|
||||
msgid "Private"
|
||||
msgstr "Privé"
|
||||
@@ -961,16 +1024,20 @@ msgstr "Privé"
|
||||
msgid "public"
|
||||
msgstr "public"
|
||||
|
||||
#: src/components/form/SegmentedField.tsx:77
|
||||
#: src/components/form/SegmentedField.tsx:80
|
||||
#: src/pages/PlaylistDetail.tsx:895
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:235
|
||||
#: src/pages/DumpEdit.tsx:241
|
||||
msgid "Refresh metadata"
|
||||
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…"
|
||||
msgstr "Actualisation…"
|
||||
|
||||
@@ -988,7 +1055,7 @@ msgstr "Inscription…"
|
||||
msgid "Registration failed"
|
||||
msgstr "Inscription échouée"
|
||||
|
||||
#: src/pages/Dump.tsx:529
|
||||
#: src/pages/Dump.tsx:530
|
||||
msgid "Related"
|
||||
msgstr "Connexe"
|
||||
|
||||
@@ -1008,7 +1075,7 @@ msgstr "Retirer le j'aime"
|
||||
msgid "Remove vote"
|
||||
msgstr "Retirer le vote"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:420
|
||||
#: src/pages/DumpEdit.tsx:429
|
||||
msgid "Replace file"
|
||||
msgstr "Remplacer le fichier"
|
||||
|
||||
@@ -1034,13 +1101,18 @@ msgstr "Échec de la réinitialisation"
|
||||
msgid "Reset password"
|
||||
msgstr "Réinitialiser le mot de passe"
|
||||
|
||||
#: src/pages/DumpEdit.tsx:381
|
||||
#: src/pages/DumpEdit.tsx:399
|
||||
#: src/pages/DumpEdit.tsx:390
|
||||
#: src/pages/DumpEdit.tsx:408
|
||||
msgid "Reset to default"
|
||||
msgstr "Réinitialiser par défaut"
|
||||
|
||||
#: src/pages/Dump.tsx:284
|
||||
#: src/pages/DumpEdit.tsx:174
|
||||
#: src/components/DumpCreateModal.tsx:208
|
||||
#: 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"
|
||||
msgstr "Réessayer"
|
||||
|
||||
@@ -1050,8 +1122,8 @@ msgstr "Rôle"
|
||||
|
||||
#: src/components/ChatModal.tsx:222
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:395
|
||||
#: src/pages/DumpEdit.tsx:463
|
||||
#: src/pages/Dump.tsx:396
|
||||
#: src/pages/DumpEdit.tsx:472
|
||||
#: src/pages/PlaylistDetail.tsx:927
|
||||
#: src/pages/UserPublicProfile.tsx:1666
|
||||
#: src/pages/UserPublicProfile.tsx:1736
|
||||
@@ -1060,7 +1132,7 @@ msgstr "Enregistrer"
|
||||
|
||||
#: src/components/ChangePasswordModal.tsx:100
|
||||
#: src/components/CommentThread.tsx:329
|
||||
#: src/pages/Dump.tsx:394
|
||||
#: src/pages/Dump.tsx:395
|
||||
#: src/pages/PlaylistDetail.tsx:923
|
||||
#: src/pages/ResetPassword.tsx:126
|
||||
#: src/pages/UserPublicProfile.tsx:1663
|
||||
@@ -1124,6 +1196,10 @@ msgstr "identifiant"
|
||||
msgid "Something went wrong"
|
||||
msgstr "Une erreur est survenue"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:706
|
||||
msgid "Start fresh"
|
||||
msgstr "Repartir de zéro"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:1155
|
||||
msgid "Style"
|
||||
msgstr "Style"
|
||||
@@ -1152,17 +1228,17 @@ msgstr "Rien à voir, circulez."
|
||||
msgid "This reset link is missing or malformed."
|
||||
msgstr "Ce lien de réinitialisation est absent ou malformé."
|
||||
|
||||
#: src/pages/DumpEdit.tsx:365
|
||||
#: src/pages/DumpEdit.tsx:374
|
||||
msgid "Thumbnail"
|
||||
msgstr "Miniature"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:389
|
||||
#: src/components/DumpCreateModal.tsx:174
|
||||
#: src/components/PlaylistCreateForm.tsx:70
|
||||
#: src/pages/DumpEdit.tsx:389
|
||||
#: src/pages/DumpEdit.tsx:398
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:237
|
||||
#: src/components/DumpCreateModal.tsx:569
|
||||
msgid "Title is required."
|
||||
msgstr "Un titre est requis."
|
||||
|
||||
@@ -1191,7 +1267,12 @@ msgstr "Ne plus suivre la collection"
|
||||
msgid "Upload failed"
|
||||
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…"
|
||||
msgstr "Envoi…"
|
||||
|
||||
@@ -1209,12 +1290,11 @@ msgstr "Voté"
|
||||
msgid "Upvoted ({0}{1})"
|
||||
msgstr "Votés ({0}{1})"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:344
|
||||
#: src/pages/DumpEdit.tsx:412
|
||||
#: src/pages/DumpEdit.tsx:421
|
||||
msgid "URL"
|
||||
msgstr "URL"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:223
|
||||
#: src/components/DumpCreateModal.tsx:553
|
||||
msgid "URL is required."
|
||||
msgstr "L'URL est obligatoire."
|
||||
|
||||
@@ -1251,12 +1331,12 @@ msgstr "Utilisateurs"
|
||||
msgid "View all →"
|
||||
msgstr "Tout voir →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:471
|
||||
#: src/components/DumpCreateModal.tsx:720
|
||||
msgid "View dump →"
|
||||
msgstr "Voir la reco →"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:434
|
||||
#: src/pages/DumpEdit.tsx:437
|
||||
#: src/components/DumpCreateModal.tsx:904
|
||||
#: src/pages/DumpEdit.tsx:446
|
||||
msgid "What makes it worth it?"
|
||||
msgstr "Pourquoi on en voudrait ?"
|
||||
|
||||
@@ -1265,8 +1345,16 @@ msgstr "Pourquoi on en voudrait ?"
|
||||
msgid "Who am I?"
|
||||
msgstr "Qui suis-je ?"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:433
|
||||
#: src/pages/DumpEdit.tsx:436
|
||||
#: src/components/DumpCreateModal.tsx:920
|
||||
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?"
|
||||
msgstr "Pourquoi ?"
|
||||
|
||||
@@ -1279,6 +1367,10 @@ msgstr "Écrire une réponse…"
|
||||
msgid "Yesterday"
|
||||
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
|
||||
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."
|
||||
|
||||
33
src/model.ts
33
src/model.ts
@@ -41,7 +41,12 @@ export interface RichContent {
|
||||
siteName?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** A real preview image — never a favicon. Absent means "no artwork". */
|
||||
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;
|
||||
embedUrl?: string;
|
||||
}
|
||||
@@ -69,6 +74,32 @@ export interface 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 {
|
||||
return {
|
||||
...raw,
|
||||
@@ -666,6 +697,8 @@ export interface RegisterRequest {
|
||||
|
||||
export interface CreateUrlDumpRequest {
|
||||
url: string;
|
||||
/** Overrides the title scraped from the page — the poster edited the preview. */
|
||||
title?: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
categoryIds?: string[];
|
||||
|
||||
@@ -53,6 +53,7 @@ export function DumpEdit() {
|
||||
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||
const [thumbUploading, setThumbUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,15 +86,20 @@ export function DumpEdit() {
|
||||
if (state.status !== "loaded" || state.dump.kind !== "url") return;
|
||||
|
||||
setRefreshing(true);
|
||||
setRefreshError(null);
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const apiResponse = await res.json();
|
||||
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||
if (apiResponse.success) {
|
||||
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
|
||||
} else {
|
||||
setRefreshError(apiResponse.error.message);
|
||||
}
|
||||
} catch (err) {
|
||||
setRefreshError(friendlyFetchError(err));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -235,6 +241,9 @@ export function DumpEdit() {
|
||||
: <Trans>Refresh metadata</Trans>}
|
||||
</button>
|
||||
)}
|
||||
{refreshError && (
|
||||
<p className="dump-edit-refresh-error">{refreshError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DumpEditForm
|
||||
|
||||
@@ -424,6 +424,13 @@
|
||||
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 {
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -315,6 +315,20 @@
|
||||
[data-style="geocities"] .playlist-card-delete-btn,
|
||||
[data-style="geocities"] .emoji-picker-float [frimousse-emoji],
|
||||
[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"] .new-item-toggle,
|
||||
[data-style="geocities"] .audio-player-btn,
|
||||
@@ -482,6 +496,13 @@
|
||||
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 {
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,9 @@
|
||||
[data-style="nyt"] .global-player,
|
||||
[data-style="nyt"] .global-player-media-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"] .visibility-toggle,
|
||||
[data-style="nyt"] .feed-tab,
|
||||
@@ -587,10 +590,17 @@
|
||||
[data-style="nyt"] .rich-content-compact-icon,
|
||||
[data-style="nyt"] .journal-card-glyph,
|
||||
[data-style="nyt"] .fdz__file-icon,
|
||||
[data-style="nyt"] .thumb-placeholder-icon,
|
||||
[data-style="nyt"] .empty-state {
|
||||
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
|
||||
bottom rules read as one column). Even vertical padding per item. ── */
|
||||
[data-style="nyt"] .dump-feed {
|
||||
|
||||
78
src/utils/bandcamp.ts
Normal file
78
src/utils/bandcamp.ts
Normal 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
85
src/utils/dumpDraft.ts
Normal 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
7
src/utils/duration.ts
Normal 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")}`;
|
||||
}
|
||||
@@ -3,3 +3,14 @@ export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,12 @@ export interface JournalEntry {
|
||||
|
||||
/** A dump that can carry a real preview image (file image or rich thumbnail). */
|
||||
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;
|
||||
return !!dump.richContent?.thumbnailUrl;
|
||||
}
|
||||
|
||||
57
src/utils/proxyImage.ts
Normal file
57
src/utils/proxyImage.ts
Normal 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)}`;
|
||||
}
|
||||
34
src/utils/streamSources.ts
Normal file
34
src/utils/streamSources.ts
Normal 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;
|
||||
}
|
||||
68
src/utils/thumbnailTint.ts
Normal file
68
src/utils/thumbnailTint.ts
Normal 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();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { lingui } from "@lingui/vite-plugin";
|
||||
|
||||
const SITE_NAME = process.env.GERBEUR_SITE_NAME || "gerbeur";
|
||||
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
|
||||
// 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_EMOJI__", SITE_EMOJI)
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
|
||||
: html,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user