Skip to content
Closed
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
16 changes: 16 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ promoted down a layer (`lib/`, `hooks/`, `types/`, `components/ui/`) instead of
5. **Undocumented locations need a doc update first.** Where new code goes should never be a guess; if no row in
"Where Does New Code Go?" fits, the table is extended in the same PR.

## API errors

Every failed API call is normalized into one shape (`ApiErrorResponse` in `src/types/api-error.ts`):

```ts
{ code: string; message: string; statusCode: number | null; timestamp: string }
```

`services/http.ts` produces it from the envelopes the backend and its gateways actually send (`{ message }`,
`{ error }`, short plain text, HTML error pages), and `ApiError` implements the contract so `code`, `message`,
`statusCode` and `timestamp` are always present. Errors that never reached a server use `statusCode: null`.

At a boundary that may throw (`toApiErrorResponse`, `withErrorHandling` in `src/lib/api-errors.ts`), call
`withErrorHandling` to get a discriminated result instead of a thrown error, and `toApiErrorResponse` to log or
report the canonical payload. Do not format errors ad hoc in components; read `message` from the normalized error.

## Known exceptions

These seven deep imports (they bypass a feature barrel) exist today. (`main.tsx` and `services/http.ts` no longer reach into feature internals.) They are tolerated, not endorsed: each lists why it
Expand Down
100 changes: 83 additions & 17 deletions src/components/ImageUpload/ImageUpload.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useId, useRef } from 'react'
import { Spinner } from '@/components/ui'
import { MAX_ALT_TEXT_LENGTH, resolveAltText } from '@/lib/altText'

interface ImageUploadProps {
/** Current image URL to display */
Expand All @@ -24,6 +25,12 @@ interface ImageUploadProps {
error?: string | null
/** Disabled state */
disabled?: boolean
/** Saved alt text for the current image; prefills the editable field. */
altText?: string
/** Called with the new value whenever the user edits the alt text. */
onAltTextChange?: (altText: string) => void
/** Wording used when neither the user nor the file name describes the image. */
altFallback?: string
}

const DEFAULT_MAX_SIZE = 10 * 1024 * 1024 // 10MB
Expand All @@ -41,9 +48,31 @@ export default function ImageUpload({
uploadProgress = 0,
error = null,
disabled = false,
altText,
onAltTextChange,
altFallback,
}: ImageUploadProps) {
const [dragActive, setDragActive] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const altInputId = useId()
const altHintId = `${altInputId}-hint`
const fileInputId = useId()

// The field starts from the saved value; once the user types, their draft wins
// so an upload completing mid-edit cannot overwrite it.
const [draftAltText, setDraftAltText] = useState<string | null>(null)
const fallbackAlt = altFallback ?? `${label} image`
const altTextValue =
draftAltText ?? altText ?? (currentImage ? resolveAltText('', currentImage, fallbackAlt) : '')
const effectiveAltText = resolveAltText(altTextValue, currentImage, fallbackAlt)

const handleAltTextChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setDraftAltText(e.target.value)
onAltTextChange?.(e.target.value)
},
[onAltTextChange],
)

const validateFile = (file: File): string | null => {
if (!acceptedTypes.includes(file.type)) {
Expand Down Expand Up @@ -71,7 +100,7 @@ export default function ImageUpload({

onFileSelect(file)
},
[acceptedTypes, maxSize, onFileSelect]
[acceptedTypes, maxSize, onFileSelect],
)

const handleDrop = useCallback(
Expand All @@ -83,16 +112,19 @@ export default function ImageUpload({
handleFiles(e.dataTransfer.files)
}
},
[disabled, handleFiles]
[disabled, handleFiles],
)

const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setDragActive(true)
}
}, [disabled])
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
setDragActive(true)
}
},
[disabled],
)

const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
Expand All @@ -106,7 +138,7 @@ export default function ImageUpload({
handleFiles(e.target.files)
}
},
[handleFiles]
[handleFiles],
)

const handleClick = useCallback(() => {
Expand All @@ -120,7 +152,7 @@ export default function ImageUpload({
e.stopPropagation()
onRemove?.()
},
[onRemove]
[onRemove],
)

const formatFileSize = (bytes: number): string => {
Expand All @@ -132,7 +164,9 @@ export default function ImageUpload({
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<label className="text-caption-sm font-semibold text-foreground">{label}</label>
<label htmlFor={fileInputId} className="text-caption-sm font-semibold text-foreground">
{label}
</label>
{currentImage && onRemove && !isUploading && (
<button
type="button"
Expand All @@ -144,6 +178,30 @@ export default function ImageUpload({
)}
</div>

{/* Alt Text (#999): the uploader describes the image; file names are a last resort. */}
<div className="flex flex-col gap-1">
<label htmlFor={altInputId} className="flex flex-col gap-1">
<span className="text-caption-sm font-semibold text-foreground">
Alternative text (alt text)
</span>
<input
id={altInputId}
type="text"
value={altTextValue}
onChange={handleAltTextChange}
maxLength={MAX_ALT_TEXT_LENGTH}
placeholder="e.g. Watercolour painting of a harbour at dusk"
aria-describedby={altHintId}
disabled={disabled}
className="rounded-control border border-line bg-surface px-3 py-2 text-body text-foreground focus-visible:shadow-focus-ring disabled:opacity-50"
/>
</label>
<p id={altHintId} className="text-caption-xs text-muted">
Describes the image for screen readers and search engines. Leave it blank and a
description is generated from the file name.
</p>
</div>

{/* Upload Zone */}
<div
className={`relative rounded-card border-2 border-dashed transition-all overflow-hidden ${
Expand All @@ -168,6 +226,7 @@ export default function ImageUpload({
>
<input
ref={fileInputRef}
id={fileInputId}
type="file"
accept={acceptedTypes.join(',')}
onChange={handleInputChange}
Expand All @@ -180,10 +239,10 @@ export default function ImageUpload({
<div className="relative">
<img
src={currentImage}
alt="Current upload"
alt={effectiveAltText}
className="w-full h-48 object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
;(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity">
Expand Down Expand Up @@ -233,7 +292,13 @@ export default function ImageUpload({
{/* Error Message */}
{error && (
<p className="text-caption-sm text-danger flex items-center gap-1">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
Expand All @@ -244,7 +309,8 @@ export default function ImageUpload({

{/* File Requirements */}
<p className="text-caption-xs text-muted">
Max size: {formatFileSize(maxSize)} • Formats: {acceptedTypes.map((t) => t.split('/')[1]).join(', ')}
Max size: {formatFileSize(maxSize)} • Formats:{' '}
{acceptedTypes.map((t) => t.split('/')[1]).join(', ')}
</p>
</div>
)
Expand Down
138 changes: 77 additions & 61 deletions src/components/PortfolioGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useId, useMemo } from 'react'
import { useFocusManagement } from '@/hooks/useFocusManagement'

export interface PortfolioItem {
Expand Down Expand Up @@ -29,6 +29,8 @@ export interface PortfolioGridProps {
* `role="gridcell"` children (a row with one cell per item is invalid ARIA).
* - Roving tabindex: exactly one cell is tabbable at a time.
* - Arrow keys move between cells; Enter/Space activates.
* - An `aria-describedby` hint announces the key bindings, so the controls are
* discoverable without sighted trial and error (#993).
*/
export default function PortfolioGrid({
items,
Expand All @@ -40,6 +42,7 @@ export default function PortfolioGrid({
}: PortfolioGridProps) {
const safeColumns = Math.max(1, columns)
const itemCount = items.length
const instructionsId = useId()

const { containerRef, getItemProps } = useFocusManagement({
itemCount,
Expand Down Expand Up @@ -74,67 +77,80 @@ export default function PortfolioGrid({
}

return (
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
role="grid"
aria-label={label}
aria-rowcount={rows.length}
aria-colcount={safeColumns}
className={`grid gap-6 ${className}`}
style={gridStyle}
>
{rows.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} role="row" aria-rowindex={rowIndex + 1} className="contents">
{row.map((item, columnIndex) => {
const index = rowIndex * safeColumns + columnIndex
const itemProps = getItemProps(index)
const accessibleName = [item.title, item.category].filter(Boolean).join(', ')
<>
{/* Outside the grid: a grid may only contain rows, and a stray child
would break both the ARIA structure and the CSS grid. */}
<p id={instructionsId} className="sr-only">
{label}: use the arrow keys to move between items, Home and End to jump to the first or last
item, and Enter or Space to open an item.
</p>
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
role="grid"
aria-label={label}
aria-describedby={instructionsId}
aria-rowcount={rows.length}
aria-colcount={safeColumns}
className={`grid gap-6 ${className}`}
style={gridStyle}
>
{rows.map((row, rowIndex) => (
<div key={`row-${rowIndex}`} role="row" aria-rowindex={rowIndex + 1} className="contents">
{row.map((item, columnIndex) => {
const index = rowIndex * safeColumns + columnIndex
const itemProps = getItemProps(index)
const accessibleName = [item.title, item.category, item.status]
.filter(Boolean)
.join(', ')

return (
<div
key={item.id}
role="gridcell"
aria-colindex={columnIndex + 1}
aria-roledescription="portfolio item"
aria-label={accessibleName}
tabIndex={itemProps.tabIndex}
data-focus-index={itemProps['data-focus-index']}
onKeyDown={itemProps.onKeyDown}
onFocus={itemProps.onFocus}
onClick={() => onItemActivate?.(item)}
className="group cursor-pointer overflow-hidden rounded-card border border-line bg-surface shadow-card outline-none transition duration-200 hover:-translate-y-1 hover:shadow-elevated focus-visible:shadow-focus-ring"
>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-surface-muted">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt=""
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted">
No image
</div>
)}
{item.status ? (
<span className="absolute top-2.5 right-2.5 rounded-full bg-primary/90 px-2 py-0.5 text-caption-sm font-semibold text-primary-contrast">
{item.status}
</span>
) : null}
return (
<div
key={item.id}
role="gridcell"
aria-colindex={columnIndex + 1}
aria-roledescription="portfolio item"
aria-label={accessibleName}
tabIndex={itemProps.tabIndex}
data-focus-index={itemProps['data-focus-index']}
onKeyDown={itemProps.onKeyDown}
onFocus={itemProps.onFocus}
onClick={() => onItemActivate?.(item)}
className="group cursor-pointer overflow-hidden rounded-card border border-line bg-surface shadow-card outline-none transition duration-200 hover:-translate-y-1 hover:shadow-elevated focus-visible:shadow-focus-ring"
>
<div className="relative aspect-[4/3] w-full overflow-hidden bg-surface-muted">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt=""
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted">
No image
</div>
)}
{item.status ? (
<span className="absolute top-2.5 right-2.5 rounded-full bg-primary/90 px-2 py-0.5 text-caption-sm font-semibold text-primary-contrast">
{item.status}
</span>
) : null}
</div>
<div className="p-4">
<h3 className="truncate text-body font-semibold text-foreground group-hover:text-primary">
{item.title}
</h3>
{item.description ? (
<p className="mt-1 line-clamp-2 text-caption text-muted">
{item.description}
</p>
) : null}
</div>
</div>
<div className="p-4">
<h3 className="truncate text-body font-semibold text-foreground group-hover:text-primary">
{item.title}
</h3>
{item.description ? (
<p className="mt-1 line-clamp-2 text-caption text-muted">{item.description}</p>
) : null}
</div>
</div>
)
})}
</div>
))}
</div>
)
})}
</div>
))}
</div>
</>
)
}
Loading
Loading