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
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/admin/src/components/shared/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<HTMLDivElement>(current !== null)

React.useEffect(() => {
if (current?.kind === "prompt") setValue(current.defaultValue ?? "")
Expand Down Expand Up @@ -105,6 +107,7 @@ export function DialogHost() {
return (
<div className="modal-overlay" onClick={cancel}>
<div
ref={modalRef}
className="modal dialog-modal"
onClick={(e) => e.stopPropagation()}
role="dialog"
Expand Down
24 changes: 24 additions & 0 deletions apps/admin/src/components/shared/lightbox.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
177 changes: 177 additions & 0 deletions apps/admin/src/components/shared/lightbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Justify the PR size

This change exceeds the repository's 400-line pull-request limit, but its description does not include the required Size: justification. Add the explicit justification or split the modal accessibility, report-photo viewer, and announcement-label work into smaller changes; this repository requirement must be satisfied before merging.

Rule Used: # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for correctness, security and performance. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/admin/src/components/shared/lightbox.tsx
Line: 1

Comment:
**Justify the PR size**

This change exceeds the repository's 400-line pull-request limit, but its description does not include the required `Size:` justification. Add the explicit justification or split the modal accessibility, report-photo viewer, and announcement-label work into smaller changes; this repository requirement must be satisfied before merging.

**Rule Used:** # civfix review rules  civfix is a live civic-tech platform that will hold government contracts. Review every PR for **correctness, security and performance**. Flag real defects with evidence; skip style nits that lint already covers.  ## Repos  - **... ([source](https://app.greptile.com/civfix/-/custom-context?memory=39a53925-3d93-4c82-980e-27b67393717d))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code


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<LightboxState>((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<HTMLDivElement>(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 (
<div className="modal-overlay lightbox-overlay" onClick={close}>
<div
ref={frameRef}
className="lightbox"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={current.alt}
>
<div className="lightbox-bar">
{many && (
<span className="lightbox-count mono">
{index + 1} / {count}
</span>
)}
<button className="lightbox-btn" onClick={close} aria-label="Close">
<Icons.X size={16} />
</button>
</div>
{status !== "failed" && (
// eslint-disable-next-line @next/next/no-img-element

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Remove new code comments

The new lightbox adds an inline lint-suppression comment, and the new report chat image rendering adds the same kind of comment. This violates the directive that new code contain no comments. Use an implementation or project configuration that does not require new inline comments; this repository requirement must be satisfied before merging.

Rule Used: # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for correctness, security and performance. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/admin/src/components/shared/lightbox.tsx
Line: 128

Comment:
**Remove new code comments**

The new lightbox adds an inline lint-suppression comment, and the new report chat image rendering adds the same kind of comment. This violates the directive that new code contain no comments. Use an implementation or project configuration that does not require new inline comments; this repository requirement must be satisfied before merging.

**Rule Used:** # civfix review rules  civfix is a live civic-tech platform that will hold government contracts. Review every PR for **correctness, security and performance**. Flag real defects with evidence; skip style nits that lint already covers.  ## Repos  - **... ([source](https://app.greptile.com/civfix/-/custom-context?memory=39a53925-3d93-4c82-980e-27b67393717d))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

<img
key={`${current.id}:${attempt}`}
className={`lightbox-img ${status === "ready" ? "" : "pending"}`}
src={current.url}
alt={current.alt}
decoding="async"
onLoad={() => setLoad({ url: current.url, status: "ready", attempt })}
onError={() => setLoad({ url: current.url, status: "failed", attempt })}
/>
)}
{status === "loading" && (
<div className="lightbox-face" role="status" aria-live="polite">
<span className="op-spin" aria-hidden="true" />
<span>Loading photo...</span>
</div>
)}
{status === "failed" && (
<div className="lightbox-face" role="alert">
<span className="lightbox-face-title">This photo link expired</span>
<span className="lightbox-face-sub">
Photo links are short-lived. Refresh to fetch a new one.
</span>
<button type="button" className="lightbox-face-btn" onClick={retry}>
Refresh photo
</button>
</div>
)}
{many && (
<>
<button
className="lightbox-btn lightbox-prev"
onClick={() => step(-1)}
aria-label="Previous photo"
>
<Icons.ChevronLeft size={20} />
</button>
<button
className="lightbox-btn lightbox-next"
onClick={() => step(1)}
aria-label="Next photo"
>
<Icons.ChevronRight size={20} />
</button>
</>
)}
</div>
</div>
)
}
33 changes: 33 additions & 0 deletions apps/admin/src/components/shared/modal-accessibility.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>\(/)
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")
})
})
47 changes: 47 additions & 0 deletions apps/admin/src/components/shared/modal-focus.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(FOCUSABLE_SELECTOR)]
}

export function useModalFocus<T extends HTMLElement>(open: boolean): React.RefObject<T | null> {
const ref = React.useRef<T>(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
}
2 changes: 2 additions & 0 deletions apps/admin/src/components/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -52,6 +53,7 @@ export function AppShell() {

<Toast />
<DialogHost />
<LightboxHost />
</div>
)
}
1 change: 1 addition & 0 deletions apps/admin/src/features/hosts/broadcast-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const BROADCAST_KIND_LABEL: Record<BroadcastKind, string> = {
event_updated: "Event updated",
event_cancelled: "Event cancelled",
thank_you: "Thank you",
announcement: "Announcement",
}

export function BroadcastLog({ items }: { items: AdminBroadcastListItemDTO[] }) {
Expand Down
Loading
Loading