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
|
|
@ -10,10 +10,12 @@ import { Badge, Button, Input } from "../../../components/ui/primitives";
|
|||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { MOCK_TARGETS, PLATFORMS } from "../../../lib/platforms";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import type { ConnectionSummary, DraftPost, DraftRecord, DraftState, ScheduledPublicationRecord, Visibility } from "../../../lib/api-types";
|
||||
import type { ConnectionSummary, DraftRecord, DraftState, ScheduledPublicationRecord, Visibility } from "../../../lib/api-types";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { Sheet } from "../../../components/mobile/sheet";
|
||||
import { AttachmentsRow, useComposeAttachments } from "../../../components/compose/attachments";
|
||||
import { MEDIA_ATTACH_HINT, MEDIA_MAX_ATTACHMENTS, type MediaDraftPost } from "../../../lib/media-types";
|
||||
|
||||
/** A publish target: a real connection when live, a fixture platform when offline. */
|
||||
interface Target {
|
||||
|
|
@ -62,9 +64,7 @@ export default function ComposePage(): React.ReactElement {
|
|||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [keyboardOffset, setKeyboardOffset] = useState(0);
|
||||
const [media, setMedia] = useState<Array<{ name: string; url: string; alt: string }>>([]);
|
||||
const mediaRef = useRef(media);
|
||||
mediaRef.current = media;
|
||||
const media = useComposeAttachments();
|
||||
|
||||
// Default selection: the first available target, until the user picks.
|
||||
const targets = (picked ?? available.slice(0, 1).map((t) => t.key)).filter((k) => available.some((t) => t.key === k));
|
||||
|
|
@ -86,7 +86,11 @@ export default function ComposePage(): React.ReactElement {
|
|||
|
||||
const chars = editor?.storage.characterCount.characters() ?? 0;
|
||||
const over = chars > limit;
|
||||
const mediaBlocked = media.length > 0;
|
||||
/** Never post a half-uploaded attachment set: the draft would silently drop bytes. */
|
||||
const mediaBusy = media.uploading;
|
||||
const mediaBroken = media.attachments.some((item) => item.error !== null);
|
||||
const dragDepth = useRef(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return;
|
||||
|
|
@ -104,12 +108,12 @@ export default function ComposePage(): React.ReactElement {
|
|||
const requested = params.get("targets")?.split(",").filter(Boolean);
|
||||
if (requested?.length) setPicked(requested);
|
||||
}, [editor]);
|
||||
useEffect(() => () => { mediaRef.current.forEach((item) => URL.revokeObjectURL(item.url)); }, []);
|
||||
|
||||
function postFor(providerMetadata: Record<string, unknown>): DraftPost {
|
||||
function postFor(providerMetadata: Record<string, unknown>): MediaDraftPost {
|
||||
return {
|
||||
text: editor?.getText() ?? "",
|
||||
mediaAssetIds: [],
|
||||
mediaAssetIds: media.mediaAssetIds,
|
||||
...(media.altTexts.length > 0 ? { altTexts: media.altTexts } : {}),
|
||||
visibility,
|
||||
providerMetadata,
|
||||
...(scheduledAt !== "" ? { scheduledAt: new Date(scheduledAt).toISOString() } : {}),
|
||||
|
|
@ -196,20 +200,39 @@ export default function ComposePage(): React.ReactElement {
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[220px] flex-1 overflow-y-auto rounded-lg border border-border-subtle bg-surface p-4 scrollbar-thin">
|
||||
<div
|
||||
onDragEnter={(event) => { event.preventDefault(); dragDepth.current += 1; setDragging(true); }}
|
||||
onDragOver={(event) => { event.preventDefault(); if (event.dataTransfer !== null) event.dataTransfer.dropEffect = "copy"; }}
|
||||
onDragLeave={(event) => { event.preventDefault(); dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDragging(false); }}
|
||||
onDrop={(event) => { event.preventDefault(); dragDepth.current = 0; setDragging(false); media.addFiles(event.dataTransfer.files); }}
|
||||
className={`relative min-h-[220px] flex-1 overflow-y-auto rounded-lg border border-border-subtle bg-surface p-4 scrollbar-thin transition-colors duration-150 ${dragging ? "border-accent ring-1 ring-accent/40" : ""}`}
|
||||
>
|
||||
{dragging && <div aria-hidden className="pointer-events-none absolute inset-2 z-10 flex items-center justify-center rounded-md border border-dashed border-accent/60 bg-surface/80 text-xs text-accent">Drop to attach</div>}
|
||||
<EditorContent editor={editor} className="prose-invert max-w-none text-sm [&_.ProseMirror]:min-h-[180px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-text-faint [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 lg:hidden">
|
||||
<div className="flex items-center gap-3"><div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-4 text-xs font-bold ${over ? "border-danger text-danger" : chars >= limit * 0.9 ? "border-warn text-warn" : "border-accent text-accent"}`}>{Math.max(0, limit - chars)}</div><div className="min-w-0 flex-1 overflow-x-auto"><div className="flex gap-2">{selected.map((target) => { const max = PLATFORMS[target.provider]?.charLimit ?? 500; return <button key={target.key} type="button" onClick={() => setPreviewOpen(true)} className={`min-h-11 shrink-0 rounded-md border px-3 text-xs ${chars > max ? "border-danger text-danger" : "border-border text-text-dim"}`}>{PLATFORMS[target.provider]?.displayName ?? target.provider} {chars}/{max}</button>; })}</div></div></div>
|
||||
{media.length > 0 && <div className="mt-2 flex gap-2 overflow-x-auto">{media.map((item) => <div key={item.url} className="w-28 shrink-0 rounded-md border border-border p-2"><div className="h-16 overflow-hidden rounded bg-surface-2"><img src={item.url} alt={item.alt || item.name} className="h-full w-full object-cover" /></div><input aria-label={`Alt text for ${item.name}`} value={item.alt} onChange={(event) => setMedia((current) => current.map((value) => value.url === item.url ? { ...value, alt: event.target.value } : value))} placeholder="ALT text" className="mt-1 min-h-11 w-full bg-transparent text-2xs outline-none" /><span className={`text-2xs ${item.alt ? "text-live" : "text-warn"}`}>{item.alt ? "ALT ready" : "Add ALT"}</span><button type="button" className="min-h-11 w-full text-2xs text-danger" onClick={() => { URL.revokeObjectURL(item.url); setMedia((current) => current.filter((value) => value.url !== item.url)); }}>Remove</button></div>)}</div>}
|
||||
{mediaBlocked && <p className="mt-2 text-xs text-warn">Media upload is not available in the publishing API yet. Remove previews to save or post text.</p>}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className={`min-h-11 cursor-pointer rounded-md border border-border px-3 text-2xs leading-[44px] text-text-dim transition-colors duration-150 hover:border-accent hover:text-accent ${media.full ? "pointer-events-none opacity-40" : ""}`}>
|
||||
+ Attach media
|
||||
<input type="file" multiple accept="image/*,video/*" className="hidden" disabled={media.full} onChange={(event) => { if (event.target.files !== null) media.addFiles(event.target.files); event.target.value = ""; }} />
|
||||
</label>
|
||||
<span className="text-2xs text-text-faint">{media.attachments.length}/{MEDIA_MAX_ATTACHMENTS} · {MEDIA_ATTACH_HINT}</span>
|
||||
</div>
|
||||
<AttachmentsRow attachments={media.attachments} onAltChange={media.setAlt} onRemove={media.remove} onRetry={media.retry} />
|
||||
{media.warning !== null && <p className="mt-2 text-2xs text-warn">{media.warning}</p>}
|
||||
<button type="button" onClick={() => setScheduleOpen(true)} className="mt-3 flex min-h-12 w-full items-center justify-between border-b border-border-subtle text-left text-sm"><span>Schedule</span><span className="text-xs text-text-faint">{scheduledAt ? new Date(scheduledAt).toLocaleString() : "Post now"} ›</span></button>
|
||||
<label className="flex min-h-12 items-center justify-between border-b border-border-subtle text-sm"><span>Request approval</span><input type="checkbox" className="h-6 w-6 accent-accent" checked={approval === "review" || !can("approval.grant")} disabled={!can("approval.grant")} onChange={(event) => setApproval(event.target.checked ? "review" : "draft")} /></label>
|
||||
{!can("approval.grant") && <p className="mt-1 text-2xs text-text-faint">Your role submits posts for editor approval.</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 hidden flex-wrap items-center gap-3 lg:flex">
|
||||
<label className={`cursor-pointer rounded-md border border-border px-2 py-1 text-xs text-text-dim transition-colors duration-150 hover:border-accent hover:text-accent ${media.full ? "pointer-events-none opacity-40" : ""}`}>
|
||||
+ Attach
|
||||
<input type="file" multiple accept="image/*,video/*" className="hidden" disabled={media.full} onChange={(event) => { if (event.target.files !== null) media.addFiles(event.target.files); event.target.value = ""; }} />
|
||||
</label>
|
||||
<span className="text-2xs text-text-faint">{media.attachments.length}/{MEDIA_MAX_ATTACHMENTS} · {MEDIA_ATTACH_HINT}</span>
|
||||
<span className={`font-mono text-xs ${over ? "text-danger" : "text-text-faint"}`}>
|
||||
{chars} / {limit}
|
||||
</span>
|
||||
|
|
@ -252,18 +275,24 @@ export default function ComposePage(): React.ReactElement {
|
|||
/>
|
||||
</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" disabled={busy || chars === 0} onClick={() => void saveDraft()}>Save draft</Button>
|
||||
<Button variant="primary" disabled={busy || targets.length === 0 || chars === 0 || over} onClick={() => void publish()}>
|
||||
<Button variant="outline" disabled={busy || mediaBusy || chars === 0} onClick={() => void saveDraft()}>Save draft</Button>
|
||||
<Button variant="primary" disabled={busy || mediaBusy || targets.length === 0 || chars === 0 || over} onClick={() => void publish()}>
|
||||
{scheduledAt !== "" ? "Schedule" : approval === "review" ? "Submit" : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{mediaBusy && (
|
||||
<p className="mt-2 text-xs text-accent">Uploading media… {media.attachments.filter((item) => item.assetId !== null && item.error === null).length}/{media.attachments.filter((item) => item.error === null).length} ready</p>
|
||||
)}
|
||||
{mediaBroken && !mediaBusy && (
|
||||
<p className="mt-2 text-xs text-warn">An attachment failed to upload — retry or remove it before posting.</p>
|
||||
)}
|
||||
{notice !== null && (
|
||||
<p className={`mt-2 text-xs ${notice.tone === "ok" ? "text-live" : "text-danger"}`}>{notice.text}</p>
|
||||
)}
|
||||
<div className="fixed left-0 right-0 z-30 border-t border-border bg-surface px-4 py-2 lg:hidden" style={{ bottom: `calc(var(--mobile-tab-height) + var(--mobile-safe-bottom) + ${keyboardOffset}px)` }}>
|
||||
<p className="mb-2 truncate text-2xs text-text-faint">{approval === "review" || !can("approval.grant") ? "→ Editors approve" : "→ Ready to publish"} · {scheduledAt ? `publishes ${new Date(scheduledAt).toLocaleString()}` : "post now"} · {selected.length} accounts</p>
|
||||
<div className="flex gap-2"><Button variant="ghost" className="min-h-11" onClick={() => router.back()}>Cancel</Button><Button variant="outline" className="min-h-11 flex-1" disabled={busy || chars === 0 || mediaBlocked} onClick={() => void saveDraft()}>Draft</Button><Button variant="primary" className="min-h-11 flex-1" disabled={busy || selected.length === 0 || chars === 0 || over || mediaBlocked} onClick={() => void publish()}>{scheduledAt ? "Schedule" : approval === "review" || !can("approval.grant") ? "Submit" : "Post"}</Button></div>
|
||||
<div className="flex gap-2"><Button variant="ghost" className="min-h-11" onClick={() => router.back()}>Cancel</Button><Button variant="outline" className="min-h-11 flex-1" disabled={busy || mediaBusy || chars === 0} onClick={() => void saveDraft()}>Draft</Button><Button variant="primary" className="min-h-11 flex-1" disabled={busy || selected.length === 0 || chars === 0 || over || mediaBusy} onClick={() => void publish()}>{scheduledAt ? "Schedule" : approval === "review" || !can("approval.grant") ? "Submit" : "Post"}</Button></div>
|
||||
</div>
|
||||
<Sheet open={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule post" footer={<Button variant="primary" className="min-h-11 w-full justify-center" onClick={() => setScheduleOpen(false)}>Done</Button>}><label className="block text-xs text-text-faint" htmlFor="mobile-schedule">Date and time</label><Input id="mobile-schedule" type="datetime-local" value={scheduledAt} onChange={(event) => setScheduledAt(event.target.value)} className="mt-2 min-h-12" /><div className="mt-4 flex gap-2">{[1, 24, 48].map((hours) => <button key={hours} type="button" className="min-h-11 rounded-md border border-border px-3 text-xs" onClick={() => { const date = new Date(Date.now() + hours * 3600000); setScheduledAt(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`); }}>{hours === 1 ? "In 1 hour" : hours === 24 ? "Tomorrow" : "In 2 days"}</button>)}</div><button type="button" onClick={() => setScheduledAt("")} className="mt-4 min-h-11 text-xs text-text-faint">Clear schedule</button></Sheet>
|
||||
<Sheet open={previewOpen} onClose={() => setPreviewOpen(false)} title="Target previews">{selected.map((target) => <div key={target.key} className="mb-3 rounded-md border border-border p-3"><p className="text-xs text-text-faint">{target.label}</p><p className="mt-2 whitespace-pre-wrap text-sm">{editor?.getText() || "Your post preview appears here…"}</p></div>)}</Sheet>
|
||||
|
|
|
|||
|
|
@ -229,7 +229,15 @@ export default function VideoPage(): React.ReactElement {
|
|||
rendering and transcoding run separately in the media worker.
|
||||
</p>
|
||||
|
||||
{selected !== null && <VideoDetailDrawer key={selected.videoAsset.id} entry={selected} onClose={() => setSelectedId(null)} />}
|
||||
{selected !== null && (
|
||||
<VideoDetailDrawer
|
||||
key={selected.videoAsset.id}
|
||||
entry={selected}
|
||||
live={live}
|
||||
canManage={canManage}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete !== null && (
|
||||
<DeleteVideoModal
|
||||
asset={confirmDelete.videoAsset}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue