From 9146cb728a72b055eec467bcc4ad98aeac3113ba Mon Sep 17 00:00:00 2001 From: Alexander Taylor Date: Mon, 4 May 2026 00:47:27 +0000 Subject: [PATCH 1/2] feat(activists): show details in search page allows user to keep their place in search results while viewing details and editing activists. --- .../activists/[id]/activist-detail.tsx | 19 +- .../src/app/(authed)/activists/[id]/page.tsx | 11 ++ .../app/(authed)/activists/activist-sheet.tsx | 71 +++++++ .../app/(authed)/activists/activists-page.tsx | 173 ++++++++++-------- .../(authed)/activists/activists-table.tsx | 129 +++++++------ frontend-v2/src/components/ui/sheet.tsx | 141 ++++++++++++++ 6 files changed, 396 insertions(+), 148 deletions(-) create mode 100644 frontend-v2/src/app/(authed)/activists/activist-sheet.tsx create mode 100644 frontend-v2/src/components/ui/sheet.tsx diff --git a/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx b/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx index e15bed9c..efa1df03 100644 --- a/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx +++ b/frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx @@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import Link from 'next/link' -import { ArrowLeft, EyeOff, GitMerge, Pencil } from 'lucide-react' +import { EyeOff, GitMerge, Pencil } from 'lucide-react' import { API_PATH, apiClient, @@ -133,22 +132,6 @@ export function ActivistDetail({ activistId }: { activistId: number }) { return ( <> -
- - {/** - * Back button does not really go back - just goes to /activists. - * Detecting if router.back() actually goes back to /activists is - * complicated but may be implemented in the future to preserve the - * page state / scroll position. - */} - - View all Activists - -
-

+
+ + + View all Activists + +
diff --git a/frontend-v2/src/app/(authed)/activists/activist-sheet.tsx b/frontend-v2/src/app/(authed)/activists/activist-sheet.tsx new file mode 100644 index 00000000..4b143f24 --- /dev/null +++ b/frontend-v2/src/app/(authed)/activists/activist-sheet.tsx @@ -0,0 +1,71 @@ +'use client' + +import * as DialogPrimitive from '@radix-ui/react-dialog' +import Link from 'next/link' +import { ExternalLink, X } from 'lucide-react' +import { useState } from 'react' +import { ActivistDetail } from './[id]/activist-detail' + +interface ActivistSheetProps { + activistId: number | null + onClose: () => void +} + +export function ActivistSheet({ activistId, onClose }: ActivistSheetProps) { + // Holds the last non-null activistId. Never cleared back to null so content + // stays mounted and visible while Radix plays the exit animation. + const [displayActivistId, setDisplayActivistId] = useState( + null, + ) + const [prevActivistId, setPrevActivistId] = useState(null) + + // React derived-state pattern: update synchronously during render so content + // is present on the first open paint without waiting for an effect. + if (activistId !== null && activistId !== prevActivistId) { + setPrevActivistId(activistId) + setDisplayActivistId(activistId) + } + + return ( + { + if (!open) onClose() + }} + > + + + + Activist Details + + + View and edit activist information + + +
+ {displayActivistId !== null && ( + + + Full page + + )} + + + Close + +
+ +
+ {displayActivistId !== null && ( + + )} +
+
+
+
+ ) +} diff --git a/frontend-v2/src/app/(authed)/activists/activists-page.tsx b/frontend-v2/src/app/(authed)/activists/activists-page.tsx index 0f80fde1..e33820ef 100644 --- a/frontend-v2/src/app/(authed)/activists/activists-page.tsx +++ b/frontend-v2/src/app/(authed)/activists/activists-page.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { useInfiniteQuery } from '@tanstack/react-query' +import { useQueryState, parseAsInteger } from 'nuqs' import { apiClient, API_PATH, @@ -16,6 +17,7 @@ import { ActivistTable } from './activists-table' import { ActivistFilters } from './filters/activist-filters' import { ColumnSelector } from './column-selector' import { SortSelector } from './sort-selector' +import { ActivistSheet } from './activist-sheet' import { buildQueryOptions } from './filter-api-query' import type { ActivistsQueryState, SortColumn } from './query-state' import { DEFAULT_SORT } from './query-state' @@ -33,6 +35,11 @@ export default function ActivistsPage({ const { user } = useAuthedPageContext() const isAdmin = user.Roles.includes('admin') + const [selectedActivistId, setSelectedActivistId] = useQueryState( + 'activist', + parseAsInteger.withOptions({ history: 'push', scroll: false }), + ) + const { filters, selectedColumns, @@ -157,92 +164,100 @@ export default function ActivistsPage({ const tableSort = isPlaceholderData ? settledTableState.sort : sort return ( -
-
-

Activists

-
+ <> +
+
+

Activists

+
- - - - setSort( - sort.length > 1 && sort[1].column !== primary.column - ? [primary, sort[1]] - : [primary], - ) - } - onClear={() => setSort([])} - canClear={isExplicitSort} - availableColumns={selectedColumns} - /> - {isExplicitSort && ( + + setSort([sort[0], secondary])} - onClear={() => setSort([sort[0]])} - availableColumns={selectedColumns.filter( - (col) => col !== sort[0].column, - )} + label="Sort by" + value={isExplicitSort ? sort[0] : undefined} + onChange={(primary) => + setSort( + sort.length > 1 && sort[1].column !== primary.column + ? [primary, sort[1]] + : [primary], + ) + } + onClear={() => setSort([])} + canClear={isExplicitSort} + availableColumns={selectedColumns} /> - )} - + {isExplicitSort && ( + setSort([sort[0], secondary])} + onClear={() => setSort([sort[0]])} + availableColumns={selectedColumns.filter( + (col) => col !== sort[0].column, + )} + /> + )} + - {isLoading && ( -
- Loading activists... -
- )} + {isLoading && ( +
+ Loading activists... +
+ )} - {isError && ( -
- {error instanceof Error - ? error.message.replace(/^invalid query options:\s*/i, '') - : 'Failed to load activists. Please try again.'} -
- )} - - {!isLoading && !isError && ( - <> - {activists.length > 0 && ( -
- {activists.length} activist - {activists.length !== 1 ? 's' : ''} shown -
- )} + {isError && ( +
+ {error instanceof Error + ? error.message.replace(/^invalid query options:\s*/i, '') + : 'Failed to load activists. Please try again.'} +
+ )} - + {!isLoading && !isError && ( + <> + {activists.length > 0 && ( +
+ {activists.length} activist + {activists.length !== 1 ? 's' : ''} shown +
+ )} - {hasNextPage && ( - - )} - - )} -
+ + {hasNextPage && ( + + )} + + )} +
+ + setSelectedActivistId(null)} + /> + ) } diff --git a/frontend-v2/src/app/(authed)/activists/activists-table.tsx b/frontend-v2/src/app/(authed)/activists/activists-table.tsx index 58fa1017..800d9d33 100644 --- a/frontend-v2/src/app/(authed)/activists/activists-table.tsx +++ b/frontend-v2/src/app/(authed)/activists/activists-table.tsx @@ -28,6 +28,7 @@ interface ActivistTableProps { visibleColumns: ActivistColumnName[] sort: SortColumn[] onSortChange: (sort: SortColumn[]) => void + onActivistClick?: (id: number) => void isStale?: boolean } @@ -36,6 +37,7 @@ export function ActivistTable({ visibleColumns, sort, onSortChange, + onActivistClick, isStale = false, }: ActivistTableProps) { const columns = useMemo[]>(() => { @@ -86,17 +88,25 @@ export function ActivistTable({ cell: ({ row }) => { if (colName === 'name') { const displayName = getActivistDisplayName(row.original) + const nameClass = `truncate text-sm text-primary hover:underline ${ + displayName.isPlaceholder ? 'italic text-muted-foreground' : '' + }` return ( - { + if (e.ctrlKey || e.metaKey || e.shiftKey) return + e.preventDefault() + onActivistClick(row.original.id) + } + : undefined + } > {displayName.text} - + ) } @@ -117,7 +127,7 @@ export function ActivistTable({ }, } }) - }, [visibleColumns, sort, onSortChange, isStale]) + }, [visibleColumns, sort, onSortChange, onActivistClick, isStale]) const table = useReactTable({ data: activists, @@ -197,53 +207,70 @@ export function ActivistTable({
{activists.map((activist) => { const displayName = getActivistDisplayName(activist) + const cardClass = `block rounded-lg border bg-card p-4 transition-opacity hover:border-primary/50 text-left w-full ${ + isStale ? 'opacity-60' : '' + }` + const cardContent = ( +
+ {visibleColumns.map((colName) => { + const definition = COLUMN_DEFINITION_BY_NAME[colName] + const label = definition?.label || colName + const isBool = COLUMN_TYPE_BY_NAME[colName] === 'boolean' + const rawValue = activist[colName as keyof ActivistJSON] + const formattedValue = isBool + ? null + : colName === 'name' + ? displayName.text + : formatValue(rawValue, colName) - return ( + return ( +
+ + {label}: + + {isBool ? ( + rawValue ? ( + + ) : ( + + ) + ) : ( + + {formattedValue} + + )} +
+ ) + })} +
+ ) + + return onActivistClick ? ( + { + if (e.ctrlKey || e.metaKey || e.shiftKey) return + e.preventDefault() + onActivistClick(activist.id) + }} + > + {cardContent} + + ) : ( -
- {visibleColumns.map((colName) => { - const definition = COLUMN_DEFINITION_BY_NAME[colName] - const label = definition?.label || colName - const isBool = COLUMN_TYPE_BY_NAME[colName] === 'boolean' - const rawValue = activist[colName as keyof ActivistJSON] - const formattedValue = isBool - ? null - : colName === 'name' - ? displayName.text - : formatValue(rawValue, colName) - - return ( -
- - {label}: - - {isBool ? ( - rawValue ? ( - - ) : ( - - ) - ) : ( - - {formattedValue} - - )} -
- ) - })} -
+ {cardContent}
) })} diff --git a/frontend-v2/src/components/ui/sheet.tsx b/frontend-v2/src/components/ui/sheet.tsx new file mode 100644 index 00000000..b50794e3 --- /dev/null +++ b/frontend-v2/src/components/ui/sheet.tsx @@ -0,0 +1,141 @@ +'use client' + +import * as React from 'react' +import * as SheetPrimitive from '@radix-ui/react-dialog' +import { cva, type VariantProps } from 'class-variance-authority' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: + 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +) + +interface SheetContentProps + extends + React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = 'SheetHeader' + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = 'SheetFooter' + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} From a04aa55dcd7b5ba69d28c4dfcce4988fdccd3b59 Mon Sep 17 00:00:00 2001 From: Alexander Taylor Date: Mon, 4 May 2026 01:26:03 +0000 Subject: [PATCH 2/2] docs(ai): clarify when to use /v2 prefix in navigation --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d3a4d3eb..27b813ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Background context on how the stack fits together. ### frontend/backend routing -- The Go server reverse-proxies `/v2/*` to the Next.js app in `./frontend-v2` (see `proxyHandler` in `server/src/main.go`). Next.js itself is unaware of this prefix — its routes start at `/`, so internal links and route definitions should never include `/v2`. For example, link to `/activists/123`, not `/v2/activists/123`. +- The Go server reverse-proxies `/v2/*` to the Next.js app in `./frontend-v2` (see `proxyHandler` in `server/src/main.go`). Next.js has `basePath: '/v2'` configured (`next.config.ts`), so it automatically prepends `/v2` to all routes. Next.js navigation (``, `router.push()`, `redirect()`, and route definitions) must therefore omit the `/v2` prefix — Next.js adds it. For example, ``, not ``. - The Go API lives at the same origin. The `ApiClient` in `frontend-v2/src/lib/api.ts` calls paths like `api/activists`, `event/get`, etc. directly without a `/v2` prefix. ## frontend