diff --git a/src/routes/users/index.tsx b/src/routes/users/index.tsx index d5c6be3..44587e4 100644 --- a/src/routes/users/index.tsx +++ b/src/routes/users/index.tsx @@ -1,6 +1,17 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; -import { ChevronLeft, ChevronRight, Info, Search, Send, Users, X } from "lucide-react"; +import { + ChevronLeft, + ChevronRight, + Info, + Minus, + Plus, + Search, + Send, + Slash, + Users, + X, +} from "lucide-react"; import { cn } from "cn"; import { type UserListPage, @@ -40,59 +51,103 @@ function formatDate(value: string) { return new Date(value).toLocaleDateString(undefined, { dateStyle: "medium" }); } -/** Any, only people who have it, or only people who do not. */ +type Presence = "yes" | "no" | undefined; + +/** The order a trait pill steps through on each click. */ +const PRESENCE_CYCLE = [undefined, "yes", "no"] as const; + +const PRESENCE_STATES = { + any: { + Glyph: Slash, + label: "any", + short: "any", + pill: "border-border bg-background text-muted-foreground hover:border-foreground/30 hover:text-foreground", + glyph: "bg-muted text-muted-foreground", + }, + yes: { + Glyph: Plus, + label: "has it", + short: "with", + pill: "border-emerald-600/40 bg-emerald-600/10 text-emerald-700 dark:border-emerald-400/40 dark:text-emerald-300", + glyph: "bg-emerald-600 text-white dark:bg-emerald-500", + }, + no: { + Glyph: Minus, + label: "does not have it", + short: "without", + pill: "border-destructive/40 bg-destructive/10 text-destructive dark:border-red-400/40 dark:text-red-300", + glyph: "bg-destructive text-white", + }, +} as const; + +const presenceState = (value: Presence) => PRESENCE_STATES[value ?? "any"]; + +/** Steps forward through any, has it, and does not have it; backward with Shift. */ +function cyclePresence(value: Presence, backward: boolean): Presence { + const index = PRESENCE_CYCLE.indexOf(value); + const step = backward ? PRESENCE_CYCLE.length - 1 : 1; + return PRESENCE_CYCLE[(index + step) % PRESENCE_CYCLE.length]; +} + +/** A pill that cycles through any, only people who have it, or only people who do not. */ function TraitFilter({ trait, value, onChange, }: { trait: (typeof USER_TRAITS)[number]; - value: "yes" | "no" | undefined; - onChange: (value: "yes" | "no" | undefined) => void; + value: Presence; + onChange: (value: Presence) => void; }) { const Icon = TRAIT_ICONS[trait.key]; - const options = [ - { value: undefined, label: "Any" }, - { value: "yes", label: "Yes" }, - { value: "no", label: "No" }, - ] as const; + const state = presenceState(value); + const next = presenceState(cyclePresence(value, false)); return ( -
- - {trait.label} -
-+ Click a filter to cycle it: + {Object.values(PRESENCE_STATES).map((state) => ( + + + {state.short} + + ))} +
); } @@ -125,6 +180,7 @@ function UsersIndex() { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [revision, setRevision] = useState(0); + const [announcement, setAnnouncement] = useState(""); const key = userSearchParams(search).toString(); const update = (changes: Partial