Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions frontend/src/components/MediaPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { ImageOff, Search, Upload, X } from "lucide-react"
import { useApiFetch } from "../hooks/useApiFetch"

export type MediaPickerItem = {
id: number
url: string
file_name: string
mime_type?: string
width?: number
height?: number
alt_text?: string
}

type GalleryResponse = {
media?: MediaPickerItem[]
}

type MediaPickerProps = {
onSelect: (item: MediaPickerItem) => void
onClose: () => void
title?: string
}

const PAGE_SIZE = 60

async function errorMessage(response: Response, fallback: string) {
try {
const body = (await response.json()) as { error?: string }
return body.error?.trim() || fallback
} catch {
return fallback
}
}

/**
* Modal for choosing an image from the media library, plus an upload shortcut
* for the common case where the image is not in the library yet.
*
* Backed by /v1/media/gallery, which returns the trimmed picker shape --
* notably including alt_text, so a chosen image arrives with its description
* already written rather than needing it retyped per article.
*/
function MediaPicker({ onSelect, onClose, title = "Insert image" }: MediaPickerProps) {
const apiFetch = useApiFetch()
const fileInputRef = useRef<HTMLInputElement>(null)

const [items, setItems] = useState<MediaPickerItem[]>([])
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [isUploading, setIsUploading] = useState(false)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timer)
}, [searchInput])

useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [onClose])

useEffect(() => {
const controller = new AbortController()

const load = async () => {
setIsLoading(true)
setError(null)
try {
const params = new URLSearchParams({ limit: String(PAGE_SIZE) })
if (search) params.set("search", search)
const response = await apiFetch(`/v1/media/gallery?${params.toString()}`, {
signal: controller.signal,
})
if (!response.ok) throw new Error(await errorMessage(response, `Request failed (${response.status})`))
const payload = (await response.json()) as GalleryResponse
if (controller.signal.aborted) return
setItems(payload.media ?? [])
} catch (err) {
if (controller.signal.aborted) return
setError(err instanceof Error ? err.message : "Unable to load media.")
} finally {
if (!controller.signal.aborted) setIsLoading(false)
}
}

void load()
return () => controller.abort()
}, [apiFetch, search])

const handleUpload = useCallback(
async (files: FileList | null) => {
const file = files?.[0]
if (!file) return
setError(null)
setIsUploading(true)
try {
const body = new FormData()
body.append("file", file)
const response = await apiFetch("/v1/media", { method: "POST", body })
if (!response.ok) {
setError(await errorMessage(response, `Upload failed (${response.status})`))
return
}
const created = (await response.json()) as {
id: number
path: string
url: string
content_type?: string
width?: number
height?: number
}
// Straight into the document: the author picked a file in order to use
// it, so making them find it in the grid afterwards is a wasted step.
onSelect({
id: created.id,
url: created.url,
file_name: created.path.split("/").pop() ?? created.path,
mime_type: created.content_type,
width: created.width,
height: created.height,
})
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed.")
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
},
[apiFetch, onSelect],
)

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
role="presentation"
>
<div
aria-label={title}
aria-modal="true"
className="flex max-h-[85vh] w-full max-w-4xl flex-col gap-4 rounded-xl border border-border bg-background p-6"
onClick={(e) => e.stopPropagation()}
role="dialog"
>
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<button
aria-label="Close"
className="p-1 text-muted-foreground hover:text-foreground"
onClick={onClose}
type="button"
>
<X className="h-5 w-5" />
</button>
</div>

<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[240px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
aria-label="Search media"
autoFocus
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40"
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search by file name, alt text, or caption..."
type="search"
value={searchInput}
/>
</div>
<input
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => void handleUpload(e.target.files)}
ref={fileInputRef}
type="file"
/>
<button
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
type="button"
>
<Upload className="h-4 w-4" aria-hidden="true" />
{isUploading ? "Uploading..." : "Upload"}
</button>
</div>

{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-2 text-sm text-destructive">
{error}
</div>
)}

<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading ? (
<p className="py-12 text-center text-sm text-muted-foreground">Loading media...</p>
) : items.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-12 text-muted-foreground">
<ImageOff className="h-10 w-10" />
<p className="text-sm">{search ? `No results for "${search}"` : "No media items yet."}</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.map((item) => (
<button
className="group flex flex-col overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary"
key={item.id}
onClick={() => onSelect(item)}
title={item.file_name}
type="button"
>
<span className="relative block aspect-square overflow-hidden bg-muted">
<img
alt={item.alt_text || item.file_name}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
referrerPolicy="no-referrer"
src={item.url}
/>
</span>
<span className="truncate px-2 py-1.5 text-xs text-foreground">{item.file_name}</span>
{!item.alt_text && (
// Surfaced here because this is the moment the choice is
// made: an image with no alt text will publish without any.
<span className="px-2 pb-1.5 text-[11px] text-muted-foreground">No alt text</span>
)}
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}

export default MediaPicker
71 changes: 39 additions & 32 deletions frontend/src/components/TrixEditor.css
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,10 @@ trix-editor.trix-content .attachment.attachment--preview.attachment--dragging {
cursor: grabbing;
}

/* Per-image alignment, set during a drop. Overrides the default centering
from .attachment--preview above. Both the inline style and the class are
applied at commit time for redundancy through Trix's HTML serialization. */
/* Per-image alignment. No longer settable from the editor -- the public site
sizes article images at width:100%, which leaves nothing for a float to do --
but alignment already present on migrated WordPress content is preserved
through a save, so it is still reflected here to match what will publish. */
trix-editor.trix-content .attachment--preview.attachment--align-left {
text-align: left;
}
Expand All @@ -306,35 +307,8 @@ trix-editor.trix-content .attachment--preview.attachment--align-right {
text-align: right;
}

/* Resize overlay rendered by JS into .trix-editor-wrapper, positioned over
the currently selected image. The overlay itself is click-through so Trix's
own drag-to-reorder still works; only the corner squares grab pointer events. */
.trix-resize-overlay {
position: absolute;
pointer-events: none;
z-index: 5;
}

.trix-resize-handle {
position: absolute;
width: 12px;
height: 12px;
background: hsl(var(--primary, 243 75% 59%));
border: 2px solid hsl(var(--background, 0 0% 100%));
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
pointer-events: auto;
}

.trix-resize-handle--nw { top: -6px; left: -6px; cursor: nwse-resize; }
.trix-resize-handle--ne { top: -6px; right: -6px; cursor: nesw-resize; }
.trix-resize-handle--sw { bottom: -6px; left: -6px; cursor: nesw-resize; }
.trix-resize-handle--se { bottom: -6px; right: -6px; cursor: nwse-resize; }

/* Drop indicator shown only during an active drag-to-rearrange. The width and
horizontal position shift to hint at the alignment that will be applied on
drop: short bar on the left = left-align, centered short bar = center,
short bar on the right = right-align. */
/* Drop indicator shown only during an active drag-to-rearrange, marking the
line the image will land on. */
.trix-drop-indicator {
position: absolute;
height: 3px;
Expand Down Expand Up @@ -401,3 +375,36 @@ trix-editor.trix-content a:hover::after {
z-index: 10;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

/* Inline failure/warning banner for image insertion (failed upload, failed
paste import, image inserted without alt text). Sits below the editor rather
than in a toast so it stays visible while the author fixes the problem. */
.trix-editor-notice {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
padding: 8px 12px;
border: 1px solid hsl(var(--border, 220 13% 91%));
border-radius: calc(var(--radius, 0.5rem) - 2px);
background: hsl(var(--muted, 220 14% 96%));
color: hsl(var(--foreground, 224 71% 4%));
font-size: 13px;
line-height: 1.4;
}

.trix-editor-notice button {
flex-shrink: 0;
border: 0;
background: transparent;
color: hsl(var(--muted-foreground, 220 9% 46%));
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}

.trix-editor-notice button:hover {
color: hsl(var(--foreground, 224 71% 4%));
}
Loading
Loading