diff --git a/AGENTS.md b/AGENTS.md
index cca652c..17a8a7d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,18 +5,29 @@
When checking UI changes through a preview or browser, start the app with agent mode enabled:
```sh
-PORT=18203 AGENT_MODE=true pnpm dev
+PORT=xxxxx AGENT_MODE=true pnpm dev
```
-Use port 18203 or override the `PORT=` in the start command, but be sure to not use ports in the 3000-range
-because those are probably used by local dev servers on the user's machine, and it may fail for EADDRINUSE.
+Setting the `PORT=xxxxx` to an high enough port that is unlikely to hit
+another service or conflict with another workflow in preview (min 10000).
-Then open `/dashboard` directly. `AGENT_MODE` bypasses session and role authorization and disables the auth-based redirects between `/login` and `/dashboard`, so no real account or login flow is needed.
+Then open `/dashboard` directly (if not working on a page outside dashboard).
+`AGENT_MODE` bypasses session and role authorization and disables the auth-based
+redirects between `/login` and `/dashboard`, so no real account or login flow is needed.
+You can indipendently check /dashboard and /login for modifying those pages
-Use this flag only for local agent-driven development and previews. Never enable it in a deployed environment, and always verify that normal behavior still works with `AGENT_MODE=false`.
+Use this flag only for local agent-driven development and previews.
+Never enable it in a deployed environment.
-You can indipendently check /dashboard and /login for modifying those pages
+When modifing auth-related code or redirects between authed and non-authed contexts,
+always verify that normal behavior still works with `AGENT_MODE=false`.
> [!IMPORTANT]
> Do not run destructive actions across multiple rows, unless specific prompt indication or
> ask for user confirmation ALWAYS.
+
+## Git
+
+Always do commits with conventional commits.
+Set description when a commit includes lot of changes that cannot
+be divided into multiple commits, to specify the most important changes of the commit.
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 3ec092a..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Claude instructions
-
-Follow the project guidance in [AGENTS.md](./AGENTS.md). In particular, use `AGENT_MODE=true` when launching the app for preview or browser-based verification of authenticated dashboard pages.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 0000000..47dc3e3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/components.json b/components.json
index ae57d62..e02f8e2 100644
--- a/components.json
+++ b/components.json
@@ -12,6 +12,8 @@
},
"iconLibrary": "lucide",
"rtl": false,
+ "menuColor": "default",
+ "menuAccent": "subtle",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -19,7 +21,8 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
- "menuColor": "default",
- "menuAccent": "subtle",
- "registries": {}
+ "registries": {
+ "@iconiq": "https://iconiqui.com/r/{name}.json",
+ "@reui": "https://reui.io/r/{style}/{name}.json"
+ }
}
diff --git a/package.json b/package.json
index a92d29e..f9bbad8 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^0.545.0",
+ "motion": "^12.42.2",
"nitro": "latest",
"react": "^19.2.0",
"react-day-picker": "^10.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 69c602f..f982a79 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -53,6 +53,9 @@ importers:
lucide-react:
specifier: ^0.545.0
version: 0.545.0(react@19.2.7)
+ motion:
+ specifier: ^12.42.2
+ version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
nitro:
specifier: latest
version: 3.0.260610-beta(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(mongodb@7.1.0)(vite@8.1.4(@types/node@22.20.1)(jiti@2.7.0))
@@ -1940,6 +1943,20 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
+ framer-motion@12.42.2:
+ resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -2398,6 +2415,26 @@ packages:
socks:
optional: true
+ motion-dom@12.42.2:
+ resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
+
+ motion-utils@12.39.0:
+ resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
+
+ motion@12.42.2:
+ resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -5066,6 +5103,15 @@ snapshots:
forwarded@0.2.0: {}
+ framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ motion-dom: 12.42.2
+ motion-utils: 12.39.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
fresh@2.0.0: {}
fs-extra@11.3.6:
@@ -5386,6 +5432,20 @@ snapshots:
bson: 7.2.0
mongodb-connection-string-url: 7.0.1
+ motion-dom@12.42.2:
+ dependencies:
+ motion-utils: 12.39.0
+
+ motion-utils@12.39.0: {}
+
+ motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ framer-motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
ms@2.1.3: {}
nanoid@3.3.12: {}
diff --git a/src/components/app-mark.tsx b/src/components/app-mark.tsx
index c30929c..62302ea 100644
--- a/src/components/app-mark.tsx
+++ b/src/components/app-mark.tsx
@@ -5,10 +5,12 @@ import { cn } from "@/lib/utils"
export function AppMark({
compact = false,
sidebarResponsive = false,
+ className,
onClick,
}: {
compact?: boolean
sidebarResponsive?: boolean
+ className?: string
onClick?: React.MouseEventHandler
}) {
return (
@@ -16,7 +18,8 @@ export function AppMark({
to="/dashboard"
className={cn(
"flex w-max items-center gap-3 rounded-lg text-inherit outline-none focus-visible:ring-3 focus-visible:ring-current/25",
- sidebarResponsive && "group-data-[collapsible=icon]:gap-0"
+ sidebarResponsive && "group-data-[collapsible=icon]:gap-0",
+ className
)}
aria-label="PoliNetwork Admin home"
onClick={onClick}
diff --git a/src/components/dashboard-navigation.ts b/src/components/dashboard-navigation.ts
index 2e4fe1d..428c2ae 100644
--- a/src/components/dashboard-navigation.ts
+++ b/src/components/dashboard-navigation.ts
@@ -38,7 +38,7 @@ export const dashboardNavigation = [
items: [
{ title: "Users", url: "/dashboard/telegram/users", icon: UsersRound },
{ title: "Groups", url: "/dashboard/telegram/groups", icon: Database },
- { title: "Access grants", url: "/dashboard/telegram/grants", icon: ShieldCheck },
+ { title: "Grants", url: "/dashboard/telegram/grants", icon: ShieldCheck },
],
},
{
diff --git a/src/components/dashboard-sidebar.tsx b/src/components/dashboard-sidebar.tsx
index a124e66..f6667c4 100644
--- a/src/components/dashboard-sidebar.tsx
+++ b/src/components/dashboard-sidebar.tsx
@@ -1,11 +1,19 @@
import { Link, useRouterState } from "@tanstack/react-router"
import { ChevronRight, LogOut } from "lucide-react"
import { useEffect, useState } from "react"
+import { cn } from "@/lib/utils"
import type { AdminSession } from "@/server/api.functions"
import { AppMark } from "./app-mark"
import { accountNavigation, dashboardNavigation, overviewNavigation } from "./dashboard-navigation"
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu"
import {
Sidebar,
SidebarContent,
@@ -24,6 +32,13 @@ import {
const categoryStorageKey = "polinetwork-sidebar-categories"
+// Shared height and visual treatment for every primary nav row.
+const navItemClass = "h-10 gap-2.5 rounded-lg px-2.5"
+
+// The single strong "selected page" treatment: a controlled PoliNetwork-blue surface.
+const selectedPageClass =
+ "data-active:bg-sidebar-primary/15 data-active:font-medium data-active:text-sidebar-accent-foreground data-active:hover:bg-sidebar-primary/20"
+
type DashboardSidebarProps = React.ComponentProps & {
user: AdminSession["user"] | undefined
loggingOut: boolean
@@ -31,7 +46,9 @@ type DashboardSidebarProps = React.ComponentProps & {
}
export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: DashboardSidebarProps) {
- const pathname = useRouterState({ select: (state) => state.location.pathname })
+ const pathname = useRouterState({
+ select: (state) => state.location.pathname,
+ })
const { setOpenMobile } = useSidebar()
const [categoryState, setCategoryState] = useState>({})
@@ -56,21 +73,23 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb
})
}
+ const accountActive = isPathActive(pathname, accountNavigation.url)
+
return (
-
-
- setOpenMobile(false)} />
+
+
+ setOpenMobile(false)} className="min-w-0" />
-
-
-
+
+
+
setOpenMobile(false)} />}
+ className={cn(navItemClass, selectedPageClass)}
>
{overviewNavigation.title}
@@ -78,43 +97,97 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb
{dashboardNavigation.map((category) => {
- const routeIsActive = category.items.some((item) => pathname.startsWith(item.url))
+ const itemActiveStates = category.items.map((item) => isPathActive(pathname, item.url))
+ const routeIsActive = itemActiveStates.some(Boolean)
const open = categoryState[category.title] ?? routeIsActive
+ const connectorHeight = category.items.length * 38 + 2
+ const itemCenters = category.items.map((_, index) => 20 + index * 38)
+ const lastItemIndex = category.items.length - 1
+ const lastItemCenter = itemCenters[lastItemIndex]
+ const inactiveBranches = itemCenters
+ .map((center, index) =>
+ !itemActiveStates[index] && index !== lastItemIndex ? `M 0 ${center} H 10` : ""
+ )
+ .filter(Boolean)
+ .join(" ")
+ const railPath = [
+ `M 0 2 V ${lastItemCenter}`,
+ !itemActiveStates[lastItemIndex] ? "H 10" : "",
+ inactiveBranches,
+ ]
+ .filter(Boolean)
+ .join(" ")
return (
}
open={open}
onOpenChange={(nextOpen) => persistCategory(category.title, nextOpen)}
className="group/collapsible"
>
- }
- >
- {category.iconSrc ? (
-
- ) : (
-
- )}
- {category.title}
-
-
-
-
- {category.items.map((item) => (
-
- setOpenMobile(false)} />}
- >
-
- {item.title}
-
-
- ))}
-
-
+
+ }
+ className={cn(
+ navItemClass,
+ "text-sidebar-foreground/80",
+ routeIsActive && "bg-sidebar-accent/40 text-sidebar-accent-foreground"
+ )}
+ >
+ {category.iconSrc ? (
+
+ ) : (
+
+ )}
+ {category.title}
+
+
+
+
+
+ {category.items.map((item, index) => {
+ const itemIsActive = itemActiveStates[index]
+
+ return (
+
+ setOpenMobile(false)} />}
+ className={cn(
+ "h-9 gap-2.5 rounded-lg pl-2.5 text-sidebar-foreground/70",
+ "data-active:bg-sidebar-primary/15 data-active:font-medium data-active:text-sidebar-accent-foreground data-active:hover:bg-sidebar-primary/20",
+ "[&>svg]:text-sidebar-foreground/50 data-active:[&>svg]:text-sidebar-primary"
+ )}
+ >
+
+ {item.title}
+
+
+ )
+ })}
+
+
+
)
})}
@@ -122,36 +195,50 @@ export function DashboardSidebar({ user, loggingOut, onLogout, ...props }: Dashb
-
-
+
+
- setOpenMobile(false)} />}
- className="h-14 rounded-b-none"
- >
-
- {user?.image && }
-
- {initials(user?.name, user?.email)}
-
-
-
- {user?.name || "PoliNetwork member"}
-
- {user?.email || "Open account settings"}
-
-
-
-
-
-
-
-
- {loggingOut ? "Signing out…" : "Sign out"}
-
+
+
+ }
+ >
+
+ {user?.image && }
+
+ {initials(user?.name, user?.email)}
+
+
+
+ {user?.name || "PoliNetwork member"}
+
+
+
+
+ setOpenMobile(false)} />}
+ className="h-10 gap-2.5 px-2.5 py-0"
+ >
+
+ Settings
+
+
+
+ {loggingOut ? "Signing out…" : "Sign out"}
+
+
+
+
@@ -168,3 +255,10 @@ function initials(name?: string | null, email?: string) {
.map((part) => part[0]?.toUpperCase())
.join("")
}
+
+function isPathActive(pathname: string, url: string, exact = false) {
+ const normalizedPathname = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname
+ const normalizedUrl = url.length > 1 ? url.replace(/\/+$/, "") : url
+
+ return normalizedPathname === normalizedUrl || (!exact && normalizedPathname.startsWith(`${normalizedUrl}/`))
+}
diff --git a/src/components/reui/stepper.tsx b/src/components/reui/stepper.tsx
new file mode 100644
index 0000000..720f4fc
--- /dev/null
+++ b/src/components/reui/stepper.tsx
@@ -0,0 +1,421 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import {
+ Children,
+ createContext,
+ type HTMLAttributes,
+ isValidElement,
+ type ReactElement,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
+
+import { cn } from "@/lib/utils"
+
+// Types
+type StepperOrientation = "horizontal" | "vertical"
+type StepState = "active" | "completed" | "inactive" | "loading"
+type StepIndicators = {
+ active?: React.ReactNode
+ completed?: React.ReactNode
+ inactive?: React.ReactNode
+ loading?: React.ReactNode
+}
+
+interface StepperContextValue {
+ activeStep: number
+ setActiveStep: (step: number) => void
+ stepsCount: number
+ orientation: StepperOrientation
+ registerTrigger: (node: HTMLButtonElement) => void
+ unregisterTrigger: (node: HTMLButtonElement) => void
+ triggerNodes: HTMLButtonElement[]
+ focusNext: (currentIdx: number) => void
+ focusPrev: (currentIdx: number) => void
+ focusFirst: () => void
+ focusLast: () => void
+ indicators: StepIndicators
+}
+
+interface StepItemContextValue {
+ step: number
+ state: StepState
+ isDisabled: boolean
+ isLoading: boolean
+}
+
+const StepperContext = createContext(undefined)
+const StepItemContext = createContext(undefined)
+
+function useStepper() {
+ const ctx = useContext(StepperContext)
+ if (!ctx) throw new Error("useStepper must be used within a Stepper")
+ return ctx
+}
+
+function useStepItem() {
+ const ctx = useContext(StepItemContext)
+ if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
+ return ctx
+}
+
+interface StepperProps extends HTMLAttributes {
+ defaultValue?: number
+ value?: number
+ onValueChange?: (value: number) => void
+ orientation?: StepperOrientation
+ indicators?: StepIndicators
+}
+
+function Stepper({
+ defaultValue = 1,
+ value,
+ onValueChange,
+ orientation = "horizontal",
+ className,
+ children,
+ indicators = {},
+ ...props
+}: StepperProps) {
+ const [activeStep, setActiveStep] = useState(defaultValue)
+ const [triggerNodes, setTriggerNodes] = useState([])
+
+ // Register/unregister triggers
+ const registerTrigger = useCallback((node: HTMLButtonElement) => {
+ setTriggerNodes((prev) => {
+ if (prev.includes(node)) return prev
+ return [...prev, node]
+ })
+ }, [])
+ const unregisterTrigger = useCallback((node: HTMLButtonElement) => {
+ setTriggerNodes((prev) => prev.filter((trigger) => trigger !== node))
+ }, [])
+
+ const handleSetActiveStep = useCallback(
+ (step: number) => {
+ if (value === undefined) {
+ setActiveStep(step)
+ }
+ onValueChange?.(step)
+ },
+ [value, onValueChange]
+ )
+
+ const currentStep = value ?? activeStep
+
+ // Keyboard navigation logic
+ const focusTrigger = (idx: number) => {
+ if (triggerNodes[idx]) triggerNodes[idx].focus()
+ }
+ const focusNext = (currentIdx: number) => focusTrigger((currentIdx + 1) % triggerNodes.length)
+ const focusPrev = (currentIdx: number) => focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
+ const focusFirst = () => focusTrigger(0)
+ const focusLast = () => focusTrigger(triggerNodes.length - 1)
+
+ // Context value
+ const contextValue = useMemo(
+ () => ({
+ activeStep: currentStep,
+ setActiveStep: handleSetActiveStep,
+ stepsCount: Children.toArray(children).filter(
+ (child): child is ReactElement =>
+ isValidElement(child) && (child.type as { displayName?: string }).displayName === "StepperItem"
+ ).length,
+ orientation,
+ registerTrigger,
+ unregisterTrigger,
+ focusNext,
+ focusPrev,
+ focusFirst,
+ focusLast,
+ triggerNodes,
+ indicators,
+ }),
+ [currentStep, handleSetActiveStep, children, orientation, registerTrigger, unregisterTrigger, triggerNodes]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+interface StepperItemProps extends React.HTMLAttributes {
+ step: number
+ completed?: boolean
+ disabled?: boolean
+ loading?: boolean
+}
+
+function StepperItem({
+ step,
+ completed = false,
+ disabled = false,
+ loading = false,
+ className,
+ children,
+ ...props
+}: StepperItemProps) {
+ const { activeStep } = useStepper()
+
+ const state: StepState = completed || step < activeStep ? "completed" : activeStep === step ? "active" : "inactive"
+
+ const isLoading = loading && step === activeStep
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+type StepperTriggerProps = useRender.ComponentProps<"button">
+
+function StepperTrigger({ className, children, tabIndex, render, ...props }: StepperTriggerProps) {
+ const { state, isLoading } = useStepItem()
+ const stepperCtx = useStepper()
+ const {
+ setActiveStep,
+ activeStep,
+ registerTrigger,
+ unregisterTrigger,
+ triggerNodes,
+ focusNext,
+ focusPrev,
+ focusFirst,
+ focusLast,
+ } = stepperCtx
+ const { step, isDisabled } = useStepItem()
+ const isSelected = activeStep === step
+ const id = `stepper-tab-${step}`
+ const panelId = `stepper-panel-${step}`
+
+ // Register this trigger for keyboard navigation
+ const btnRef = useRef(null)
+ useEffect(() => {
+ const node = btnRef.current
+ if (!node) return
+ registerTrigger(node)
+ return () => unregisterTrigger(node)
+ }, [registerTrigger, unregisterTrigger])
+
+ // Find our index among triggers for navigation
+ const myIdx = useMemo(() => triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current), [triggerNodes])
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ switch (e.key) {
+ case "ArrowRight":
+ case "ArrowDown":
+ e.preventDefault()
+ if (myIdx !== -1 && focusNext) focusNext(myIdx)
+ break
+ case "ArrowLeft":
+ case "ArrowUp":
+ e.preventDefault()
+ if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
+ break
+ case "Home":
+ e.preventDefault()
+ if (focusFirst) focusFirst()
+ break
+ case "End":
+ e.preventDefault()
+ if (focusLast) focusLast()
+ break
+ case "Enter":
+ case " ":
+ e.preventDefault()
+ setActiveStep(step)
+ break
+ }
+ }
+
+ const defaultProps = {
+ role: "tab",
+ id,
+ "aria-selected": isSelected,
+ "aria-controls": panelId,
+ tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
+ "data-slot": "stepper-trigger",
+ "data-state": state,
+ "data-loading": isLoading,
+ className: cn(
+ "focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
+ "gap-2.5 rounded-full",
+ className
+ ),
+ onClick: () => setActiveStep(step),
+ onKeyDown: handleKeyDown,
+ disabled: isDisabled,
+ children,
+ }
+
+ return useRender({
+ defaultTagName: "button",
+ render,
+ ref: btnRef,
+ props: mergeProps<"button">(defaultProps, props),
+ })
+}
+
+function StepperIndicator({ children, className }: React.ComponentProps<"div">) {
+ const { state, isLoading } = useStepItem()
+ const { indicators } = useStepper()
+
+ return (
+
+
+ {indicators &&
+ ((isLoading && indicators.loading) ||
+ (state === "completed" && indicators.completed) ||
+ (state === "active" && indicators.active) ||
+ (state === "inactive" && indicators.inactive))
+ ? (isLoading && indicators.loading) ||
+ (state === "completed" && indicators.completed) ||
+ (state === "active" && indicators.active) ||
+ (state === "inactive" && indicators.inactive)
+ : children}
+
+
+ )
+}
+
+function StepperSeparator({ className }: React.ComponentProps<"div">) {
+ const { state } = useStepItem()
+
+ return (
+
+ )
+}
+
+function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
+ const { state } = useStepItem()
+
+ return (
+
+ {children}
+
+ )
+}
+
+function StepperDescription({ children, className }: React.ComponentProps<"div">) {
+ const { state } = useStepItem()
+
+ return (
+
+ {children}
+
+ )
+}
+
+function StepperNav({ children, className }: React.ComponentProps<"nav">) {
+ const { activeStep, orientation } = useStepper()
+
+ return (
+
+ )
+}
+
+function StepperPanel({ children, className }: React.ComponentProps<"div">) {
+ const { activeStep } = useStepper()
+
+ return (
+
+ {children}
+
+ )
+}
+
+interface StepperContentProps extends React.ComponentProps<"div"> {
+ value: number
+ forceMount?: boolean
+}
+
+function StepperContent({ value, forceMount, children, className }: StepperContentProps) {
+ const { activeStep } = useStepper()
+ const isActive = value === activeStep
+
+ if (!forceMount && !isActive) {
+ return null
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export {
+ useStepper,
+ useStepItem,
+ Stepper,
+ StepperItem,
+ StepperTrigger,
+ StepperIndicator,
+ StepperSeparator,
+ StepperTitle,
+ StepperDescription,
+ StepperPanel,
+ StepperContent,
+ StepperNav,
+ type StepperProps,
+ type StepperItemProps,
+ type StepperTriggerProps,
+ type StepperContentProps,
+}
diff --git a/src/components/telegram/create-grant-dialog.tsx b/src/components/telegram/create-grant-dialog.tsx
new file mode 100644
index 0000000..8755712
--- /dev/null
+++ b/src/components/telegram/create-grant-dialog.tsx
@@ -0,0 +1,445 @@
+import { useRouter } from "@tanstack/react-router"
+import { ArrowLeft, CalendarPlus, LoaderCircle, Search, UserRound, X } from "lucide-react"
+import { useState } from "react"
+import { toast } from "sonner"
+import {
+ Stepper,
+ StepperIndicator,
+ StepperItem,
+ StepperNav,
+ StepperTitle,
+ StepperTrigger,
+} from "@/components/reui/stepper"
+import type { TgUser } from "@/lib/api/types"
+import { createTelegramGrant, findTelegramUser } from "@/server/api.functions"
+import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"
+import { Button } from "../ui/button"
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog"
+import { Field, FieldError, FieldGroup, FieldLabel } from "../ui/field"
+import { Input } from "../ui/input"
+import { Textarea } from "../ui/textarea"
+import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"
+import { GrantDateTimeFields } from "./grant-date-time-fields"
+
+export type GrantDialogUser = Pick
+
+type CreateGrantDialogProps = {
+ user?: GrantDialogUser
+}
+
+const DISCARD_TOAST_ID = "discard-grant-dialog-changes"
+
+function toDateTimeInput(date: Date) {
+ const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
+ return local.toISOString().slice(0, 16)
+}
+
+function earliestGrantStart() {
+ const start = new Date()
+ start.setHours(0, 0, 0, 0)
+ return start
+}
+
+function displayName(user: GrantDialogUser) {
+ return [user.firstName, user.lastName].filter(Boolean).join(" ") || "Unnamed account"
+}
+
+function grantMutationError(error: string) {
+ if (error === "UNAUTHORIZED") return "You do not have permission to create grants."
+ if (error === "ALREADY_EXISTING") return "This user already has an ongoing grant."
+ return "The grant could not be created."
+}
+
+function UserInformation({
+ user,
+ clearable,
+ onClear,
+}: {
+ user: GrantDialogUser
+ clearable?: boolean
+ onClear?: () => void
+}) {
+ return (
+
+
+ {displayName(user)}
+
+ {user.username ? `@${user.username}` : "No Telegram username"} · Telegram ID {user.id}
+
+ {clearable && onClear && (
+
+
+
+ )}
+
+ )
+}
+
+function GrantStepper({
+ step,
+ canOpenDetails,
+ onStepChange,
+}: {
+ step: "user" | "details"
+ canOpenDetails: boolean
+ onStepChange: (step: "user" | "details") => void
+}) {
+ const steps = [
+ { title: "Target user", value: 1 },
+ { title: "Details", value: 2 },
+ ]
+
+ return (
+ {
+ if (value === 1) onStepChange("user")
+ if (value === 2 && canOpenDetails) onStepChange("details")
+ }}
+ className="p-5"
+ aria-label="Grant creation progress"
+ >
+
+ {steps.map((item) => (
+
+
+
+ Step {item.value}
+
+
+ {item.title}
+
+
+
+ ))}
+
+
+ )
+}
+
+export function CreateGrantDialog({ user: fixedUser }: CreateGrantDialogProps) {
+ const [open, setOpen] = useState(false)
+ const [step, setStep] = useState<"user" | "details">(fixedUser ? "details" : "user")
+ const [lookupBy, setLookupBy] = useState<"username" | "id">("username")
+ const [query, setQuery] = useState("")
+ const [selectedUser, setSelectedUser] = useState(fixedUser ?? null)
+ const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "not-found" | "error">("idle")
+ const [lookupMessage, setLookupMessage] = useState("")
+ const [validSince, setValidSince] = useState("")
+ const [validUntil, setValidUntil] = useState("")
+ const [reason, setReason] = useState("")
+ const [dirty, setDirty] = useState(false)
+ const [pending, setPending] = useState(false)
+ const router = useRouter()
+
+ const resetForm = () => {
+ setStep(fixedUser ? "details" : "user")
+ setLookupBy("username")
+ setQuery("")
+ setSelectedUser(fixedUser ?? null)
+ setLookupStatus("idle")
+ setLookupMessage("")
+ setValidSince("")
+ setValidUntil("")
+ setReason("")
+ setDirty(false)
+ }
+
+ const closeAndReset = () => {
+ toast.dismiss(DISCARD_TOAST_ID)
+ setOpen(false)
+ resetForm()
+ }
+
+ const requestClose = () => {
+ if (pending) return
+ if (!dirty) {
+ closeAndReset()
+ return
+ }
+
+ toast.warning("Discard grant changes?", {
+ id: DISCARD_TOAST_ID,
+ description: "Your edits will be lost.",
+ position: "bottom-center",
+ duration: Number.POSITIVE_INFINITY,
+ className: "grant-discard-toast",
+ action: {
+ label: "Discard",
+ onClick: closeAndReset,
+ },
+ cancel: {
+ label: "Keep editing",
+ onClick: () => undefined,
+ },
+ })
+ }
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (nextOpen) {
+ resetForm()
+ setOpen(true)
+ return
+ }
+ requestClose()
+ }
+
+ const changeLookupBy = (value: string[]) => {
+ const next = value[0]
+ if (next !== "username" && next !== "id") return
+ setLookupBy(next)
+ setQuery("")
+ setSelectedUser(null)
+ setLookupStatus("idle")
+ setLookupMessage("")
+ setDirty(true)
+ }
+
+ const lookup = async () => {
+ const normalized = query.trim()
+ const numericId = Number(normalized)
+ if (!normalized || (lookupBy === "id" && (!Number.isInteger(numericId) || numericId <= 0))) {
+ setLookupStatus("error")
+ setLookupMessage(lookupBy === "id" ? "Enter a positive numeric Telegram ID." : "Enter a Telegram username.")
+ return
+ }
+
+ setSelectedUser(null)
+ setLookupStatus("loading")
+ setLookupMessage("")
+ try {
+ const result = await findTelegramUser({
+ data: lookupBy === "username" ? { by: "username", username: normalized } : { by: "id", userId: numericId },
+ })
+ if (result.status === "found" && result.user) {
+ setSelectedUser(result.user)
+ setLookupStatus("idle")
+ } else {
+ setLookupStatus(result.status === "not-found" ? "not-found" : "error")
+ setLookupMessage(
+ result.status === "not-found"
+ ? `No Telegram user was found for this ${lookupBy === "id" ? "ID" : "username"}.`
+ : (result.message ?? "Telegram user lookup failed.")
+ )
+ }
+ } catch (error) {
+ console.error(error)
+ setLookupStatus("error")
+ setLookupMessage("Telegram user lookup failed. Check your connection and try again.")
+ }
+ }
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ const since = new Date(validSince)
+ const until = new Date(validUntil)
+ if (
+ !selectedUser ||
+ !validSince ||
+ !validUntil ||
+ Number.isNaN(since.getTime()) ||
+ Number.isNaN(until.getTime()) ||
+ since < earliestGrantStart() ||
+ until <= since ||
+ pending
+ )
+ return
+
+ setPending(true)
+ try {
+ const result = await createTelegramGrant({
+ data: { userId: selectedUser.id, since, until, reason: reason.trim() || undefined },
+ })
+ if (result.error) {
+ toast.error(grantMutationError(result.error))
+ return
+ }
+ toast.success(`Grant created for ${displayName(selectedUser)}.`)
+ closeAndReset()
+ await router.invalidate({ sync: true })
+ } catch (error) {
+ console.error(error)
+ toast.error("The grant could not be created. Check your permissions and try again.")
+ } finally {
+ setPending(false)
+ }
+ }
+
+ const sinceTime = new Date(validSince).getTime()
+ const untilTime = new Date(validUntil).getTime()
+ const minimumSince = toDateTimeInput(earliestGrantStart())
+ const invalidStart = Boolean(validSince) && (Number.isNaN(sinceTime) || sinceTime < earliestGrantStart().getTime())
+ const invalidEnd = Boolean(validUntil) && (Number.isNaN(untilTime) || (Boolean(validSince) && untilTime <= sinceTime))
+ const detailsUser = fixedUser ?? selectedUser
+
+ return (
+
+ )
+}
diff --git a/src/components/telegram/grant-date-time-fields.tsx b/src/components/telegram/grant-date-time-fields.tsx
new file mode 100644
index 0000000..5f2bc83
--- /dev/null
+++ b/src/components/telegram/grant-date-time-fields.tsx
@@ -0,0 +1,373 @@
+import { CalendarIcon, Clock3Icon } from "lucide-react"
+import { useEffect, useMemo, useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Calendar } from "@/components/ui/calendar"
+import { Field, FieldLabel } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Separator } from "@/components/ui/separator"
+import { WheelPicker, WheelPickerColumn } from "@/components/ui/wheel-picker"
+
+const LOCAL_DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
+
+function parseLocalDateTime(value: string) {
+ const match = LOCAL_DATE_TIME_PATTERN.exec(value)
+ if (!match) return undefined
+
+ const [, year, month, day, hours, minutes] = match
+ const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hours), Number(minutes))
+ return Number.isNaN(date.getTime()) ? undefined : date
+}
+
+function localDatePart(date: Date) {
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, "0")
+ const day = String(date.getDate()).padStart(2, "0")
+ return `${year}-${month}-${day}`
+}
+
+function localDateTimeValue(date: Date) {
+ const hours = String(date.getHours()).padStart(2, "0")
+ const minutes = String(date.getMinutes()).padStart(2, "0")
+ return `${localDatePart(date)}T${hours}:${minutes}`
+}
+
+function sameLocalDay(left: Date, right: Date) {
+ return (
+ left.getFullYear() === right.getFullYear() &&
+ left.getMonth() === right.getMonth() &&
+ left.getDate() === right.getDate()
+ )
+}
+
+function clampToMinimum(date: Date, minimum?: Date) {
+ return minimum && date < minimum ? new Date(minimum) : date
+}
+
+function isMobileDateTimePickerDevice() {
+ const userAgent = navigator.userAgent
+ const iPadOSDesktopMode = /Macintosh/.test(userAgent) && navigator.maxTouchPoints > 1
+ return /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent) || iPadOSDesktopMode
+}
+
+function useNativeDateTimePicker() {
+ const [nativePicker, setNativePicker] = useState(false)
+
+ useEffect(() => {
+ setNativePicker(isMobileDateTimePickerDevice())
+ }, [])
+
+ return nativePicker
+}
+
+export type AdaptiveDateTimeFieldProps = {
+ id: string
+ label: string
+ value: string
+ onChange: (value: string) => void
+ min?: string
+ invalid?: boolean
+ error?: React.ReactNode
+ required?: boolean
+ disabled?: boolean
+}
+
+type DesktopDateTimePickerProps = Pick<
+ AdaptiveDateTimeFieldProps,
+ "id" | "label" | "value" | "onChange" | "invalid" | "disabled"
+> & {
+ minimum?: Date
+}
+
+const HOUR_VALUES = Array.from({ length: 24 }, (_, hour) => String(hour).padStart(2, "0"))
+const MINUTE_VALUES = Array.from({ length: 60 }, (_, minute) => String(minute).padStart(2, "0"))
+
+function DesktopDateTimePicker({
+ id,
+ label,
+ value,
+ onChange,
+ minimum,
+ invalid = false,
+ disabled = false,
+}: DesktopDateTimePickerProps) {
+ const selected = parseLocalDateTime(value)
+ const [dateOpen, setDateOpen] = useState(false)
+ const [timeOpen, setTimeOpen] = useState(false)
+ const [draft, setDraft] = useState(() => clampToMinimum(selected ?? new Date(), minimum))
+
+ const updateTimeOpen = (nextOpen: boolean) => {
+ if (nextOpen) {
+ setDraft(clampToMinimum(selected ?? new Date(), minimum))
+ }
+ setTimeOpen(nextOpen)
+ }
+
+ const chooseDate = (date: Date | undefined) => {
+ if (!date) return
+
+ const next = new Date(date)
+ next.setHours(selected?.getHours() ?? 0, selected?.getMinutes() ?? 0, 0, 0)
+ onChange(localDateTimeValue(clampToMinimum(next, minimum)))
+ setDateOpen(false)
+ }
+
+ const chooseHour = (hour: string) => {
+ const next = new Date(draft)
+ next.setHours(Number(hour))
+ setDraft(clampToMinimum(next, minimum))
+ }
+
+ const chooseMinute = (minute: string) => {
+ const next = new Date(draft)
+ next.setMinutes(Number(minute))
+ setDraft(clampToMinimum(next, minimum))
+ }
+
+ const hourOptions = useMemo(
+ () =>
+ HOUR_VALUES.map((hour) => {
+ const endOfHour = new Date(draft)
+ endOfHour.setHours(Number(hour), 59, 59, 999)
+ return {
+ value: hour,
+ disabled: Boolean(minimum && sameLocalDay(draft, minimum) && endOfHour < minimum),
+ }
+ }),
+ [draft, minimum]
+ )
+
+ const minuteOptions = useMemo(
+ () =>
+ MINUTE_VALUES.map((minute) => {
+ const candidate = new Date(draft)
+ candidate.setMinutes(Number(minute), 0, 0)
+ return {
+ value: minute,
+ disabled: Boolean(minimum && sameLocalDay(draft, minimum) && candidate < minimum),
+ }
+ }),
+ [draft, minimum]
+ )
+
+ return (
+
+
+
+ }
+ >
+
+ {selected
+ ? selected.toLocaleDateString(undefined, { dateStyle: "medium" })
+ : `Choose ${label.toLocaleLowerCase()}`}
+
+
+
+
+
+
+
+ }
+ >
+
+ {selected
+ ? `${String(selected.getHours()).padStart(2, "0")}:${String(selected.getMinutes()).padStart(2, "0")}`
+ : "--:--"}
+
+
+ 24-hour time
+
+
+
+ :
+
+
+
+
+
+
+
+
+ )
+}
+
+export function AdaptiveDateTimeField({
+ id,
+ label,
+ value,
+ onChange,
+ min,
+ invalid = false,
+ error,
+ required = false,
+ disabled = false,
+}: AdaptiveDateTimeFieldProps) {
+ const nativePicker = useNativeDateTimePicker()
+ const minimum = min ? parseLocalDateTime(min) : undefined
+
+ return (
+
+ {label}
+ {nativePicker ? (
+ onChange(event.target.value)}
+ aria-invalid={invalid}
+ required={required}
+ disabled={disabled}
+ className="max-w-full [inline-size:-webkit-fill-available]"
+ />
+ ) : (
+
+ )}
+ {invalid && error}
+
+ )
+}
+
+export type GrantDateTimeFieldsProps = {
+ validSince: string
+ validUntil: string
+ onValidSinceChange: (value: string) => void
+ onValidUntilChange: (value: string) => void
+ minimumSince: string
+ minimumUntil: string
+ invalidStart: boolean
+ invalidEnd: boolean
+}
+
+export function GrantDateTimeFields({
+ validSince,
+ validUntil,
+ onValidSinceChange,
+ onValidUntilChange,
+ minimumSince,
+ minimumUntil,
+ invalidStart,
+ invalidEnd,
+}: GrantDateTimeFieldsProps) {
+ const setDuration = (hours: number) => {
+ const start = parseLocalDateTime(validSince)
+ if (!start) return
+ onValidUntilChange(localDateTimeValue(new Date(start.getTime() + hours * 60 * 60 * 1000)))
+ }
+
+ return (
+ <>
+ Choose today or a future date.
}
+ required
+ />
+
+ Choose a time after the grant starts.}
+ required
+ disabled={!validSince}
+ />
+
+ >
+ )
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..208b13b
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,266 @@
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx
index 8b371b9..7e06503 100644
--- a/src/components/ui/sidebar.tsx
+++ b/src/components/ui/sidebar.tsx
@@ -18,7 +18,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
-const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_WIDTH_ICON = "3.5rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
@@ -200,8 +200,8 @@ function Sidebar({
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
- "group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
+ state === "collapsed" && collapsible === "offcanvas" && "w-0",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
@@ -211,7 +211,10 @@ function Sidebar({
data-slot="sidebar-container"
data-side={side}
className={cn(
- "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
+ "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[translate,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=right]:right-0 md:flex",
+ state === "collapsed" &&
+ collapsible === "offcanvas" &&
+ (side === "left" ? "-translate-x-full" : "translate-x-full"),
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
diff --git a/src/components/ui/wheel-picker.tsx b/src/components/ui/wheel-picker.tsx
new file mode 100644
index 0000000..b637a2c
--- /dev/null
+++ b/src/components/ui/wheel-picker.tsx
@@ -0,0 +1,722 @@
+"use client";
+
+import {
+ type AnimationPlaybackControls,
+ animate,
+ type MotionValue,
+ motion,
+ useMotionTemplate,
+ useMotionValue,
+ useReducedMotion,
+ useTransform,
+} from "motion/react";
+import {
+ type CSSProperties,
+ createContext,
+ type KeyboardEvent as ReactKeyboardEvent,
+ type ReactNode,
+ type PointerEvent as ReactPointerEvent,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { cn } from "@/lib/utils";
+
+const HIDE_ANGLE = Math.PI / 2 - 0.04;
+const MAX_ANGLE = Math.PI / 2;
+const RUBBER_BAND = 0.32;
+const FLICK_POWER = 0.22;
+
+export type WheelPickerOption =
+ | string
+ | { value: string; label?: ReactNode; disabled?: boolean };
+
+type NormalizedOption = {
+ value: string;
+ label: ReactNode;
+ disabled: boolean;
+};
+
+export interface WheelPickerProps {
+ children: ReactNode;
+ className?: string;
+ itemHeight?: number;
+ visibleCount?: 3 | 5 | 7;
+ lens?: boolean;
+ "aria-label"?: string;
+}
+
+export interface WheelPickerColumnProps {
+ options: WheelPickerOption[];
+ value?: string;
+ defaultValue?: string;
+ onChange?: (
+ value: string,
+ option: { value: string; label: ReactNode }
+ ) => void;
+ onValueCommit?: (
+ value: string,
+ option: { value: string; label: ReactNode }
+ ) => void;
+ loop?: boolean;
+ disabled?: boolean;
+ name?: string;
+ className?: string;
+ id?: string;
+ "aria-label"?: string;
+}
+
+type WheelPickerContextValue = {
+ itemHeight: number;
+ visibleCount: number;
+ lens: boolean;
+};
+
+const WheelPickerContext = createContext({
+ itemHeight: 44,
+ visibleCount: 5,
+ lens: true,
+});
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max);
+}
+
+function normalizeOption(option: WheelPickerOption): NormalizedOption {
+ if (typeof option === "string") {
+ return { value: option, label: option, disabled: false };
+ }
+
+ return {
+ value: option.value,
+ label: option.label ?? option.value,
+ disabled: option.disabled ?? false,
+ };
+}
+
+function WheelPickerItem({
+ index,
+ itemHeight,
+ itemId,
+ label,
+ loopSize,
+ offset,
+ optionDisabled,
+ selected,
+ visibleCount,
+}: {
+ index: number;
+ itemHeight: number;
+ itemId: string;
+ label: ReactNode;
+ loopSize: number;
+ offset: MotionValue;
+ optionDisabled: boolean;
+ selected: boolean;
+ visibleCount: number;
+}) {
+ const step = Math.PI / (visibleCount + 2);
+ const radius = itemHeight / step;
+
+ const distance = useTransform(offset, (current: number) => {
+ let delta = index * itemHeight - current;
+
+ if (loopSize > 0) {
+ delta = ((delta % loopSize) + loopSize) % loopSize;
+ if (delta > loopSize / 2) {
+ delta -= loopSize;
+ }
+ }
+
+ return delta / itemHeight;
+ });
+
+ const angle = useTransform(distance, (d) =>
+ clamp(d * step, -MAX_ANGLE, MAX_ANGLE)
+ );
+ const y = useTransform(angle, (a) => radius * Math.sin(a));
+ const rotateX = useTransform(angle, (a) => `${(-a * 180) / Math.PI}deg`);
+ const opacity = useTransform(distance, (d) => {
+ const a = Math.abs(d) * step;
+
+ if (a >= HIDE_ANGLE) {
+ return 0;
+ }
+
+ return Math.cos(a) ** 1.15;
+ });
+ const visibility = useTransform(distance, (d) =>
+ Math.abs(d) * step >= HIDE_ANGLE ? "hidden" : "visible"
+ );
+ const emphasis = useTransform(distance, (d) => clamp(1 - Math.abs(d), 0, 1));
+ const mixPercent = useTransform(emphasis, (e) => (e * 100).toFixed(1));
+ const color = useMotionTemplate`color-mix(in oklab, var(--foreground) ${mixPercent}%, var(--muted-foreground))`;
+
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+export function WheelPickerColumn({
+ options,
+ value,
+ defaultValue,
+ onChange,
+ onValueCommit,
+ loop = false,
+ disabled = false,
+ name,
+ className,
+ id: idProp,
+ "aria-label": ariaLabel,
+}: WheelPickerColumnProps) {
+ const { itemHeight, visibleCount, lens } = useContext(WheelPickerContext);
+ const generatedId = useId();
+ const id = idProp ?? `wheel-column-${generatedId.replace(/[«»:]/g, "")}`;
+ const prefersReducedMotion = useReducedMotion();
+
+ const items = useMemo(() => options.map(normalizeOption), [options]);
+ const count = items.length;
+ const canLoop = loop && count >= visibleCount + 2;
+ const loopSize = canLoop ? count * itemHeight : 0;
+ const maxOffset = Math.max(0, (count - 1) * itemHeight);
+ const step = Math.PI / (visibleCount + 2);
+ const radius = itemHeight / step;
+
+ const initialIndexRef = useRef(null);
+
+ if (initialIndexRef.current === null) {
+ const initial = value ?? defaultValue;
+ const initialIndex =
+ initial == null ? -1 : items.findIndex((item) => item.value === initial);
+ const firstEnabled = items.findIndex((item) => !item.disabled);
+ initialIndexRef.current =
+ initialIndex >= 0 ? initialIndex : Math.max(0, firstEnabled);
+ }
+
+ const offset = useMotionValue(initialIndexRef.current * itemHeight);
+ const [activeIndex, setActiveIndex] = useState(initialIndexRef.current);
+
+ const activeIndexRef = useRef(activeIndex);
+ const committedIndexRef = useRef(activeIndex);
+ const itemsRef = useRef(items);
+ const onChangeRef = useRef(onChange);
+ const onValueCommitRef = useRef(onValueCommit);
+ const animationRef = useRef(null);
+ const wheelTimerRef = useRef | null>(null);
+ const containerRef = useRef(null);
+ const dragRef = useRef<{
+ pointerId: number;
+ startClientY: number;
+ startOffset: number;
+ moved: boolean;
+ } | null>(null);
+
+ useEffect(() => {
+ itemsRef.current = items;
+ onChangeRef.current = onChange;
+ onValueCommitRef.current = onValueCommit;
+ });
+
+ const wrapIndex = useCallback(
+ (index: number) => {
+ if (count === 0) {
+ return 0;
+ }
+
+ if (canLoop) {
+ return ((index % count) + count) % count;
+ }
+
+ return clamp(index, 0, count - 1);
+ },
+ [canLoop, count]
+ );
+
+ const nearestEnabled = useCallback(
+ (index: number, direction: number) => {
+ if (count === 0 || !items[wrapIndex(index)]?.disabled) {
+ return index;
+ }
+
+ const preferred = direction === 0 ? [1, -1] : [direction, -direction];
+
+ for (let span = 1; span < count; span += 1) {
+ for (const sign of preferred) {
+ const candidate = index + sign * span;
+
+ if (!canLoop && (candidate < 0 || candidate > count - 1)) {
+ continue;
+ }
+
+ if (!items[wrapIndex(candidate)]?.disabled) {
+ return candidate;
+ }
+ }
+ }
+
+ return index;
+ },
+ [canLoop, count, items, wrapIndex]
+ );
+
+ const stopAnimation = useCallback(() => {
+ animationRef.current?.stop();
+ animationRef.current = null;
+ }, []);
+
+ const commit = useCallback(() => {
+ const index = activeIndexRef.current;
+
+ if (committedIndexRef.current === index) {
+ return;
+ }
+
+ committedIndexRef.current = index;
+ const item = itemsRef.current[index];
+
+ if (item) {
+ onValueCommitRef.current?.(item.value, {
+ value: item.value,
+ label: item.label,
+ });
+ }
+ }, []);
+
+ const animateTo = useCallback(
+ (target: number, velocity = 0) => {
+ stopAnimation();
+
+ if (prefersReducedMotion) {
+ animationRef.current = animate(offset, target, {
+ duration: 0.16,
+ ease: "easeOut",
+ onComplete: commit,
+ });
+ return;
+ }
+
+ animationRef.current = animate(offset, target, {
+ type: "spring",
+ stiffness: 200,
+ damping: 24,
+ mass: 0.9,
+ velocity,
+ restDelta: 0.25,
+ restSpeed: 2,
+ onComplete: commit,
+ });
+ },
+ [commit, offset, prefersReducedMotion, stopAnimation]
+ );
+
+ const snapToIndex = useCallback(
+ (index: number, velocity = 0) => {
+ if (count === 0) {
+ return;
+ }
+
+ const safeIndex = clamp(wrapIndex(index), 0, count - 1);
+ let target: number;
+
+ if (canLoop) {
+ const current = Math.round(offset.get() / itemHeight);
+ let diff = (((safeIndex - wrapIndex(current)) % count) + count) % count;
+
+ if (diff > count / 2) {
+ diff -= count;
+ }
+
+ target = (current + diff) * itemHeight;
+ } else {
+ target = safeIndex * itemHeight;
+ }
+
+ animateTo(target, velocity);
+ },
+ [animateTo, canLoop, count, itemHeight, offset, wrapIndex]
+ );
+
+ const settle = useCallback(() => {
+ if (count === 0) {
+ return;
+ }
+
+ const velocity = offset.getVelocity();
+ let projected = offset.get() + velocity * FLICK_POWER;
+
+ if (!canLoop) {
+ projected = clamp(projected, 0, maxOffset);
+ }
+
+ const direction = velocity > 20 ? 1 : velocity < -20 ? -1 : 0;
+ const index = nearestEnabled(Math.round(projected / itemHeight), direction);
+ snapToIndex(wrapIndex(index), velocity);
+ }, [
+ canLoop,
+ count,
+ itemHeight,
+ maxOffset,
+ nearestEnabled,
+ offset,
+ snapToIndex,
+ wrapIndex,
+ ]);
+
+ useEffect(
+ () =>
+ offset.on("change", (current) => {
+ const index = wrapIndex(Math.round(current / itemHeight));
+
+ if (index === activeIndexRef.current) {
+ return;
+ }
+
+ activeIndexRef.current = index;
+ setActiveIndex(index);
+ const item = itemsRef.current[index];
+
+ if (item) {
+ onChangeRef.current?.(item.value, {
+ value: item.value,
+ label: item.label,
+ });
+ }
+ }),
+ [itemHeight, offset, wrapIndex]
+ );
+
+ useEffect(() => {
+ if (value == null) {
+ return;
+ }
+
+ const index = items.findIndex((item) => item.value === value);
+
+ if (index < 0 || index === activeIndexRef.current || dragRef.current) {
+ return;
+ }
+
+ snapToIndex(index);
+ }, [items, snapToIndex, value]);
+
+ useEffect(() => {
+ if (count === 0 || dragRef.current || animationRef.current) {
+ return;
+ }
+
+ const maxIndex = count - 1;
+
+ if (activeIndexRef.current > maxIndex) {
+ snapToIndex(maxIndex);
+ }
+ }, [count, snapToIndex]);
+
+ useEffect(() => {
+ if (dragRef.current || animationRef.current) {
+ return;
+ }
+
+ offset.set(activeIndexRef.current * itemHeight);
+ }, [itemHeight, offset]);
+
+ useEffect(() => {
+ if (canLoop || count === 0 || dragRef.current) {
+ return;
+ }
+
+ if (offset.get() > maxOffset) {
+ snapToIndex(count - 1);
+ }
+ }, [canLoop, count, maxOffset, offset, snapToIndex]);
+
+ useEffect(() => {
+ const node = containerRef.current;
+
+ if (!node || disabled) {
+ return;
+ }
+
+ const handleWheel = (event: WheelEvent) => {
+ event.preventDefault();
+ stopAnimation();
+
+ const delta =
+ event.deltaMode === 1 ? event.deltaY * (itemHeight / 2) : event.deltaY;
+ let next = offset.get() + delta * 0.55;
+
+ if (!canLoop) {
+ next = clamp(next, 0, maxOffset);
+ }
+
+ offset.set(next);
+
+ if (wheelTimerRef.current) {
+ clearTimeout(wheelTimerRef.current);
+ }
+
+ wheelTimerRef.current = setTimeout(() => {
+ wheelTimerRef.current = null;
+ settle();
+ }, 130);
+ };
+
+ node.addEventListener("wheel", handleWheel, { passive: false });
+
+ return () => {
+ node.removeEventListener("wheel", handleWheel);
+ };
+ }, [canLoop, disabled, itemHeight, maxOffset, offset, settle, stopAnimation]);
+
+ useEffect(
+ () => () => {
+ animationRef.current?.stop();
+
+ if (wheelTimerRef.current) {
+ clearTimeout(wheelTimerRef.current);
+ }
+ },
+ []
+ );
+
+ const handlePointerDown = (event: ReactPointerEvent) => {
+ if (disabled || event.button > 0) {
+ return;
+ }
+
+ stopAnimation();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ dragRef.current = {
+ pointerId: event.pointerId,
+ startClientY: event.clientY,
+ startOffset: offset.get(),
+ moved: false,
+ };
+ };
+
+ const handlePointerMove = (event: ReactPointerEvent) => {
+ const drag = dragRef.current;
+
+ if (!drag || drag.pointerId !== event.pointerId) {
+ return;
+ }
+
+ const delta = drag.startClientY - event.clientY;
+
+ if (Math.abs(delta) > 3) {
+ drag.moved = true;
+ }
+
+ let next = drag.startOffset + delta;
+
+ if (!canLoop) {
+ if (next < 0) {
+ next *= RUBBER_BAND;
+ } else if (next > maxOffset) {
+ next = maxOffset + (next - maxOffset) * RUBBER_BAND;
+ }
+ }
+
+ offset.set(next);
+ };
+
+ const handlePointerEnd = (event: ReactPointerEvent) => {
+ const drag = dragRef.current;
+
+ if (!drag || drag.pointerId !== event.pointerId) {
+ return;
+ }
+
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+
+ dragRef.current = null;
+
+ if (drag.moved || event.type === "pointercancel") {
+ settle();
+ return;
+ }
+
+ const node = containerRef.current;
+
+ if (!node) {
+ return;
+ }
+
+ const rect = node.getBoundingClientRect();
+ const tapY = event.clientY - (rect.top + rect.height / 2);
+ const steps = Math.round(Math.asin(clamp(tapY / radius, -1, 1)) / step);
+
+ if (steps === 0) {
+ commit();
+ return;
+ }
+
+ const current = Math.round(offset.get() / itemHeight);
+ const index = nearestEnabled(current + steps, steps > 0 ? 1 : -1);
+ snapToIndex(wrapIndex(index));
+ };
+
+ const moveBy = useCallback(
+ (delta: number) => {
+ const current = Math.round(offset.get() / itemHeight);
+ const index = nearestEnabled(current + delta, delta > 0 ? 1 : -1);
+ snapToIndex(wrapIndex(index));
+ },
+ [itemHeight, nearestEnabled, offset, snapToIndex, wrapIndex]
+ );
+
+ const handleKeyDown = (event: ReactKeyboardEvent) => {
+ if (disabled) {
+ return;
+ }
+
+ switch (event.key) {
+ case "ArrowDown":
+ event.preventDefault();
+ moveBy(1);
+ break;
+ case "ArrowUp":
+ event.preventDefault();
+ moveBy(-1);
+ break;
+ case "PageDown":
+ event.preventDefault();
+ moveBy(visibleCount);
+ break;
+ case "PageUp":
+ event.preventDefault();
+ moveBy(-visibleCount);
+ break;
+ case "Home":
+ event.preventDefault();
+ snapToIndex(nearestEnabled(0, 1));
+ break;
+ case "End":
+ event.preventDefault();
+ snapToIndex(nearestEnabled(count - 1, -1));
+ break;
+ default:
+ break;
+ }
+ };
+
+ return (
+
+ {lens ? (
+
+ ) : null}
+
+ {items.map((item, index) => (
+
+ ))}
+
+ {name ? (
+
+ ) : null}
+
+ );
+}
+
+export function WheelPicker({
+ children,
+ className,
+ itemHeight = 44,
+ visibleCount = 5,
+ lens = true,
+ "aria-label": ariaLabel,
+}: WheelPickerProps) {
+ const context = useMemo(
+ () => ({ itemHeight, visibleCount, lens }),
+ [itemHeight, lens, visibleCount]
+ );
+
+ return (
+
+ {/* biome-ignore lint/a11y/useSemanticElements: fieldset chrome would fight the barrel layout; a grouped div matches the listbox columns. */}
+
+ {children}
+
+
+ );
+}
diff --git a/src/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx
index be7ccb2..953388a 100644
--- a/src/routes/dashboard/index.tsx
+++ b/src/routes/dashboard/index.tsx
@@ -19,8 +19,8 @@ const areas = [
service: "Telegram",
},
{
- title: "Access grants",
- description: "Review active and scheduled temporary access for Telegram members.",
+ title: "Grants",
+ description: "Review active and scheduled Telegram grants authorized by the association board.",
to: "/dashboard/telegram/grants",
icon: ShieldCheck,
service: "Telegram",
diff --git a/src/routes/dashboard/telegram/grants.tsx b/src/routes/dashboard/telegram/grants.tsx
index 0319170..8a0c21c 100644
--- a/src/routes/dashboard/telegram/grants.tsx
+++ b/src/routes/dashboard/telegram/grants.tsx
@@ -6,6 +6,7 @@ import { EmptyState } from "@/components/empty-state"
import { LiveStatus } from "@/components/live-status"
import { DataPageSkeleton } from "@/components/loading-skeleton"
import { Pagination } from "@/components/pagination"
+import { CreateGrantDialog } from "@/components/telegram/create-grant-dialog"
import { Badge } from "@/components/ui/badge"
import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
@@ -95,7 +96,7 @@ function Grants() {
}),
grantColumnHelper.display({
id: "grantor",
- header: "Granted by",
+ header: "Authorized by",
cell: ({ row }) => (
}
onSearch={(value) => {
table.setGlobalFilter(value)
table.setPageIndex(0)
@@ -240,7 +242,7 @@ function Grants() {
title={hasSearch ? "No grants match this search" : "No grants in this view"}
text={
hasSearch
- ? "Clear the search or try a different user, grantor, or reason."
+ ? "Clear the search or try a different user, authorizer, or reason."
: "There are no grants for the selected status."
}
/>
diff --git a/src/routes/dashboard/telegram/users/$userId.tsx b/src/routes/dashboard/telegram/users/$userId.tsx
index 3dadd14..9f20822 100644
--- a/src/routes/dashboard/telegram/users/$userId.tsx
+++ b/src/routes/dashboard/telegram/users/$userId.tsx
@@ -2,7 +2,6 @@ import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import {
ArrowLeft,
CalendarClock,
- CalendarPlus,
ExternalLink,
History,
LoaderCircle,
@@ -19,6 +18,7 @@ import { useEffect, useState } from "react"
import { toast } from "sonner"
import { LiveStatus } from "@/components/live-status"
import { DetailPageSkeleton } from "@/components/loading-skeleton"
+import { CreateGrantDialog } from "@/components/telegram/create-grant-dialog"
import {
AlertDialog,
AlertDialogAction,
@@ -34,7 +34,7 @@ import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import {
Combobox,
ComboboxContent,
@@ -53,13 +53,10 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
import type { TgUserRole } from "@/lib/api/types"
import {
addTelegramGroupAdmin,
addTelegramUserRole,
- createTelegramGrant,
getTelegramUserDetails,
interruptTelegramGrant,
removeTelegramUserRole,
@@ -111,7 +108,7 @@ function UserProfile() {
)
}
- const { user, roles, configuredRoles, groupAdmin, groups, messages, audits, grant, grantStatus } = data
+ const { user, roles, configuredRoles, groupAdmin, groups, messages, audits, ongoingGrant, scheduledGrants } = data
const administeredGroups = groupAdmin.filter((entry): entry is NonNullable => entry !== null)
const displayName = [user.firstName, user.lastName].filter(Boolean).join(" ") || "Unnamed account"
@@ -175,27 +172,36 @@ function UserProfile() {
- ) : (
-
- )
+ <>
+
+ {ongoingGrant && }
+ >
}
>
- {grant ? (
-
-
-
- {grantStatus === "scheduled" ? "Scheduled" : "Active"}
-
-
- {formatDate(grant.validSince)}
- {formatDate(grant.validUntil)}
-
+ {ongoingGrant || scheduledGrants.length ? (
+
+ {ongoingGrant && (
+
+ )}
+ {scheduledGrants.map((scheduledGrant, index) => (
+
+ ))}
+
) : (
- No active or scheduled grant
+ No active or scheduled grants
)}
@@ -330,16 +336,16 @@ function SummaryCard({
}) {
return (
-
+
-
+
{label}
{children}
- {actions && {actions}
}
+ {actions && {actions}}
)
}
@@ -352,6 +358,29 @@ function Definition({ label, children }: { label: string; children: React.ReactN
)
}
+
+function GrantDetails({
+ label,
+ status,
+ validSince,
+ validUntil,
+}: {
+ label: string
+ status: "Active" | "Scheduled"
+ validSince: Date | string
+ validUntil: Date | string
+}) {
+ return (
+
+
+ {status}
+
+ {formatDate(validSince)}
+ {formatDate(validUntil)}
+
+ )
+}
+
function SectionEmpty({ text }: { text: string }) {
return (
@@ -459,7 +488,7 @@ function RoleDialog({
render={