diff --git a/api/db/migrate.ts b/api/db/migrate.ts index dd84913..f518031 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -11,6 +11,7 @@ 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; @@ -36,6 +37,7 @@ const MIGRATIONS: Migration[] = [ name: "0012_fix_favicon_reclassification", up: up0012FixFaviconReclassification, }, + { name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0013_dump_url_canonical.ts b/api/db/migrations/0013_dump_url_canonical.ts new file mode 100644 index 0000000..6539f03 --- /dev/null +++ b/api/db/migrations/0013_dump_url_canonical.ts @@ -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); + } +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 86e4375..9daf721 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -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); diff --git a/api/lib/canonical-url.ts b/api/lib/canonical-url.ts new file mode 100644 index 0000000..0f803f0 --- /dev/null +++ b/api/lib/canonical-url.ts @@ -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}`; +} diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 681cb11..d3eaafb 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -479,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 { @@ -499,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; diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index ff1363e..5b8dbc0 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -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 = { + 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); diff --git a/api/routes/preview.ts b/api/routes/preview.ts index 9f6e6d3..48ed5b2 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -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 }, + }; }); /** diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 2695cf7..7a40352 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -3,6 +3,7 @@ import { APIException, type CreateUrlDumpRequest, type Dump, + type DumpUrlMatch, type UpdateDumpRequest, } from "../model/interfaces.ts"; import { @@ -28,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, @@ -63,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", @@ -79,6 +84,7 @@ export async function createUrlDump( userId, createdAt.toISOString(), request.url, + canonicalizeUrl(request.url), richContent ? JSON.stringify(richContent) : null, isPrivate ? 1 : 0, ); @@ -294,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, @@ -387,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(), diff --git a/src/App.css b/src/App.css index 0d1edbb..e630cb5 100644 --- a/src/App.css +++ b/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%; diff --git a/src/App.tsx b/src/App.tsx index f118797..563419a 100644 --- a/src/App.tsx +++ b/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 ; + const { search } = useLocation(); + return ; } // Bare `/` lands on that category's default feed tab. function CategoryRedirect() { const { categorySlug } = useParams(); - return ; + const { search } = useLocation(); + return ( + + ); } // Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed. diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 4a628ef..4bfcadd 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -1,31 +1,48 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { UseFormRegisterReturn } from "react-hook-form"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; +import { Plural, Trans } from "@lingui/react/macro"; import { API_URL, VALIDATION } from "../config/api.ts"; import type { CreateUrlDumpRequest, Dump, + DumpUrlMatch, PlaylistMembership, RawDump, + RawDumpUrlMatch, RawPlaylistMembership, + UrlPreviewResponse, +} from "../model.ts"; +import { + deserializeDump, + deserializeDumpUrlMatch, + deserializePlaylistMembership, } from "../model.ts"; -import { deserializeDump, deserializePlaylistMembership } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { dumpUrl, normalizeUrl } from "../utils/urls.ts"; +import { relativeTime } from "../utils/relativeTime.ts"; +import { titleFromFileName } from "../utils/format.ts"; +import { + clearDumpDraft, + type DumpDraft, + EMPTY_DUMP_DRAFT, + loadDumpDraft, + saveDumpDraft, +} from "../utils/dumpDraft.ts"; import { MAX_FILE_SIZE } from "../config/upload.ts"; import RichContentCard from "./RichContentCard.tsx"; import { MediaPlayer } from "./MediaPlayer.tsx"; import type { RichContent } from "../model.ts"; import { Modal } from "./Modal.tsx"; +import type { TextEditorHandle } from "./TextEditor.tsx"; import { CategorySelect } from "./CategorySelect.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; import { expectOk, FileField, - FormActions, FormError, FormProvider, RichTextField, @@ -34,13 +51,56 @@ import { VisibilityToggle, } from "./form/index.ts"; -type Mode = "url" | "file"; -type Phase = "create" | "playlist"; +/** + * Posting a dump walks three panels. The split is deliberate: categories and + * visibility used to sit behind a disclosure, where they read as optional and + * went untouched — they are neither, so "why & where" is a stop on the way to + * posting rather than something to go looking for. + */ +type Step = "what" | "details" | "playlist"; + +/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */ +const NO_DUPLICATES: DumpUrlMatch[] = []; + +const STEPS: { id: Step; label: () => string }[] = [ + { id: "what", label: () => t`Link or file` }, + { id: "details", label: () => t`Why & where` }, + { id: "playlist", label: () => t`Playlists` }, +]; + +/** Where you are in the three panels — and, past the first, how far you got. */ +function StepIndicator({ current }: { current: Step }) { + const currentIndex = STEPS.findIndex((s) => s.id === current); + + return ( +
    + {STEPS.map((step, i) => { + const state = i < currentIndex + ? "done" + : i === currentIndex + ? "current" + : "todo"; + return ( +
  1. + + {step.label()} +
  2. + ); + })} +
+ ); +} type UrlPreview = | { status: "idle" } | { status: "loading" } - | { status: "done"; richContent: RichContent | null }; + | { status: "done"; reached: boolean; richContent: RichContent | null }; interface CreateValues { url: string; @@ -50,6 +110,15 @@ interface CreateValues { isPublic: boolean; } +/** + * Whether the pointer is coarse — a touch device, where autofocusing the URL + * input raises a keyboard that covers most of the modal before the user has + * seen it. + */ +function prefersNoAutoFocus(): boolean { + return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false; +} + function LocalFilePreview({ file }: { file: File }) { const [src, setSrc] = useState(null); @@ -77,6 +146,118 @@ function LocalFilePreview({ file }: { file: File }) { return null; } +/** + * Title row shared by both kinds of dump: the suggested title (scraped from the + * page, or derived from the filename) sits there read-only until the ✎ is used, + * which is enough to stop stray typing while keeping a correction one click + * away. Once edited, a reset offers the suggestion back. + */ +function TitleField({ + registration, + editing, + onEdit, + onReset, + disabled, +}: { + registration: UseFormRegisterReturn<"title">; + editing: boolean; + onEdit: () => void; + onReset?: () => void; + disabled: boolean; +}) { + const inputRef = useRef(null); + const { ref, ...titleReg } = registration; + + return ( +
+ +
+ { + ref(el); + inputRef.current = el; + }} + disabled={disabled || !editing} + maxLength={VALIDATION.DUMP_TITLE_MAX} + /> + {!editing && ( + + )} + {editing && onReset && ( + + )} +
+
+ ); +} + +/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */ +function DuplicateNotice( + { matches, onNavigate }: { + matches: DumpUrlMatch[]; + onNavigate: () => void; + }, +) { + const [first, ...rest] = matches; + + return ( +
+ +
+

+ + Already dumped by {first.username} {relativeTime(first.createdAt)} + +

+ + {first.title} + + {rest.length > 0 && ( +

+ +

+ )} +

+ You can post it anyway. +

+
+
+ ); +} + interface DumpCreateModalProps { onClose: () => void; initialUrl?: string; @@ -85,37 +266,69 @@ interface DumpCreateModalProps { export function DumpCreateModal( { onClose, initialUrl = "" }: DumpCreateModalProps, ) { - const { authFetch } = useAuth(); + const { authFetch, authUpload } = useAuth(); const { injectDump } = useWS(); - const [phase, setPhase] = useState("create"); + const [step, setStep] = useState("what"); + // Which way the next panel should come in from. Purely presentational. + const [direction, setDirection] = useState<"forward" | "back">("forward"); const [createdDump, setCreatedDump] = useState(null); - // Create phase state - const [mode, setMode] = useState("url"); + const goToStep = (next: Step, dir: "forward" | "back") => { + setDirection(dir); + setStep(next); + }; + + // Arriving with a URL (the Android share sheet) is a request to post *that*, + // so an abandoned draft is dropped whole rather than half-merged: pairing + // someone's old "why" with a link they just shared from another app would be + // more confusing than starting them clean. + const [restoredDraft] = useState(() => + initialUrl ? null : loadDumpDraft() + ); + const draft = restoredDraft ?? EMPTY_DUMP_DRAFT; + const [draftNoticeVisible, setDraftNoticeVisible] = useState(!!restoredDraft); + + // Composition state const [titleEditing, setTitleEditing] = useState(false); - const titleInputRef = useRef(null); + const [titleFromUser, setTitleFromUser] = useState(draft.titleFromUser); const [urlFetchState, setUrlFetchState] = useState({ status: "idle", }); + // Tagged with the URL they describe, so a result never lingers over the next + // link during the debounce (and so clearing them needs no effect). + const [duplicateState, setDuplicateState] = useState< + { url: string; matches: DumpUrlMatch[] } + >({ url: "", matches: [] }); + const [uploadProgress, setUploadProgress] = useState(); + const [confirmClose, setConfirmClose] = useState(false); const debounceRef = useRef | null>(null); + const commentEditorRef = useRef(null); const form = useApiForm({ defaultValues: { - url: initialUrl, + url: initialUrl || draft.url, file: null, - title: "", - comment: "", - isPublic: true, + title: draft.title, + comment: draft.comment, + isPublic: draft.isPublic, }, }); - const { register, setValue, watch, clearErrors } = form; + const { register, setValue, watch, clearErrors, reset } = form; const submitting = form.formState.isSubmitting; const url = watch("url"); const file = watch("file"); + const title = watch("title"); + const comment = watch("comment"); + const isPublic = watch("isPublic"); + + // The dump's kind is whatever the user actually provided, so there is no mode + // to pick: attaching a file makes it a file dump, typing a link makes it a + // link dump, and clearing either offers both again. + const mode: "url" | "file" = file ? "file" : "url"; const [selectedCategoryIds, setSelectedCategoryIds] = useState>( - new Set(), + () => new Set(draft.categoryIds), ); const toggleCategory = (id: string) => setSelectedCategoryIds((prev) => { @@ -124,15 +337,18 @@ export function DumpCreateModal( return next; }); - // Playlist phase state + // Playlist step state const [memberships, setMemberships] = useState([]); const [playlistsLoading, setPlaylistsLoading] = useState(false); - const selectFile = (f: File | null) => { + const selectFile = useCallback((f: File | null) => { setValue("file", f); - setValue("title", f?.name ?? ""); + if (f) { + setValue("title", titleFromFileName(f.name)); + setTitleFromUser(false); + } setTitleEditing(false); - }; + }, [setValue]); // Canonical form of `url`, used (rather than the raw input text) to decide // whether the preview actually needs to re-fetch — e.g. "example.com" and @@ -154,7 +370,12 @@ export function DumpCreateModal( ? urlFetchState : { status: "idle" }; - // Debounced URL preview + const duplicates = canonicalUrl && duplicateState.url === canonicalUrl + ? duplicateState.matches + : NO_DUPLICATES; + + // Debounced preview + "already dumped?" lookup. Both answer questions about + // the same link and settle at the same time, so they share one debounce. useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); if (!canonicalUrl) return; @@ -165,23 +386,114 @@ export function DumpCreateModal( queueMicrotask(() => setUrlFetchState({ status: "loading" })); debounceRef.current = setTimeout(() => { - fetch(`${API_URL}/api/preview?url=${encodeURIComponent(canonicalUrl)}`) + const encoded = encodeURIComponent(canonicalUrl); + + fetch(`${API_URL}/api/preview?url=${encoded}`) .then((res) => res.json()) .then((body) => { + const data = body.success + ? body.data as UrlPreviewResponse + : undefined; setUrlFetchState({ status: "done", - richContent: body.success ? body.data : null, + reached: data?.reached ?? false, + richContent: data?.richContent ?? null, }); }) .catch(() => { - setUrlFetchState({ status: "done", richContent: null }); + setUrlFetchState({ status: "done", reached: false, richContent: null }); }); + + // Signed in, so that a private dump of the poster's own counts as a + // duplicate for them and for nobody else. + authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`) + .then((res) => res.json()) + .then((body) => { + setDuplicateState({ + url: canonicalUrl, + matches: body.success + ? (body.data as RawDumpUrlMatch[]).map(deserializeDumpUrlMatch) + : [], + }); + }) + .catch(() => + setDuplicateState({ url: canonicalUrl, matches: [] }) + ); }, 600); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [canonicalUrl]); + }, [canonicalUrl, authFetch]); + + // Until the poster takes the field over, the title *is* the current page's + // title — including when the new link suggests nothing, which is why this + // writes an empty suggestion too rather than leaving the previous link's. + const suggestedTitle = urlPreview.status === "done" + ? urlPreview.richContent?.title ?? "" + : ""; + useEffect(() => { + if (mode !== "url" || titleFromUser) return; + setValue("title", suggestedTitle); + }, [mode, titleFromUser, suggestedTitle, setValue]); + + // Mirror the draft on every change so a dismissal — deliberate or not — never + // costs more than the attached file, which cannot be stored. + useEffect(() => { + if (step === "playlist") return; + saveDumpDraft({ + url, + title, + titleFromUser, + comment, + isPublic, + categoryIds: [...selectedCategoryIds], + }); + }, [ + step, + url, + title, + titleFromUser, + comment, + isPublic, + selectedCategoryIds, + ]); + + const discardDraft = () => { + clearDumpDraft(); + reset({ + url: "", + file: null, + title: "", + comment: "", + isPublic: true, + }); + setSelectedCategoryIds(new Set()); + setTitleFromUser(false); + setTitleEditing(false); + setDraftNoticeVisible(false); + }; + + // Land the caret in the "why" box on arrival, so the second panel is + // immediately typeable — except on touch, where it would raise a keyboard + // over the categories this panel exists to show. + useEffect(() => { + if (step !== "details" || prefersNoAutoFocus()) return; + commentEditorRef.current?.focus(); + }, [step]); + + // Only the attached file is unrecoverable, so it is the only thing worth + // interrupting a dismissal for — the text comes back on its own. + const handleBeforeClose = useCallback(() => { + if (step === "playlist" || submitting || !file || confirmClose) return true; + setConfirmClose(true); + return false; + }, [step, submitting, file, confirmClose]); + + /** Dismissal from inside the form (Cancel), guarded like Escape and the ✕. */ + const requestClose = () => { + if (handleBeforeClose()) onClose(); + }; // Paste handler useEffect(() => { @@ -194,7 +506,6 @@ export function DumpCreateModal( if (tag === "INPUT" || tag === "TEXTAREA") return; const pastedFile = e.clipboardData?.files[0]; if (pastedFile) { - setMode("file"); setValue("url", ""); selectFile(pastedFile); clearErrors("root"); @@ -204,7 +515,6 @@ export function DumpCreateModal( try { const u = new URL(normalizeUrl(text)); if (u.protocol === "http:" || u.protocol === "https:") { - setMode("url"); selectFile(null); setValue("url", u.toString()); clearErrors("root"); @@ -213,8 +523,24 @@ export function DumpCreateModal( }; globalThis.addEventListener("paste", handler); return () => globalThis.removeEventListener("paste", handler); - // setValue/clearErrors are stable RHF refs; selectFile closes over them. - }, []); + }, [selectFile, setValue, clearErrors]); + + const [dragging, setDragging] = useState(false); + + // Dropping is accepted over the whole modal, not just the drop zone: once a + // link is being typed the zone is hidden, and aiming at it was never the + // point of dragging a file into a "new dump" dialog. + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + if (submitting) return; + const dropped = e.dataTransfer.files[0]; + if (dropped) { + setValue("url", ""); + selectFile(dropped); + clearErrors("root"); + } + }; const onSubmit = form.submit(async ( { url, file, title, comment, isPublic }, @@ -222,12 +548,15 @@ export function DumpCreateModal( const isPrivate = !isPublic; let res: Response; - if (mode === "url") { + if (!file) { const normalizedUrl = normalizeUrl(url); if (!normalizedUrl) throw new Error(t`URL is required.`); setValue("url", normalizedUrl); const body: CreateUrlDumpRequest = { url: normalizedUrl, + // Only when the poster typed it: otherwise the server would be pinned + // to whatever the preview happened to scrape a moment ago. + title: titleFromUser && title.trim() ? title.trim() : undefined, comment: comment.trim() || undefined, isPrivate, categoryIds: [...selectedCategoryIds], @@ -237,7 +566,6 @@ export function DumpCreateModal( body: JSON.stringify(body), }); } else { - if (!file) throw new Error(t`Please select a file.`); if (!title.trim()) throw new Error(t`Title is required.`); if (file.size > MAX_FILE_SIZE) { throw new Error(t`File too large (max 50 MB).`); @@ -248,16 +576,23 @@ export function DumpCreateModal( if (title.trim()) formData.append("title", title.trim()); formData.append("isPrivate", String(isPrivate)); formData.append("categoryIds", JSON.stringify([...selectedCategoryIds])); - res = await authFetch(`${API_URL}/api/dumps`, { - method: "POST", - body: formData, - }); + setUploadProgress(0); + try { + res = await authUpload( + `${API_URL}/api/dumps`, + formData, + setUploadProgress, + ); + } finally { + setUploadProgress(undefined); + } } const dump = deserializeDump(await expectOk(res)); + clearDumpDraft(); injectDump(dump); setCreatedDump(dump); - setPhase("playlist"); + goToStep("playlist", "forward"); setPlaylistsLoading(true); authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`) .then((r) => r.json()) @@ -300,201 +635,334 @@ export function DumpCreateModal( } }; + const canProceed = !!file || !!canonicalUrl; + + // What the second panel's choices apply to, in one line. + const recap = title.trim() || file?.name || normalizeUrl(url); + const uploadPercentLabel = t`Uploading ${ + Math.round((uploadProgress ?? 0) * 100) + }%`; + + // Enter in the link field advances rather than posts, so the panels behave + // like a form the whole way through. + const onFormSubmit = (e: React.FormEvent) => { + if (step === "what") { + e.preventDefault(); + if (canProceed) goToStep("details", "forward"); + return; + } + void onSubmit(e); + }; + return ( - {phase === "create" - ? ( - <> -
- -
+ + )} - -
- + {draftNoticeVisible && step === "what" && ( +
+ + Picked up where you left off. + + +
+ )} - {mode === "url" - ? ( - <> -
- - - setValue("url", normalizeUrl(e.target.value))} - onPaste={(e) => { - const pastedFile = e.clipboardData.files[0]; - if (pastedFile) { - e.preventDefault(); - setMode("file"); - setValue("url", ""); - selectFile(pastedFile); - clearErrors("root"); - } - }} - disabled={submitting} - placeholder="https://..." - autoFocus - /> -
- {urlPreview.status === "loading" && ( -

- Fetching preview… -

- )} - {urlPreview.status === "done" && - urlPreview.richContent && ( - - )} - - ) - : ( - <> - - name="file" - onValueChange={selectFile} - disabled={submitting} - /> - {file && } - {file && ( -
- -
- {(() => { - const { ref, ...titleReg } = register("title"); - return ( - { - ref(el); - titleInputRef.current = el; - }} - disabled={submitting || !titleEditing} - maxLength={VALIDATION.DUMP_TITLE_MAX} - /> - ); - })()} - {!titleEditing && ( - - )} -
-
- )} - - )} + - - name="comment" - label={t`Why?`} - placeholder={t`What makes it worth it?`} - rows={3} - maxLength={VALIDATION.DUMP_COMMENT_MAX} - rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }} - disabled={submitting} - /> + {step === "playlist" + ? ( +
+ {createdDump && ( +

+ Dumped!{" "} + + View dump → + +

+ )} - + + setMemberships((prev) => [membership, ...prev])} + /> - - name="isPublic" - disabled={submitting} - /> - - - Fetching… - : Uploading…} +
+
+ + Done + +
- - )} + ) + : ( + +
+ + + {/* Keyed so each panel re-enters from the side it came from. */} +
+ {step === "what" + ? ( + <> + {mode === "url" && ( + <> +
+ + + setValue("url", normalizeUrl(e.target.value))} + onPaste={(e) => { + const pastedFile = e.clipboardData.files[0]; + if (pastedFile) { + e.preventDefault(); + setValue("url", ""); + selectFile(pastedFile); + clearErrors("root"); + } + }} + disabled={submitting} + placeholder={t`Paste a link…`} + autoFocus={!prefersNoAutoFocus()} + /> +
+ + {urlPreview.status === "loading" && ( +

+ Fetching preview… +

+ )} + + {urlPreview.status === "done" && ( + <> + {urlPreview.richContent && ( + + )} + {!urlPreview.reached && ( +

+ + Couldn't load a preview for this link — + check it, or post it as-is and refresh the + preview later. + +

+ )} + + )} + + {duplicates.length > 0 && ( + + )} + + {urlPreview.status === "done" && ( + { + setTitleEditing(true); + setTitleFromUser(true); + }} + onReset={titleFromUser && suggestedTitle + ? () => { + setTitleFromUser(false); + setTitleEditing(false); + setValue("title", suggestedTitle); + } + : undefined} + disabled={submitting} + /> + )} + + )} + + {/* Hidden only while a link is being written: an empty + panel offers both ways in at once, no mode to pick. */} + {!(mode === "url" && url.trim()) && ( + + name="file" + onValueChange={selectFile} + disabled={submitting} + hint={t`Drop a file here, or paste one`} + /> + )} + + {file && ( + <> + + { + setTitleEditing(true); + setTitleFromUser(true); + }} + disabled={submitting} + /> + + )} + + ) + : ( + <> + {/* The link itself is a panel back, so name the thing + these choices are about. */} +

+ {" "} + {recap} +

+ + {uploadProgress !== undefined && ( +
+
+
+ )} + + + name="comment" + label={t`Why?`} + placeholder={t`What makes it worth it?`} + rows={3} + maxLength={VALIDATION.DUMP_COMMENT_MAX} + rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }} + disabled={submitting} + editorRef={commentEditorRef} + /> + + + + + name="isPublic" + label={t`Who can see it`} + disabled={submitting} + /> + + )} +
+ +
+ {step === "details" && ( + + )} +
+ {step === "what" && ( + + )} + Posting… + : typeof uploadProgress === "number" + ? uploadPercentLabel + : Uploading…} + > + {step === "what" + ? Next → + : Dump it} + +
+
+ + + )} +
); } diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 031b640..d2a5b61 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -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(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(
{ - if (e.target === backdropRef.current) onClose(); + if (e.target === backdropRef.current) requestClose(); }} >
@@ -41,7 +54,7 @@ export function Modal({ title, onClose, children, wide = false }: ModalProps) {