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.
235 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|