Skip to content
Open
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<Link href>`, `router.push()`, `redirect()`, and route definitions) must therefore omit the `/v2` prefix — Next.js adds it. For example, `<Link href="/activists/123">`, not `<Link href="/v2/activists/123">`.
- 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -133,22 +132,6 @@ export function ActivistDetail({ activistId }: { activistId: number }) {

return (
<>
<div className="flex items-center gap-3">
<Link
href="/activists"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{/**
* 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.
*/}
<ArrowLeft className="h-4 w-4" />
View all Activists
</Link>
</div>

<div className="flex flex-wrap items-center justify-between gap-3">
<h1
className={`text-3xl font-bold ${
Expand Down
11 changes: 11 additions & 0 deletions frontend-v2/src/app/(authed)/activists/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
QueryClient,
} from '@tanstack/react-query'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { ContentWrapper } from '@/app/content-wrapper'
import { API_PATH, ApiClient } from '@/lib/api'
import { getCookies } from '@/lib/auth'
Expand Down Expand Up @@ -35,6 +37,15 @@ export default async function ActivistPage({

return (
<ContentWrapper size="lg" className="gap-6">
<div className="flex items-center gap-3">
<Link
href="/activists"
className="flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
View all Activists
</Link>
</div>
<HydrationBoundary state={dehydrate(queryClient)}>
<ActivistDetail activistId={activistId} />
</HydrationBoundary>
Expand Down
71 changes: 71 additions & 0 deletions frontend-v2/src/app/(authed)/activists/activist-sheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use client'
Comment thread
alexsapps marked this conversation as resolved.

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<number | null>(
null,
)
const [prevActivistId, setPrevActivistId] = useState<number | null>(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 (
<DialogPrimitive.Root
modal={false}
open={activistId !== null}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Content className="fixed inset-y-0 right-0 z-50 flex h-full w-full flex-col overflow-hidden border-l bg-background 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 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-2xl">
<DialogPrimitive.Title className="sr-only">
Activist Details
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
View and edit activist information
</DialogPrimitive.Description>

<div className="flex items-center justify-between border-b px-6 py-3">
{displayActivistId !== null && (
<Link
href={`/activists/${displayActivistId}`}
className="flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
Full page
</Link>
)}
<DialogPrimitive.Close className="ml-auto rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>

<div className="flex flex-1 flex-col gap-6 overflow-y-auto px-6 pb-6 pt-4">
{displayActivistId !== null && (
<ActivistDetail activistId={displayActivistId} />
)}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
)
}
Comment thread
alexsapps marked this conversation as resolved.
173 changes: 94 additions & 79 deletions frontend-v2/src/app/(authed)/activists/activists-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useMemo, useState } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useQueryState, parseAsInteger } from 'nuqs'
import {
apiClient,
API_PATH,
Expand All @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -157,92 +164,100 @@ export default function ActivistsPage({
const tableSort = isPlaceholderData ? settledTableState.sort : sort

return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold">Activists</h1>
</div>
<>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold">Activists</h1>
</div>

<ActivistFilters
filters={filters}
onFiltersChange={setFilters}
isAdmin={isAdmin}
isDirty={isDirty}
onReset={resetAll}
>
<ColumnSelector
visibleColumns={selectedColumns}
onColumnsChange={setSelectedColumns}
isChapterColumnShown={filters.searchAcrossChapters}
/>
<SortSelector
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 && (
<ActivistFilters
filters={filters}
onFiltersChange={setFilters}
isAdmin={isAdmin}
isDirty={isDirty}
onReset={resetAll}
>
<ColumnSelector
visibleColumns={selectedColumns}
onColumnsChange={setSelectedColumns}
isChapterColumnShown={filters.searchAcrossChapters}
/>
<SortSelector
label="Then by"
inactiveLabel="Then sort by"
value={sort[1]}
onChange={(secondary) => 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}
/>
)}
</ActivistFilters>
{isExplicitSort && (
<SortSelector
label="Then by"
inactiveLabel="Then sort by"
value={sort[1]}
onChange={(secondary) => setSort([sort[0], secondary])}
onClear={() => setSort([sort[0]])}
availableColumns={selectedColumns.filter(
(col) => col !== sort[0].column,
)}
/>
)}
</ActivistFilters>

{isLoading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
Loading activists...
</div>
)}
{isLoading && (
<div className="flex items-center justify-center py-12 text-muted-foreground">
Loading activists...
</div>
)}

{isError && (
<div className="flex items-center justify-center py-12 text-destructive">
{error instanceof Error
? error.message.replace(/^invalid query options:\s*/i, '')
: 'Failed to load activists. Please try again.'}
</div>
)}

{!isLoading && !isError && (
<>
{activists.length > 0 && (
<div className="text-sm text-muted-foreground">
{activists.length} activist
{activists.length !== 1 ? 's' : ''} shown
</div>
)}
{isError && (
<div className="flex items-center justify-center py-12 text-destructive">
{error instanceof Error
? error.message.replace(/^invalid query options:\s*/i, '')
: 'Failed to load activists. Please try again.'}
</div>
)}

<ActivistTable
activists={activists}
visibleColumns={tableColumns}
sort={tableSort}
onSortChange={setSort}
isStale={isPlaceholderData}
/>
{!isLoading && !isError && (
<>
{activists.length > 0 && (
<div className="text-sm text-muted-foreground">
{activists.length} activist
{activists.length !== 1 ? 's' : ''} shown
</div>
)}

{hasNextPage && (
<InfiniteScrollTrigger
onLoadMore={fetchNextPage}
isLoading={isFetchingNextPage}
canLoadMore={hasNextPage}
loadingLabel="Loading more activists…"
<ActivistTable
activists={activists}
visibleColumns={tableColumns}
sort={tableSort}
onSortChange={setSort}
onActivistClick={setSelectedActivistId}
isStale={isPlaceholderData}
/>
)}
</>
)}
</div>

{hasNextPage && (
<InfiniteScrollTrigger
onLoadMore={fetchNextPage}
isLoading={isFetchingNextPage}
canLoadMore={hasNextPage}
loadingLabel="Loading more activists…"
/>
)}
</>
)}
</div>

<ActivistSheet
activistId={selectedActivistId}
onClose={() => setSelectedActivistId(null)}
/>
</>
)
}
Loading
Loading