diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..4b71d2d --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get:1": { + "version": "1.0.17", + "resolved": "ghcr.io/devcontainers-extra/features/ffmpeg-apt-get@sha256:735491c8e9b2236cae727d6d4904663f6a58eb131d8c4b6b18fd0eea3d8c5b71", + "integrity": "sha256:735491c8e9b2236cae727d6d4904663f6a58eb131d8c4b6b18fd0eea3d8c5b71" + }, + "ghcr.io/warrenbuckley/codespace-features/sqlite:1": { + "version": "1.0.0", + "resolved": "ghcr.io/warrenbuckley/codespace-features/sqlite@sha256:4fdf50a6929f918c8208efc7416b6505f5ca085d71e37c3003367ed2855bbf76", + "integrity": "sha256:4fdf50a6929f918c8208efc7416b6505f5ca085d71e37c3003367ed2855bbf76" + } + } +} diff --git a/Dockerfile b/Dockerfile index 76af45b..cb073ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ── Stage 1: build Vite frontend ───────────────────────────────────────────── -FROM denoland/deno:2.7.11 AS builder +FROM denoland/deno:2.8.3 AS builder WORKDIR /app @@ -23,7 +23,7 @@ ENV VITE_API_PROTOCOL=$VITE_API_PROTOCOL \ RUN deno task build # ── Stage 2: runtime ────────────────────────────────────────────────────────── -FROM denoland/deno:alpine-2.7.11 +FROM denoland/deno:alpine-2.8.3 RUN apk add --no-cache ffmpeg curl diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 1afca17..8115b49 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -420,6 +420,7 @@ export function isCreateUrlDumpRequest( export interface UpdateDumpRequest { url?: string; + title?: string; comment?: string; isPrivate?: boolean; } @@ -428,6 +429,11 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; if ("url" in o && typeof o.url !== "string" && o.url !== null) return false; + if ("title" in o) { + if (typeof o.title !== "string") return false; + const trimmed = (o.title as string).trim(); + if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return false; + } if ( "comment" in o && typeof o.comment !== "string" && o.comment !== null ) return false; diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 08178a2..d3b335d 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -39,6 +39,7 @@ router.post( const formData = await ctx.request.body.formData(); const file = formData.get("file"); const comment = formData.get("comment"); + const title = formData.get("title"); const isPrivate = formData.get("isPrivate") === "true"; if (!(file instanceof File)) { @@ -54,6 +55,7 @@ router.post( typeof comment === "string" && comment ? comment : undefined, userId, isPrivate, + typeof title === "string" && title ? title : undefined, ); } else { const body = await ctx.request.body.json(); @@ -109,6 +111,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { const formData = await ctx.request.body.formData(); const file = formData.get("file"); const comment = formData.get("comment"); + const title = formData.get("title"); if (!(file instanceof File)) { throw new APIException( @@ -122,6 +125,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => { dumpId, file, typeof comment === "string" && comment ? comment : undefined, + typeof title === "string" && title ? title : undefined, ); const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index ae14416..05b9187 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -109,6 +109,7 @@ export async function createFileDump( comment: string | undefined, userId: string, isPrivate = false, + title?: string, ): Promise { if (!isAllowedMime(file.type)) { throw new APIException( @@ -127,7 +128,8 @@ export async function createFileDump( const dumpId = crypto.randomUUID(); const createdAt = new Date(); - const slug = makeSlug(file.name, dumpId); + const finalTitle = title?.trim() || file.name; + const slug = makeSlug(finalTitle, dumpId); await Deno.mkdir(DUMPS_DIR, { recursive: true }); const data = new Uint8Array(await file.arrayBuffer()); @@ -141,7 +143,7 @@ export async function createFileDump( ).run( dumpId, "file", - file.name, + finalTitle, slug, comment ?? null, userId, @@ -160,7 +162,7 @@ export async function createFileDump( const dump: Dump = { id: dumpId, kind: "file", - title: file.name, + title: finalTitle, slug, comment, userId, @@ -174,10 +176,10 @@ export async function createFileDump( }; if (!isPrivate) { broadcastNewDump(dump); - notifyUserFollowersNewDump(userId, dumpId, file.name); + notifyUserFollowersNewDump(userId, dumpId, finalTitle); } if (comment) { - notifyMentions(userId, comment, "dump", dumpId, file.name); + notifyMentions(userId, comment, "dump", dumpId, finalTitle); linkAttachments(comment, dumpId); } return dump; @@ -285,10 +287,14 @@ export async function updateDump( const now = new Date(); - // File dumps: only comment and isPrivate are editable + // File dumps: title, comment and isPrivate are editable if (dump.kind === "file") { + const title = request.title?.trim() || dump.title; + const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug; const updatedDump: Dump = { ...dump, + title, + slug, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment, @@ -298,8 +304,10 @@ export async function updateDump( updatedAt: now, }; db.prepare( - `UPDATE dumps SET comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, + `UPDATE dumps SET title = ?, slug = ?, comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`, ).run( + updatedDump.title, + updatedDump.slug ?? null, updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, now.toISOString(), @@ -334,7 +342,11 @@ export async function updateDump( title = richContent?.title ?? titleFromUrl(newUrl); } - const newSlug = makeSlug(title, dumpId); + if (request.title?.trim()) { + title = request.title.trim(); + } + + const newSlug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug; const updatedDump: Dump = { ...dump, title, @@ -387,6 +399,7 @@ export async function replaceFileDump( dumpId: string, file: File, comment: string | undefined, + title?: string, ): Promise { if (!isAllowedMime(file.type)) { throw new APIException( @@ -415,13 +428,16 @@ export async function replaceFileDump( await Deno.writeFile(filePath, data); const now = new Date(); - const newSlug = makeSlug(file.name, dumpId); + const finalTitle = title?.trim() || dump.title; + const newSlug = finalTitle !== dump.title + ? makeSlug(finalTitle, dumpId) + : dump.slug; try { db.prepare( `UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`, ).run( - file.name, - newSlug, + finalTitle, + newSlug ?? null, file.name, file.type, file.size, @@ -437,12 +453,12 @@ export async function replaceFileDump( } if (comment) { - notifyMentions(dump.userId, comment, "dump", dumpId, file.name); + notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle); linkAttachments(comment, dumpId); } return { ...dump, - title: file.name, + title: finalTitle, slug: newSlug, fileName: file.name, fileMime: file.type, diff --git a/api/services/user-service.ts b/api/services/user-service.ts index da4b39b..1599741 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -167,7 +167,7 @@ export async function updateUser( ); if (userResult.changes === 0) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); } if (updatedUser.description) { diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/App.css b/src/App.css index 902cc88..47aa639 100644 --- a/src/App.css +++ b/src/App.css @@ -128,6 +128,63 @@ word-break: break-word; } +.dump-title-view { + position: relative; + display: inline-flex; + align-items: baseline; + gap: 0.4rem; + border-radius: 6px; + max-width: 100%; +} + +.dump-title-view--editable { + cursor: pointer; +} + +.dump-title-view--editable:hover { + background: var(--color-surface); +} + +.dump-title-edit-btn { + font-size: 0.85rem; + color: var(--color-muted); + opacity: 0; + transition: opacity 0.1s; + pointer-events: none; +} + +.dump-title-view--editable:hover .dump-title-edit-btn { + opacity: 1; +} + +.dump-title-editor { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.dump-title-input { + flex: 1; + min-width: 12rem; + padding: 0.4rem 0.6rem; + border-radius: 8px; + border: 2px solid var(--color-border); + background-color: var(--color-bg); + color: var(--color-text); + font-size: 1.25rem; + font-weight: 700; + font-family: "Saira", sans-serif; + outline: none; +} + +.dump-title-error { + width: 100%; + margin: 0; + font-size: 0.85rem; + color: var(--color-danger); +} + .dump-comment { font-size: 1.05rem; line-height: 1.72; @@ -254,6 +311,32 @@ opacity: 0.6; } +.dump-create-title-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.dump-create-title-row input { + flex: 1; +} + +.dump-create-title-edit-btn { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + line-height: 1; + color: var(--color-muted); + border-radius: 4px; + padding: 0.4rem; + transition: color 0.15s; +} + +.dump-create-title-edit-btn:hover { + color: var(--color-text); +} + /* ── New dump form ── */ .dump-create-wrapper { width: 100%; diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 4c002a9..7c09197 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -88,6 +88,9 @@ export function DumpCreateModal( const [mode, setMode] = useState("url"); const [url, setUrl] = useState(initialUrl); const [file, setFile] = useState(null); + const [title, setTitle] = useState(""); + const [titleEditing, setTitleEditing] = useState(false); + const titleInputRef = useRef(null); const [comment, setComment] = useState(""); const [isPrivate, setIsPrivate] = useState(false); const [submitState, setSubmitState] = useState({ @@ -100,6 +103,12 @@ export function DumpCreateModal( const [memberships, setMemberships] = useState([]); const [playlistsLoading, setPlaylistsLoading] = useState(false); + const selectFile = (f: File | null) => { + setFile(f); + setTitle(f?.name ?? ""); + setTitleEditing(false); + }; + // Debounced URL preview useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); @@ -143,7 +152,7 @@ export function DumpCreateModal( setMode("file"); setUrl(""); setUrlPreview({ status: "idle" }); - setFile(pastedFile); + selectFile(pastedFile); setSubmitState({ status: "idle" }); return; } @@ -154,7 +163,7 @@ export function DumpCreateModal( const u = new URL(normalizeUrl(text)); if (u.protocol === "http:" || u.protocol === "https:") { setMode("url"); - setFile(null); + selectFile(null); setUrl(u.toString()); setSubmitState({ status: "idle" }); } @@ -206,6 +215,7 @@ export function DumpCreateModal( const formData = new FormData(); formData.append("file", file); if (comment.trim()) formData.append("comment", comment.trim()); + if (title.trim()) formData.append("title", title.trim()); formData.append("isPrivate", String(isPrivate)); res = await authFetch(`${API_URL}/api/dumps`, { method: "POST", @@ -287,7 +297,7 @@ export function DumpCreateModal( className={mode === "url" ? "active" : ""} onClick={() => { setMode("url"); - setFile(null); + selectFile(null); setSubmitState({ status: "idle" }); }} disabled={submitting} @@ -337,7 +347,7 @@ export function DumpCreateModal( setMode("file"); setUrl(""); setUrlPreview({ status: "idle" }); - setFile(pastedFile); + selectFile(pastedFile); setSubmitState({ status: "idle" }); } }} @@ -364,10 +374,46 @@ export function DumpCreateModal( <> {file && } + {file && ( +
+ +
+ setTitle(e.target.value)} + disabled={submitting || !titleEditing} + maxLength={VALIDATION.DUMP_TITLE_MAX} + required + /> + {!titleEditing && ( + + )} +
+
+ )} )} diff --git a/src/model.ts b/src/model.ts index 440d798..52b647f 100644 --- a/src/model.ts +++ b/src/model.ts @@ -504,6 +504,7 @@ export interface CreateUrlDumpRequest { export interface UpdateDumpRequest { url?: string; + title?: string; comment?: string; isPrivate?: boolean; } diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 45e0721..f45253e 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -5,7 +5,7 @@ import { Trans } from "@lingui/react/macro"; import { dumpUrl } from "../utils/urls.ts"; import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx"; -import { API_URL } from "../config/api.ts"; +import { API_URL, VALIDATION } from "../config/api.ts"; import type { Comment, @@ -13,6 +13,7 @@ import type { PublicUser, RawComment, RawDump, + UpdateDumpRequest, } from "../model.ts"; import { deserializeComment, @@ -54,7 +55,12 @@ export function Dump() { const [comments, setComments] = useState([]); - const { user, token } = useAuth(); + const [titleEditing, setTitleEditing] = useState(false); + const [titleDraft, setTitleDraft] = useState(""); + const [titleSaving, setTitleSaving] = useState(false); + const [titleError, setTitleError] = useState(null); + + const { user, token, authFetch } = useAuth(); const { voteCounts, myVotes, @@ -226,6 +232,41 @@ export function Dump() { const { dump } = dumpState; const canEdit = !!user && (dump.userId === user.id || user.isAdmin === true); + const handleTitleSave = async () => { + const trimmed = titleDraft.trim(); + if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return; + + setTitleSaving(true); + setTitleError(null); + try { + const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, { + method: "PUT", + body: JSON.stringify( + { title: trimmed } satisfies UpdateDumpRequest, + ), + }); + const apiResponse = parseAPIResponse(await res.json()); + if (!apiResponse.success) { + setTitleError(apiResponse.error.message); + return; + } + const updatedDump = deserializeDump(apiResponse.data); + setTitleEditing(false); + if (updatedDump.slug !== dump.slug) { + navigate(dumpUrl(updatedDump), { + replace: true, + state: { dump: updatedDump }, + }); + } else { + setDumpState({ status: "loaded", dump: updatedDump }); + } + } catch (err) { + setTitleError(friendlyFetchError(err)); + } finally { + setTitleSaving(false); + } + }; + return (
@@ -252,7 +293,68 @@ export function Dump() { )}
-

{dump.title}

+ {titleEditing + ? ( +
+ setTitleDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleTitleSave(); + } else if (e.key === "Escape") { + setTitleEditing(false); + } + }} + maxLength={VALIDATION.DUMP_TITLE_MAX} + disabled={titleSaving} + autoFocus + /> + + + {titleError && ( +

{titleError}

+ )} +
+ ) + : ( +
{ + setTitleDraft(dump.title); + setTitleError(null); + setTitleEditing(true); + } + : undefined} + > +

{dump.title}

+ {canEdit && ( + + ✎ + + )} +
+ )}
({ status: "loading" }); const [url, setUrl] = useState(""); + const [title, setTitle] = useState(""); const [comment, setComment] = useState(""); const [isPrivate, setIsPrivate] = useState(false); const [newFile, setNewFile] = useState(null); @@ -52,6 +53,7 @@ export function DumpEdit() { if (apiResponse.success) { const dump: Dump = deserializeDump(apiResponse.data); setUrl(dump.url ?? ""); + setTitle(dump.title); setComment(dump.comment ?? ""); setIsPrivate(dump.isPrivate); setState({ status: "loaded", dump }); @@ -65,8 +67,11 @@ export function DumpEdit() { }, [selectedDump, token]); const handleSave = async () => { + const trimmedTitle = title.trim(); if ( - state.status !== "loaded" || comment.length > VALIDATION.DUMP_COMMENT_MAX + state.status !== "loaded" || + comment.length > VALIDATION.DUMP_COMMENT_MAX || + !trimmedTitle || trimmedTitle.length > VALIDATION.DUMP_TITLE_MAX ) return; let res: Response; @@ -74,6 +79,7 @@ export function DumpEdit() { if (state.dump.kind === "file" && newFile) { const formData = new FormData(); formData.append("file", newFile); + formData.append("title", trimmedTitle); if (comment.trim()) formData.append("comment", comment.trim()); res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}/file`, { method: "PUT", @@ -83,10 +89,11 @@ export function DumpEdit() { const body: UpdateDumpRequest = state.dump.kind === "url" ? { url: url.trim() || undefined, + title: trimmedTitle, comment: comment.trim() || undefined, isPrivate, } - : { comment: comment.trim() || undefined, isPrivate }; + : { title: trimmedTitle, comment: comment.trim() || undefined, isPrivate }; res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, { method: "PUT", body: JSON.stringify(body), @@ -223,6 +230,20 @@ export function DumpEdit() { handleSave(); }} > +
+ + setTitle(e.currentTarget.value)} + maxLength={VALIDATION.DUMP_TITLE_MAX} + required + /> +
+ {dump.kind === "url" ? (
@@ -301,7 +322,9 @@ export function DumpEdit() {