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.
This commit is contained in:
parent
71db0a08b2
commit
1183d69849
7 changed files with 903 additions and 17 deletions
235
apps/web/src/components/compose/attachments.tsx
Normal file
235
apps/web/src/components/compose/attachments.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue