T-HUB/apps/web/src/components/compose/attachments.tsx
glass beadian 1183d69849 feat: compose media attachments UI + video producer panel (t2+t5)
Compose: upload picked files to /media with progress, thumbnails via the
auth-scoped serve route, per-attachment alt text, remove, ≤4 cap with the
Mastodon norm hint, in-flight blocking. Video drawer: Producer section —
lower-third editor, logo key field, MediaRecorder voiceover (record/
stop/review/re-take/upload), produce submission with lane + label, job
progress, derived-asset link. Local types for both contracts.
2026-10-14 20:14:20 +00:00

235 lines
8.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { errorMessage } from "../../lib/api-client";
import { MEDIA_MAX_ATTACHMENTS, fetchMediaObjectUrl, uploadMediaAsset } from "../../lib/media-types";
/**
* One picked file on its way to POST /media. `previewUrl` is the asset's bytes
* fetched with auth and rewrapped as an object URL (never a bare asset URL,
* which the browser cannot authorize) — revoke it on remove/unmount.
*/
export interface ComposeAttachment {
key: string;
file: File;
name: string;
kind: "image" | "video";
progress: number;
assetId: string | null;
previewUrl: string | null;
alt: string;
error: string | null;
}
function kindOf(mime: string): "image" | "video" | null {
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
return null;
}
/** Attach state + upload lifecycle for the compose surface. */
export function useComposeAttachments(): {
attachments: ComposeAttachment[];
warning: string | null;
uploading: boolean;
full: boolean;
mediaAssetIds: string[];
altTexts: Array<string | null>;
addFiles: (files: FileList | File[]) => void;
setAlt: (key: string, alt: string) => void;
remove: (key: string) => void;
retry: (key: string) => void;
} {
const [attachments, setAttachments] = useState<ComposeAttachment[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
// Every object URL minted here must die with the page, not only on a remove click.
useEffect(
() => () => {
attachmentsRef.current.forEach((item) => {
if (item.previewUrl !== null) URL.revokeObjectURL(item.previewUrl);
});
},
[],
);
const patch = useCallback((key: string, changes: Partial<ComposeAttachment>): void => {
setAttachments((current) => current.map((item) => (item.key === key ? { ...item, ...changes } : item)));
}, []);
const upload = useCallback(
async (key: string, file: File): Promise<void> => {
try {
const asset = await uploadMediaAsset(file, (percent) => patch(key, { progress: percent }));
let previewUrl: string | null = null;
try {
previewUrl = await fetchMediaObjectUrl(asset.id);
} catch {
// The asset is attached and postable even if its thumbnail cannot load.
}
patch(key, { assetId: asset.id, previewUrl, progress: 100, error: null });
} catch (err) {
patch(key, { error: errorMessage(err) });
}
},
[patch],
);
/** Picks files, enforcing the ≤4 / 1-video cap before anything is uploaded. */
const addFiles = useCallback(
(files: FileList | File[]): void => {
const picked = Array.from(files);
if (picked.length === 0) return;
// Failed picks do not hold a slot: they are removed or retried, not counted.
const held = attachmentsRef.current.filter((item) => item.error === null);
const accepted: Array<{ file: File; kind: "image" | "video" }> = [];
const rejected: string[] = [];
for (const file of picked) {
const kind = kindOf(file.type);
if (kind === null) {
rejected.push(`${file.name}: only images and video can be attached`);
continue;
}
const next = [...held.map((item) => item.kind), ...accepted.map((entry) => entry.kind)];
if (next.length + 1 > MEDIA_MAX_ATTACHMENTS) {
rejected.push(`${file.name}: at most ${MEDIA_MAX_ATTACHMENTS} attachments per post`);
continue;
}
if (next.includes("video") || (kind === "video" && next.length > 0)) {
rejected.push(`${file.name}: a video must be the only attachment`);
continue;
}
accepted.push({ file, kind });
}
setWarning(rejected.length > 0 ? rejected.join(" · ") : null);
if (accepted.length === 0) return;
const entries: ComposeAttachment[] = accepted.map(({ file, kind }) => ({
key: `${file.name}:${file.size}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`,
file,
name: file.name,
kind,
progress: 0,
assetId: null,
previewUrl: null,
alt: "",
error: null,
}));
setAttachments((current) => [...current, ...entries]);
entries.forEach((entry) => {
void upload(entry.key, entry.file);
});
},
[upload],
);
const setAlt = useCallback(
(key: string, alt: string): void => {
patch(key, { alt });
},
[patch],
);
const remove = useCallback((key: string): void => {
setAttachments((current) => {
const victim = current.find((item) => item.key === key);
if (victim !== undefined && victim.previewUrl !== null) URL.revokeObjectURL(victim.previewUrl);
const rest = current.filter((item) => item.key !== key);
if (rest.length === 0) setWarning(null);
return rest;
});
}, []);
const retry = useCallback(
(key: string): void => {
const victim = attachmentsRef.current.find((item) => item.key === key);
if (victim === undefined || victim.error === null) return;
patch(key, { error: null, progress: 0 });
void upload(key, victim.file);
},
[patch, upload],
);
const settled = attachments.filter((item) => item.error === null && item.assetId !== null);
const held = attachments.filter((item) => item.error === null);
return {
attachments,
warning,
/** True while any pick is still in flight — posting must wait for these. */
uploading: attachments.some((item) => item.error === null && item.assetId === null),
full: held.length >= MEDIA_MAX_ATTACHMENTS || held.some((item) => item.kind === "video"),
mediaAssetIds: settled.map((item) => item.assetId as string),
altTexts: settled.map((item) => (item.alt.trim() === "" ? null : item.alt.trim())),
addFiles,
setAlt,
remove,
retry,
};
}
/** Thumbnail strip shared by the mobile composer and the desktop compose column. */
export function AttachmentsRow({
attachments,
onAltChange,
onRemove,
onRetry,
}: {
attachments: ComposeAttachment[];
onAltChange: (key: string, alt: string) => void;
onRemove: (key: string) => void;
onRetry: (key: string) => void;
}): React.ReactElement | null {
if (attachments.length === 0) return null;
return (
<div className="mt-2 flex gap-2 overflow-x-auto">
{attachments.map((item) => {
const pending = item.assetId === null && item.error === null;
return (
<div key={item.key} className="w-32 shrink-0 rounded-md border border-border p-2">
<div className="relative h-16 overflow-hidden rounded bg-surface-2">
{item.previewUrl !== null && item.kind === "image" && (
<img src={item.previewUrl} alt={item.alt || item.name} className="h-full w-full object-cover" />
)}
{item.previewUrl !== null && item.kind === "video" && (
<video src={item.previewUrl} muted playsInline preload="metadata" className="h-full w-full object-cover" />
)}
{item.previewUrl === null && !pending && (
<span className="flex h-full items-center justify-center px-1 text-center text-2xs text-text-faint">
{item.error !== null ? "upload failed" : "no preview"}
</span>
)}
{pending && (
<span className="absolute inset-x-1 bottom-1 block">
<span className="block h-1 overflow-hidden rounded bg-surface">
<span className="block h-full bg-accent transition-[width] duration-150" style={{ width: `${item.progress}%` }} />
</span>
<span className="mt-0.5 block text-center text-2xs text-text-faint">{item.progress}%</span>
</span>
)}
</div>
<input
aria-label={`Alt text for ${item.name}`}
value={item.alt}
disabled={pending}
onChange={(event) => onAltChange(item.key, event.target.value)}
placeholder="ALT text"
className="mt-1 min-h-11 w-full bg-transparent text-2xs outline-none disabled:opacity-50"
/>
<span className={`text-2xs ${item.alt ? "text-live" : "text-warn"}`}>{item.alt ? "ALT ready" : "Add ALT"}</span>
<div className="flex items-center justify-between gap-1">
{item.error !== null ? (
<button type="button" className="min-h-11 text-2xs text-accent" onClick={() => onRetry(item.key)}>
Retry
</button>
) : (
<span className="text-2xs text-text-faint">{item.kind}</span>
)}
<button type="button" className="min-h-11 text-2xs text-danger" onClick={() => onRemove(item.key)}>
Remove
</button>
</div>
</div>
);
})}
</div>
);
}