From 3bf20b57b2c3ec7bbcf9ac83ffbd46a046d1a403 Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:11:31 -0700 Subject: [PATCH 1/7] report list rows show the report photo; report images expand in a lightbox --- .../src/components/shared/lightbox.test.ts | 24 +++ apps/admin/src/components/shared/lightbox.tsx | 122 ++++++++++++++ apps/admin/src/components/shell/app-shell.tsx | 2 + .../src/features/reports/reports-page.tsx | 156 +++++++++++++----- apps/admin/src/styles/admin.css | 48 ++++++ 5 files changed, 314 insertions(+), 38 deletions(-) create mode 100644 apps/admin/src/components/shared/lightbox.test.ts create mode 100644 apps/admin/src/components/shared/lightbox.tsx 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..fb96163 --- /dev/null +++ b/apps/admin/src/components/shared/lightbox.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import { create } from "zustand" + +import { Icons } from "@/components/icons" + +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 + open: (images: LightboxImage[], index: number) => void + close: () => void + step: (delta: number) => void +} + +const useLightboxStore = create((set) => ({ + images: [], + index: 0, + open: (images, index) => set({ images, index }), + close: () => set({ images: [], index: 0 }), + step: (delta) => + set((s) => (s.images.length === 0 ? s : { index: stepIndex(s.index, delta, s.images.length) })), +})) + +export function openLightbox(images: LightboxImage[], index = 0): void { + if (images.length === 0) return + useLightboxStore.getState().open(images, startIndex(index, images.length)) +} + +export function LightboxHost() { + const images = useLightboxStore((s) => s.images) + const index = useLightboxStore((s) => s.index) + const close = useLightboxStore((s) => s.close) + const step = useLightboxStore((s) => s.step) + const closeRef = React.useRef(null) + const count = images.length + + 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]) + + React.useEffect(() => { + if (count > 0) closeRef.current?.focus() + }, [count]) + + if (count === 0) return null + const current = images[index] ?? images[0] + if (!current) return null + const many = count > 1 + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={current.alt} + > +
+ {many && ( + + {index + 1} / {count} + + )} + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {current.alt} + {many && ( + <> + + + + )} +
+
+ ) +} 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/reports/reports-page.tsx b/apps/admin/src/features/reports/reports-page.tsx index 77c813f..164a037 100644 --- a/apps/admin/src/features/reports/reports-page.tsx +++ b/apps/admin/src/features/reports/reports-page.tsx @@ -18,6 +18,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 { openLightbox, type LightboxImage } from "@/components/shared/lightbox" import { BUCKET_VIEW, reportBucket, reportStatusView } from "@/lib/report-status" import { eventKindView } from "@/lib/event-kind" import { getReporterProfileId } from "@/features/reports/reporter-navigation" @@ -193,6 +194,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 (
@@ -243,19 +247,34 @@ 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 - + ) : ( )} - ) - })} + ), + )}
)} @@ -380,10 +399,18 @@ const ReportRow = React.memo(function ReportRow({ const pin = catPinSrc(item.category) const nav = useNav() const reporterId = getReporterProfileId(item.reporter.id) + const [brokenThumb, setBrokenThumb] = React.useState(null) + const thumb = item.thumbnailUrl !== brokenThumb ? item.thumbnailUrl : null return (
onSelect(item.id)}> -
- {pin ? ( +
+ {thumb ? ( + // eslint-disable-next-line @next/next/no-img-element + setBrokenThumb(thumb)} /> + ) : pin ? ( // eslint-disable-next-line @next/next/no-img-element ) : ( @@ -436,6 +463,39 @@ const ReportRow = React.memo(function ReportRow({ ) }) +function ReportPhotoFace({ + photoUrl, + pin, + category, +}: { + photoUrl: string | null + pin: string | null + category: ReportCategory +}) { + return ( + <> + {photoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Reporter photo + ) : ( + + {pin ? ( + // 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() @@ -477,6 +537,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() @@ -679,28 +744,33 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- {report.hasPhoto && ( -
- {photoUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - Reporter photo - ) : ( - - {pin ? ( - // 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/styles/admin.css b/apps/admin/src/styles/admin.css index daf34e9..ea06494 100644 --- a/apps/admin/src/styles/admin.css +++ b/apps/admin/src/styles/admin.css @@ -1065,6 +1065,17 @@ button:focus-visible { outline: none; box-shadow: var(--ring); } width: 100%; height: 100%; object-fit: contain; } +.qrow .leading.has-thumb { + background: var(--paper-2); + border: 1px solid var(--border-soft); + border-radius: 8px; + overflow: hidden; +} +.qrow .leading.has-thumb img { + width: 100%; height: 100%; + object-fit: cover; + display: block; +} .qrow .body { min-width: 0; } .qrow .body .top { @@ -2922,6 +2933,43 @@ 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%); + display: flex; align-items: center; justify-content: center; + animation: modal-pop 200ms cubic-bezier(0.25, 0.8, 0.3, 1); +} +.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-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); } From de08c55e9da05764b2402dc198dedc5f8a092bce Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 15 Sep 2026 23:25:16 -0700 Subject: [PATCH 2/7] adopt @civfix/shared 0.49.0 --- apps/admin/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 4e69082..a04f653 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -14,7 +14,7 @@ "clean": "rimraf .next out *.tsbuildinfo" }, "dependencies": { - "@civfix/shared": "^0.48.1", + "@civfix/shared": "^0.49.0", "@tanstack/react-query": "^5.62.7", "clsx": "^2.1.1", "leaflet": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85333d9..6306fb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: apps/admin: dependencies: '@civfix/shared': - specifier: ^0.48.1 - version: 0.48.1 + specifier: ^0.49.0 + version: 0.49.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.48.1': - resolution: {integrity: sha512-NXk+GK9YnsE0dzSDwyvyEjALTg1i0ylFTu7YKgSpzJZ5RNvSz6qx235sbuGIk7aKNLx0Bmu9SmOpmd92eTHcsQ==} + '@civfix/shared@0.49.0': + resolution: {integrity: sha512-xs6fB2p8bG26T5TRnKWKEQGq06zOm4DAjYiKgdHus6+CRifPlbpQaSIa2KcHxqaDyki3Dp/OwRxGhTjqQGlpgw==} engines: {node: '>=20'} '@emnapi/core@1.10.0': @@ -2484,7 +2484,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@civfix/shared@0.48.1': + '@civfix/shared@0.49.0': dependencies: zod: 3.25.76 From 584627c4f6712c1ec8173cf4a39c6d686e257906 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 16 Sep 2026 00:34:11 -0700 Subject: [PATCH 3/7] lightbox focus trap, expired-photo retry, and report thumb fixes --- apps/admin/src/components/shared/dialog.tsx | 3 + apps/admin/src/components/shared/lightbox.tsx | 85 +++++++++++++++---- .../shared/modal-accessibility.test.ts | 33 +++++++ .../src/components/shared/modal-focus.ts | 47 ++++++++++ .../src/features/reports/reports-page.tsx | 42 ++++++--- .../admin/src/features/reports/use-reports.ts | 7 ++ apps/admin/src/styles/admin.css | 33 ++++++- 7 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 apps/admin/src/components/shared/modal-accessibility.test.ts create mode 100644 apps/admin/src/components/shared/modal-focus.ts 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.tsx b/apps/admin/src/components/shared/lightbox.tsx index fb96163..2f1ee5d 100644 --- a/apps/admin/src/components/shared/lightbox.tsx +++ b/apps/admin/src/components/shared/lightbox.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" export interface LightboxImage { id: string @@ -23,32 +24,59 @@ export function startIndex(index: number, count: number): number { interface LightboxState { images: LightboxImage[] index: number - open: (images: LightboxImage[], index: number) => void + 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, - open: (images, index) => set({ images, index }), - close: () => 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): void { +export function openLightbox(images: LightboxImage[], index = 0, refresh?: () => void): void { if (images.length === 0) return - useLightboxStore.getState().open(images, startIndex(index, images.length)) + 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 closeRef = React.useRef(null) 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 @@ -68,18 +96,18 @@ export function LightboxHost() { return () => window.removeEventListener("keydown", onKey) }, [count, close, step]) - React.useEffect(() => { - if (count > 0) closeRef.current?.focus() - }, [count]) - - if (count === 0) return null - const current = images[index] ?? images[0] 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" @@ -92,12 +120,39 @@ export function LightboxHost() { {index + 1} / {count} )} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {current.alt} + {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 && ( <> ) : ( {m.thumbUrl ? ( // eslint-disable-next-line @next/next/no-img-element - + ) : ( )} @@ -376,6 +381,7 @@ function ReportDiscussion({ reportId }: { reportId: string }) { onRemove={onRemove} removing={removeMsg.isPending} nav={nav} + refreshPhotos={() => void q.refetch()} /> ), )} @@ -401,18 +407,28 @@ const ReportRow = React.memo(function ReportRow({ 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)}>
{thumb ? ( // eslint-disable-next-line @next/next/no-img-element - setBrokenThumb(thumb)} /> + setBrokenThumb(thumb)} + /> ) : pin ? ( // eslint-disable-next-line @next/next/no-img-element - + ) : ( )} @@ -476,7 +492,7 @@ function ReportPhotoFace({ <> {photoUrl ? ( // eslint-disable-next-line @next/next/no-img-element - Reporter photo + Reporter photo ) : ( {pin ? ( @@ -500,6 +516,7 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i const q = useReport(reportId) const nav = useNav() const toast = useToast() + const refreshMedia = useRefreshReportMedia(reportId) const setStatus = useSetReportStatus() const flag = useFlagReport() @@ -744,6 +761,7 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
+ {report.hasPhoto && (previewIndex >= 0 ? ( ) : ( {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 54e2263..c6b46e8 100644 --- a/apps/admin/src/features/reports/use-reports.ts +++ b/apps/admin/src/features/reports/use-reports.ts @@ -47,6 +47,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 ea06494..cd9f34a 100644 --- a/apps/admin/src/styles/admin.css +++ b/apps/admin/src/styles/admin.css @@ -1066,11 +1066,19 @@ button:focus-visible { outline: none; box-shadow: var(--ring); } object-fit: contain; } .qrow .leading.has-thumb { + position: relative; background: var(--paper-2); - border: 1px solid var(--border-soft); + 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; @@ -2937,9 +2945,31 @@ button.prow-main { color: inherit; font: inherit; background: none; border: 0; p .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; @@ -2947,6 +2977,7 @@ button.prow-main { color: inherit; font: inherit; background: none; border: 0; p 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; From 4d288c33c7f1d3645c85226be3eca34fc2ea8356 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 16 Sep 2026 15:45:56 -0700 Subject: [PATCH 4/7] adopt @civfix/shared 0.50.0 --- apps/admin/package.json | 2 +- apps/admin/src/features/hosts/broadcast-log.tsx | 1 + pnpm-lock.yaml | 10 +++++----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index a04f653..6b7c818 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -14,7 +14,7 @@ "clean": "rimraf .next out *.tsbuildinfo" }, "dependencies": { - "@civfix/shared": "^0.49.0", + "@civfix/shared": "^0.50.0", "@tanstack/react-query": "^5.62.7", "clsx": "^2.1.1", "leaflet": "^1.9.4", 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/pnpm-lock.yaml b/pnpm-lock.yaml index 6306fb0..f78e3b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: apps/admin: dependencies: '@civfix/shared': - specifier: ^0.49.0 - version: 0.49.0 + specifier: ^0.50.0 + version: 0.50.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.49.0': - resolution: {integrity: sha512-xs6fB2p8bG26T5TRnKWKEQGq06zOm4DAjYiKgdHus6+CRifPlbpQaSIa2KcHxqaDyki3Dp/OwRxGhTjqQGlpgw==} + '@civfix/shared@0.50.0': + resolution: {integrity: sha512-bteKU/bE5lKmoQvPWFgCVKBLCxzFqXVapLWg2+46FgbS9yEugGPv9zJQ88O+zUinv3na0Cvmzj+jLlN6FfyyYw==} engines: {node: '>=20'} '@emnapi/core@1.10.0': @@ -2484,7 +2484,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@civfix/shared@0.49.0': + '@civfix/shared@0.50.0': dependencies: zod: 3.25.76 From dc955c6c2c23b936d3e329ca00a26446b1a2a3b1 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 16 Sep 2026 18:38:47 -0700 Subject: [PATCH 5/7] adopt @civfix/shared 0.51.0 --- apps/admin/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 6b7c818..5fdb391 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -14,7 +14,7 @@ "clean": "rimraf .next out *.tsbuildinfo" }, "dependencies": { - "@civfix/shared": "^0.50.0", + "@civfix/shared": "^0.51.0", "@tanstack/react-query": "^5.62.7", "clsx": "^2.1.1", "leaflet": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f78e3b9..57fd726 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: apps/admin: dependencies: '@civfix/shared': - specifier: ^0.50.0 - version: 0.50.0 + specifier: ^0.51.0 + version: 0.51.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.50.0': - resolution: {integrity: sha512-bteKU/bE5lKmoQvPWFgCVKBLCxzFqXVapLWg2+46FgbS9yEugGPv9zJQ88O+zUinv3na0Cvmzj+jLlN6FfyyYw==} + '@civfix/shared@0.51.0': + resolution: {integrity: sha512-bWzOhGZITr8G6I0XkvXP5id+EymKgP+TSBWcXBjXmc80/c2Q1NY+i8HatCsz/RgaqV4+DIoLxm85YX6QECoYbA==} engines: {node: '>=20'} '@emnapi/core@1.10.0': @@ -2484,7 +2484,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@civfix/shared@0.50.0': + '@civfix/shared@0.51.0': dependencies: zod: 3.25.76 From 94c2daa764cd1b10a61df768453b985a31842491 Mon Sep 17 00:00:00 2001 From: Theo Date: Mon, 21 Sep 2026 23:06:14 -0700 Subject: [PATCH 6/7] adopt @civfix/shared 0.54.0 --- apps/admin/package.json | 2 +- apps/admin/src/features/reports/reports-page.tsx | 3 ++- pnpm-lock.yaml | 10 +++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 0a1df7b..5fd2a64 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.54.0", "@tanstack/react-query": "^5.62.7", "clsx": "^2.1.1", "leaflet": "^1.9.4", diff --git a/apps/admin/src/features/reports/reports-page.tsx b/apps/admin/src/features/reports/reports-page.tsx index 31754ff..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" @@ -22,7 +23,7 @@ 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 { BUCKET_VIEW, reportBucket, reportStatusView } from "@/lib/report-status" +import { reportStatusView } from "@/lib/report-status" import { eventKindView } from "@/lib/event-kind" import { getReporterProfileId } from "@/features/reports/reporter-navigation" import { routeActionFor, routeSendLabel, type RouteAction } from "@/features/reports/route-action" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a9fff7..75fff9f 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.54.0 + version: 0.54.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.54.0': + resolution: {integrity: sha512-H9vFZ+t9w/4UidgUYBNApeuqE2q9vuzXD0BmhZmeWOHJ2SeKjL7iPZOBL3Zpj8NGn+tq9ClcueYDUq4IXQcavw==} 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.54.0': dependencies: zod: 3.25.76 From fd48476644b07012cf2cc915670bb1dbcd23967d Mon Sep 17 00:00:00 2001 From: Theo Date: Tue, 22 Sep 2026 01:06:11 -0700 Subject: [PATCH 7/7] adopt @civfix/shared 0.55.0 --- apps/admin/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/admin/package.json b/apps/admin/package.json index 5fd2a64..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.54.0", + "@civfix/shared": "^0.55.0", "@tanstack/react-query": "^5.62.7", "clsx": "^2.1.1", "leaflet": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75fff9f..d0a9b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: apps/admin: dependencies: '@civfix/shared': - specifier: ^0.54.0 - version: 0.54.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.54.0': - resolution: {integrity: sha512-H9vFZ+t9w/4UidgUYBNApeuqE2q9vuzXD0BmhZmeWOHJ2SeKjL7iPZOBL3Zpj8NGn+tq9ClcueYDUq4IXQcavw==} + '@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.54.0': + '@civfix/shared@0.55.0': dependencies: zod: 3.25.76