diff --git a/apps/admin/package.json b/apps/admin/package.json index 0a1df7b..88b0148 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -15,7 +15,7 @@ "clean": "rimraf .next out *.tsbuildinfo" }, "dependencies": { - "@civfix/shared": "^0.53.0", + "@civfix/shared": "^0.55.0", "@tanstack/react-query": "^5.62.7", "clsx": "^2.1.1", "leaflet": "^1.9.4", diff --git a/apps/admin/src/components/shared/dialog.tsx b/apps/admin/src/components/shared/dialog.tsx index d0defb7..89745f3 100644 --- a/apps/admin/src/components/shared/dialog.tsx +++ b/apps/admin/src/components/shared/dialog.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { create } from "zustand" import { Icons } from "@/components/icons" +import { useModalFocus } from "@/components/shared/modal-focus" interface ConfirmRequest { kind: "confirm" @@ -60,6 +61,7 @@ export function DialogHost() { const current = useDialogStore((s) => s.current) const close = useDialogStore((s) => s.close) const [value, setValue] = React.useState("") + const modalRef = useModalFocus(current !== null) React.useEffect(() => { if (current?.kind === "prompt") setValue(current.defaultValue ?? "") @@ -105,6 +107,7 @@ export function DialogHost() { return (
e.stopPropagation()} role="dialog" diff --git a/apps/admin/src/components/shared/lightbox.test.ts b/apps/admin/src/components/shared/lightbox.test.ts new file mode 100644 index 0000000..463c9fe --- /dev/null +++ b/apps/admin/src/components/shared/lightbox.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest" + +import { startIndex, stepIndex } from "@/components/shared/lightbox" + +describe("lightbox paging", () => { + it("wraps forward past the last photo and backward past the first", () => { + expect(stepIndex(0, 1, 3)).toBe(1) + expect(stepIndex(2, 1, 3)).toBe(0) + expect(stepIndex(0, -1, 3)).toBe(2) + expect(stepIndex(1, -1, 3)).toBe(0) + }) + + it("stays put on a single photo and never divides by an empty set", () => { + expect(stepIndex(0, 1, 1)).toBe(0) + expect(stepIndex(0, -1, 1)).toBe(0) + expect(stepIndex(0, 1, 0)).toBe(0) + }) + + it("clamps an out-of-range start to the first photo", () => { + expect(startIndex(2, 4)).toBe(2) + expect(startIndex(-1, 4)).toBe(0) + expect(startIndex(4, 4)).toBe(0) + }) +}) diff --git a/apps/admin/src/components/shared/lightbox.tsx b/apps/admin/src/components/shared/lightbox.tsx new file mode 100644 index 0000000..2f1ee5d --- /dev/null +++ b/apps/admin/src/components/shared/lightbox.tsx @@ -0,0 +1,177 @@ +"use client" + +import * as React from "react" +import { create } from "zustand" + +import { Icons } from "@/components/icons" +import { useModalFocus } from "@/components/shared/modal-focus" + +export interface LightboxImage { + id: string + url: string + alt: string +} + +export function stepIndex(index: number, delta: number, count: number): number { + if (count <= 0) return 0 + return (((index + delta) % count) + count) % count +} + +export function startIndex(index: number, count: number): number { + return index >= 0 && index < count ? index : 0 +} + +interface LightboxState { + images: LightboxImage[] + index: number + refresh: (() => void) | null + open: (images: LightboxImage[], index: number, refresh: (() => void) | null) => void + close: () => void + step: (delta: number) => void + sync: (images: LightboxImage[]) => void +} + +const useLightboxStore = create((set) => ({ + images: [], + index: 0, + refresh: null, + open: (images, index, refresh) => set({ images, index, refresh }), + close: () => set({ images: [], index: 0, refresh: null }), + step: (delta) => + set((s) => (s.images.length === 0 ? s : { index: stepIndex(s.index, delta, s.images.length) })), + sync: (images) => + set((s) => { + if (s.images.length === 0) return s + const next = s.images.map((shown) => images.find((i) => i.id === shown.id) ?? shown) + return next.every((img, i) => img.url === s.images[i]?.url) ? s : { images: next } + }), +})) + +export function openLightbox(images: LightboxImage[], index = 0, refresh?: () => void): void { + if (images.length === 0) return + useLightboxStore.getState().open(images, startIndex(index, images.length), refresh ?? null) +} + +export function LightboxSync({ images }: { images: LightboxImage[] }) { + React.useEffect(() => { + useLightboxStore.getState().sync(images) + }, [images]) + return null +} + +export function LightboxHost() { + const images = useLightboxStore((s) => s.images) + const index = useLightboxStore((s) => s.index) + const refresh = useLightboxStore((s) => s.refresh) + const close = useLightboxStore((s) => s.close) + const step = useLightboxStore((s) => s.step) + const count = images.length + const frameRef = useModalFocus(count > 0) + const current = count > 0 ? (images[index] ?? images[0]) : undefined + const url = current?.url ?? null + const [load, setLoad] = React.useState<{ + url: string | null + status: "loading" | "ready" | "failed" + attempt: number + }>({ url: null, status: "loading", attempt: 0 }) + const forCurrent = load.url === url + const status = forCurrent ? load.status : "loading" + const attempt = forCurrent ? load.attempt : 0 + + React.useEffect(() => { + if (count === 0) return + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + close() + } else if (e.key === "ArrowRight" && count > 1) { + e.preventDefault() + step(1) + } else if (e.key === "ArrowLeft" && count > 1) { + e.preventDefault() + step(-1) + } + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [count, close, step]) + + if (!current) return null + const many = count > 1 + + const retry = () => { + setLoad({ url: current.url, status: "loading", attempt: attempt + 1 }) + refresh?.() + } + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={current.alt} + > +
+ {many && ( + + {index + 1} / {count} + + )} + +
+ {status !== "failed" && ( + // eslint-disable-next-line @next/next/no-img-element + {current.alt} setLoad({ url: current.url, status: "ready", attempt })} + onError={() => setLoad({ url: current.url, status: "failed", attempt })} + /> + )} + {status === "loading" && ( +
+
+ )} + {status === "failed" && ( +
+ This photo link expired + + Photo links are short-lived. Refresh to fetch a new one. + + +
+ )} + {many && ( + <> + + + + )} +
+
+ ) +} diff --git a/apps/admin/src/components/shared/modal-accessibility.test.ts b/apps/admin/src/components/shared/modal-accessibility.test.ts new file mode 100644 index 0000000..ff9674c --- /dev/null +++ b/apps/admin/src/components/shared/modal-accessibility.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from "node:fs" + +import { describe, expect, it } from "vitest" + +import { ESCAPE_OWNER_SELECTOR } from "@/components/shell/escape-owner" + +const lightboxSource = readFileSync(new URL("./lightbox.tsx", import.meta.url), "utf8") +const dialogSource = readFileSync(new URL("./dialog.tsx", import.meta.url), "utf8") + +const lightboxFrame = + lightboxSource.match(/className="lightbox"[\s\S]{0,300}?aria-label=\{current\.alt\}/)?.[0] ?? "" + +describe("modal host markup", () => { + it("marks the lightbox frame as the modal dialog the shell yields Escape to", () => { + expect(ESCAPE_OWNER_SELECTOR).toContain('[role="dialog"][aria-modal="true"]') + expect(lightboxFrame).toMatch(/role="dialog"/) + expect(lightboxFrame).toMatch(/aria-modal="true"/) + expect(lightboxFrame).not.toMatch(/aria-hidden/) + }) + + it("traps and restores focus in both hosts through the shared helper", () => { + for (const source of [lightboxSource, dialogSource]) { + expect(source).toMatch(/useModalFocus\(/) + expect(source).toMatch(/ref=\{(frameRef|modalRef)\}/) + } + }) + + it("keeps an explicit outcome for a photo that fails to load", () => { + expect(lightboxSource).toMatch(/onError=/) + expect(lightboxSource).toMatch(/onLoad=/) + expect(lightboxSource).toContain("Refresh photo") + }) +}) diff --git a/apps/admin/src/components/shared/modal-focus.ts b/apps/admin/src/components/shared/modal-focus.ts new file mode 100644 index 0000000..417c0cf --- /dev/null +++ b/apps/admin/src/components/shared/modal-focus.ts @@ -0,0 +1,47 @@ +"use client" + +import * as React from "react" + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' + +function focusableWithin(container: HTMLElement): HTMLElement[] { + return [...container.querySelectorAll(FOCUSABLE_SELECTOR)] +} + +export function useModalFocus(open: boolean): React.RefObject { + const ref = React.useRef(null) + + React.useEffect(() => { + const container = ref.current + if (!open || !container) return + const restoreTo = document.activeElement as HTMLElement | null + + if (!container.contains(document.activeElement)) focusableWithin(container)[0]?.focus() + + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Tab") return + const focusable = focusableWithin(container) + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (!first || !last) { + e.preventDefault() + return + } + const active = document.activeElement + const leavingBackwards = e.shiftKey && (active === first || !container.contains(active)) + const leavingForwards = !e.shiftKey && (active === last || !container.contains(active)) + if (!leavingBackwards && !leavingForwards) return + e.preventDefault() + ;(e.shiftKey ? last : first).focus() + } + + document.addEventListener("keydown", onKey, true) + return () => { + document.removeEventListener("keydown", onKey, true) + if (restoreTo && document.contains(restoreTo)) restoreTo.focus() + } + }, [open]) + + return ref +} diff --git a/apps/admin/src/components/shell/app-shell.tsx b/apps/admin/src/components/shell/app-shell.tsx index 10a76e1..65ed6ef 100644 --- a/apps/admin/src/components/shell/app-shell.tsx +++ b/apps/admin/src/components/shell/app-shell.tsx @@ -6,6 +6,7 @@ import { BackBar } from "@/components/shell/back-bar" import { shellEscapeGoesHome } from "@/components/shell/escape-owner" import { Toast } from "@/components/shell/toast" import { DialogHost } from "@/components/shared/dialog" +import { LightboxHost } from "@/components/shared/lightbox" import { PAGE_REGISTRY, type SectionPageProps } from "@/components/shell/page-registry" import { LoadingState } from "@/components/shared/data-states" import { useUiStore, type SectionId } from "@/store/ui-store" @@ -52,6 +53,7 @@ export function AppShell() { +
) } diff --git a/apps/admin/src/features/hosts/broadcast-log.tsx b/apps/admin/src/features/hosts/broadcast-log.tsx index 20756d7..5d5f922 100644 --- a/apps/admin/src/features/hosts/broadcast-log.tsx +++ b/apps/admin/src/features/hosts/broadcast-log.tsx @@ -28,6 +28,7 @@ export const BROADCAST_KIND_LABEL: Record = { event_updated: "Event updated", event_cancelled: "Event cancelled", thank_you: "Thank you", + announcement: "Announcement", } export function BroadcastLog({ items }: { items: AdminBroadcastListItemDTO[] }) { diff --git a/apps/admin/src/features/reports/reports-page.tsx b/apps/admin/src/features/reports/reports-page.tsx index 2cdbc68..97bd27f 100644 --- a/apps/admin/src/features/reports/reports-page.tsx +++ b/apps/admin/src/features/reports/reports-page.tsx @@ -13,6 +13,7 @@ import { type AdminReportStatus, type ChatMessageDTO, type LinkedEventRef, + type ReportCategory, type ReportOutreachStatus, } from "@civfix/shared" @@ -20,6 +21,7 @@ import { Icons, type IconComponent } from "@/components/icons" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { confirmDialog } from "@/components/shared/dialog" +import { LightboxSync, openLightbox, type LightboxImage } from "@/components/shared/lightbox" import { categoryCssVar, categoryPinSrc } from "@/lib/category" import { reportStatusView } from "@/lib/report-status" import { eventKindView } from "@/lib/event-kind" @@ -31,6 +33,7 @@ import { useRemoveReport, useRemoveReportMessage, useReport, + useRefreshReportMedia, useReportChatHistory, useReportListInfinite, useRouteReport, @@ -200,11 +203,13 @@ function ChatMessageRow({ onRemove, removing, nav, + refreshPhotos, }: { msg: ChatMessageDTO onRemove: (msg: ChatMessageDTO) => void removing: boolean nav: ReturnType + refreshPhotos: () => void }) { const removed = !!msg.deletedAt || msg.from == null const authorName = chatAuthorName(msg) @@ -212,6 +217,9 @@ function ChatMessageRow({ const authorId = msg.from?.id const reactions = (msg.reactions ?? []).filter((r) => r.count > 0) const attachments = msg.attachments ?? [] + const chatImages: LightboxImage[] = attachments + .filter((m) => m.kind === "image") + .map((m) => ({ id: m.id, url: m.url, alt: `Photo from ${authorName}` })) return (
@@ -262,19 +270,36 @@ function ChatMessageRow({ {!removed && attachments.length > 0 && (
- {attachments.map((m) => { - const thumb = m.kind === "image" ? (m.thumbUrl ?? m.url) : m.thumbUrl - return ( + + {attachments.map((m) => + m.kind === "image" ? ( + + ) : ( - {thumb ? ( + {m.thumbUrl ? ( // eslint-disable-next-line @next/next/no-img-element - + ) : ( )} - ) - })} + ), + )}
)} @@ -400,6 +425,7 @@ function ReportDiscussion({ onRemove={onRemove} removing={removeMsg.isPending} nav={nav} + refreshPhotos={() => void q.refetch()} /> ), )} @@ -452,11 +478,31 @@ const ReportRow = React.memo(function ReportRow({ const view = reportStatusView(item.status) const nav = useNav() const reporterId = getReporterProfileId(item.reporter.id) + const [brokenThumb, setBrokenThumb] = React.useState(null) + const thumb = item.thumbnailUrl !== brokenThumb ? item.thumbnailUrl : null + const categoryLabel = REPORT_CATEGORY_LABELS[item.category] return (
onSelect(item.id)}> -
- {/* eslint-disable-next-line @next/next/no-img-element */} - +
+ {thumb ? ( + // eslint-disable-next-line @next/next/no-img-element + setBrokenThumb(thumb)} + /> + ) : ( + // eslint-disable-next-line @next/next/no-img-element + + )}
@@ -504,10 +550,40 @@ const ReportRow = React.memo(function ReportRow({ ) }) +function ReportPhotoFace({ + photoUrl, + pin, + category, +}: { + photoUrl: string | null + pin: string + category: ReportCategory +}) { + return ( + <> + {photoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Reporter photo + ) : ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + )} + {photoUrl && ( + + Reporter photo + + )} + + ) +} + function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (id: string) => void }) { const q = useReport(reportId) const nav = useNav() const toast = useToast() + const refreshMedia = useRefreshReportMedia(reportId) const setStatus = useSetReportStatus() const flag = useFlagReport() @@ -554,6 +630,11 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i const galleryMedia = previewMedia ? report.media.filter((m) => m.id !== previewMedia?.id) : report.media + const lightboxImages: LightboxImage[] = report.media + .filter((m) => m.kind === "image") + .map((m) => ({ id: m.id, url: m.url, alt: `Photo on ${report.title}` })) + const lightboxIndex = (id: string) => lightboxImages.findIndex((i) => i.id === id) + const previewIndex = previewMedia ? lightboxIndex(previewMedia.id) : -1 const send = () => { const body = text.trim() @@ -795,27 +876,34 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- {report.hasPhoto && ( -
- {photoUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - Reporter photo - ) : ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - - - )} - {photoUrl && ( - - Reporter photo - - )} -
- )} + + {report.hasPhoto && + (previewIndex >= 0 ? ( + + ) : ( +
+ +
+ ))}
- {galleryMedia.map((m) => { - const thumb = m.kind === "image" ? (m.thumbUrl ?? m.url) : m.thumbUrl - return ( + {galleryMedia.map((m) => + m.kind === "image" ? ( + + ) : ( - {thumb ? ( + {m.thumbUrl ? ( // eslint-disable-next-line @next/next/no-img-element - + ) : ( )} - ) - })} + ), + )}
diff --git a/apps/admin/src/features/reports/use-reports.ts b/apps/admin/src/features/reports/use-reports.ts index 75da64c..9e9f3bd 100644 --- a/apps/admin/src/features/reports/use-reports.ts +++ b/apps/admin/src/features/reports/use-reports.ts @@ -48,6 +48,13 @@ export function useReport(id: string | null) { }) } +export function useRefreshReportMedia(id: string): () => void { + const qc = useQueryClient() + return () => { + qc.invalidateQueries({ queryKey: queryKeys.reports.detail(id) }) + } +} + function invalidateReports(qc: ReturnType, id: string) { qc.invalidateQueries({ queryKey: queryKeys.reports.detail(id) }) qc.invalidateQueries({ queryKey: queryKeys.reports.all }) diff --git a/apps/admin/src/styles/admin.css b/apps/admin/src/styles/admin.css index 5316554..1fd910e 100644 --- a/apps/admin/src/styles/admin.css +++ b/apps/admin/src/styles/admin.css @@ -1065,6 +1065,25 @@ button:focus-visible { outline: none; box-shadow: var(--ring); } width: 100%; height: 100%; object-fit: contain; } +.qrow .leading.has-thumb { + position: relative; + background: var(--paper-2); + border: 1px solid color-mix(in oklab, var(--cat, var(--border-soft)) 60%, var(--border-soft)); + border-radius: 8px; + overflow: hidden; +} +.qrow .leading.has-thumb::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: 0; + height: 3px; + background: var(--cat, transparent); +} +.qrow .leading.has-thumb img { + width: 100%; height: 100%; + object-fit: cover; + display: block; +} .qrow .body { min-width: 0; } .qrow .body .top { @@ -2926,6 +2945,66 @@ button.prow-main { color: inherit; font: inherit; background: none; border: 0; p .modal-foot .spacer { flex: 1; } .compose-from { font-size: 11.5px; color: var(--ink-3); } +.lightbox-overlay { background: rgba(26, 23, 20, 0.78); padding: 32px; } +.lightbox { + position: relative; + width: fit-content; max-width: min(1100px, 100%); + min-width: min(320px, 100%); min-height: min(240px, 100%); + display: flex; align-items: center; justify-content: center; + animation: modal-pop 200ms cubic-bezier(0.25, 0.8, 0.3, 1); +} +.lightbox-face { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 8px; + width: min(320px, 100%); + padding: 24px; + text-align: center; + color: #fff; + font-size: 12.5px; + background: rgba(26, 23, 20, 0.55); + border-radius: 12px; +} +.lightbox-face-title { font-weight: 700; font-size: 14px; } +.lightbox-face-sub { color: rgba(255, 255, 255, 0.72); line-height: 1.5; } +.lightbox-face-btn { + margin-top: 4px; + padding: 7px 14px; border-radius: 10px; + font-size: 12px; font-weight: 700; + color: #fff; background: rgba(26, 23, 20, 0.72); + transition: background-color 120ms ease; +} +.lightbox-face-btn:hover { background: rgba(26, 23, 20, 0.9); } +.lightbox-img { + max-width: 100%; max-height: calc(100vh - 64px); + width: auto; height: auto; display: block; + border-radius: 12px; + background: var(--paper-2); + box-shadow: 0 24px 64px rgba(26, 23, 20, 0.45); +} +.lightbox-img.pending { display: none; } +.lightbox-bar { + position: absolute; top: 10px; right: 10px; z-index: 1; + display: flex; align-items: center; gap: 8px; +} +.lightbox-count { + font-size: 10.5px; font-weight: 700; color: #fff; + background: rgba(26, 23, 20, 0.72); + padding: 4px 8px; border-radius: 8px; +} +.lightbox-btn { + width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + color: #fff; background: rgba(26, 23, 20, 0.72); + transition: background-color 120ms ease; +} +.lightbox-btn:hover { background: rgba(26, 23, 20, 0.9); } +.lightbox-prev, .lightbox-next { position: absolute; top: 50%; transform: translateY(-50%); } +.lightbox-prev { left: 10px; } +.lightbox-next { right: 10px; } + +.rep-photo-open { padding: 0; cursor: zoom-in; } +.dsc-msg-thumb-open { padding: 0; cursor: zoom-in; } + .dialog-modal { max-width: 460px; } .dialog-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; } .dialog-text { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--ink-2); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a9fff7..d0a9b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: apps/admin: dependencies: '@civfix/shared': - specifier: ^0.53.0 - version: 0.53.0 + specifier: ^0.55.0 + version: 0.55.0 '@tanstack/react-query': specifier: ^5.62.7 version: 5.101.0(react@19.0.0) @@ -101,8 +101,8 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@civfix/shared@0.53.0': - resolution: {integrity: sha512-TvY+VHFkg3G7GNNNjhk9kwSrvXVsEDq9NAUbNohmN2X29JAC2uLaUS3FfIJIeew6Qc710+Zr7Ik2+C7Cbs0YkQ==} + '@civfix/shared@0.55.0': + resolution: {integrity: sha512-f24XyngBWMV4L7ipARvX8oATSObFKkrEvCBQxJDobbpenHduZ3LkU6sC5uZfN/ZrsySiH0xcInvxgit966S3gA==} engines: {node: '>=20'} '@emnapi/core@1.10.0': @@ -2484,7 +2484,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@civfix/shared@0.53.0': + '@civfix/shared@0.55.0': dependencies: zod: 3.25.76