) {
)
}
-function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+function DataTableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
- ) {
)
}
-function TableCaption({
- className,
- ...props
-}: React.ComponentProps<"caption">) {
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
-
)
}
+function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
export {
Table,
+ TableSurface,
TableHeader,
TableBody,
TableFooter,
TableHead,
+ DataTableHead,
TableRow,
TableCell,
TableCaption,
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
deleted file mode 100644
index c298661..0000000
--- a/src/components/ui/tabs.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-"use client"
-
-import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
-import { cva, type VariantProps } from "class-variance-authority"
-
-import { cn } from "@/lib/utils/shadcn"
-
-function Tabs({
- className,
- orientation = "horizontal",
- ...props
-}: TabsPrimitive.Root.Props) {
- return (
-
- )
-}
-
-const tabsListVariants = cva(
- "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
- {
- variants: {
- variant: {
- default: "bg-background border",
- line: "gap-1 bg-transparent",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
-)
-
-function TabsList({
- className,
- variant = "default",
- ...props
-}: TabsPrimitive.List.Props & VariantProps) {
- return (
-
- )
-}
-
-function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
- return (
-
- )
-}
-
-function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
- return (
-
- )
-}
-
-export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
index c0eacbf..83974ed 100644
--- a/src/components/ui/textarea.tsx
+++ b/src/components/ui/textarea.tsx
@@ -1,6 +1,6 @@
-import * as React from "react"
+import type * as React from "react"
-import { cn } from "@/lib/utils/shadcn"
+import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx
index 34d955a..86342fe 100644
--- a/src/components/ui/toggle-group.tsx
+++ b/src/components/ui/toggle-group.tsx
@@ -1,12 +1,9 @@
-"use client"
-
-import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
-import { type VariantProps } from "class-variance-authority"
-
-import { cn } from "@/lib/utils/shadcn"
+import type { VariantProps } from "class-variance-authority"
+import * as React from "react"
import { toggleVariants } from "@/components/ui/toggle"
+import { cn } from "@/lib/utils"
const ToggleGroupContext = React.createContext<
VariantProps & {
@@ -16,7 +13,7 @@ const ToggleGroupContext = React.createContext<
>({
size: "default",
variant: "default",
- spacing: 0,
+ spacing: 2,
orientation: "horizontal",
})
@@ -24,7 +21,7 @@ function ToggleGroup({
className,
variant,
size,
- spacing = 0,
+ spacing = 2,
orientation = "horizontal",
children,
...props
@@ -47,9 +44,7 @@ function ToggleGroup({
)}
{...props}
>
-
+
{children}
diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx
index 9ecf9c8..1d47b60 100644
--- a/src/components/ui/toggle.tsx
+++ b/src/components/ui/toggle.tsx
@@ -1,12 +1,10 @@
-"use client"
-
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "@/lib/utils/shadcn"
+import { cn } from "@/lib/utils"
const toggleVariants = cva(
- "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-primary data-[state=on]:bg-primary dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -14,8 +12,7 @@ const toggleVariants = cva(
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
- default:
- "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ default: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
@@ -33,13 +30,7 @@ function Toggle({
size = "default",
...props
}: TogglePrimitive.Props & VariantProps) {
- return (
-
- )
+ return
}
export { Toggle, toggleVariants }
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index b64148d..7824c7d 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -1,20 +1,9 @@
-"use client"
-
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
-import { cn } from "@/lib/utils/shadcn"
+import { cn } from "@/lib/utils"
-function TooltipProvider({
- delay = 0,
- ...props
-}: TooltipPrimitive.Provider.Props) {
- return (
-
- )
+function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
+ return
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
@@ -34,10 +23,7 @@ function TooltipContent({
children,
...props
}: TooltipPrimitive.Popup.Props &
- Pick<
- TooltipPrimitive.Positioner.Props,
- "align" | "alignOffset" | "side" | "sideOffset"
- >) {
+ Pick) {
return (
{children}
-
diff --git a/src/components/ui/wheel-picker.tsx b/src/components/ui/wheel-picker.tsx
new file mode 100644
index 0000000..6ae73f1
--- /dev/null
+++ b/src/components/ui/wheel-picker.tsx
@@ -0,0 +1,678 @@
+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/components/user-select.tsx b/src/components/user-select.tsx
deleted file mode 100644
index 68b2a14..0000000
--- a/src/components/user-select.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-"use client"
-
-import { Search, X } from "lucide-react"
-import { useState } from "react"
-import { toast } from "sonner"
-import { fmtUser } from "@/lib/utils/telegram"
-import { getUserInfo, searchUserInfo } from "@/server/actions/users"
-import type { TgUser } from "@/server/trpc/types"
-import { Button } from "./ui/button"
-import { Input } from "./ui/input"
-import { Label } from "./ui/label"
-import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"
-
-type Props = {
- onUser(user: TgUser): void
- onReset(): void
-}
-
-export function UserSelect({ onUser, onReset }: Props) {
- const [user, setUser] = useState(null)
- const [searchType, setSearchType] = useState<"username" | "id">("username")
- const [username, setUsername] = useState("")
- const [id, setId] = useState("")
-
- async function search() {
- const user = searchType === "username" ? await searchUserInfo(username) : await getUserInfo(parseInt(id, 10))
- if (!user) return toast.error(`User with this ${searchType === "id" ? "ID" : "username"} not found`)
- setUser(user)
- onUser(user)
- return
- }
-
- async function reset() {
- setUser(null)
- setUsername("")
- setId("")
- onReset()
- }
-
- async function submit(e: React.FormEvent) {
- e.preventDefault()
- await search()
- }
-
- return (
-
-
-
Search User by
-
v[0] && setSearchType(v[0] as "username" | "id")}
- >
-
- @ Username
-
-
- # ID
-
-
-
-
-
-
{user ? fmtUser(user) : ""}
-
- )
-}
diff --git a/src/components/web-header.tsx b/src/components/web-header.tsx
deleted file mode 100644
index bfe2628..0000000
--- a/src/components/web-header.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client"
-
-import type { ReactNode } from "react"
-import { Button } from "@/components/ui/button"
-
-interface WebHeaderProps {
- title: string
- description?: string
- action?: {
- label: string
- icon?: ReactNode
- onClick: () => void
- }
-}
-
-export default function WebHeader({ title, description, action }: WebHeaderProps) {
- return (
-
-
-
- {title}
-
- {description &&
{description}
}
-
- {action && (
-
- {action.icon}
- {action.label}
-
- )}
-
- )
-}
diff --git a/src/constants.ts b/src/constants.ts
deleted file mode 100644
index b0d438f..0000000
--- a/src/constants.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const COOKIES = {
- SIDEBAR_OPEN: "sidebar_open",
- SIDEBAR_CATEGORY_STATE: "sidebar_category_state",
-} as const
diff --git a/src/env.ts b/src/env.ts
index dfb523b..e987ebf 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -1,43 +1,27 @@
-import { createEnv } from "@t3-oss/env-nextjs"
+import { createEnv } from "@t3-oss/env-core"
import { z } from "zod"
+import { resolveBackendUrl } from "@/server/runtime-env"
export const env = createEnv({
- /**
- * Specify your server-side environment variables schema here. This way you can ensure the app
- * isn't built with invalid env vars.
- */
server: {
- BACKEND_URL: z.string().default("http://localhost:3000"),
- ADMIN_ORG_EMAIL: z.string().describe("Email address of admin Azure account."),
- NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
+ AGENT_MODE: z
+ .enum(["true", "false"])
+ .default("false")
+ .transform((value) => value === "true"),
+ NODE_ENV: z.enum(["development", "test", "production"]),
+ BACKEND_URL: z.url(),
},
- /**
- * Specify your client-side environment variables schema here. This way you can ensure the app
- * isn't built with invalid env vars. To expose them to the client, prefix them with
- * `NEXT_PUBLIC_`.
- */
- client: {
- // NEXT_PUBLIC_CLIENTVAR: z.string(),
- },
+ clientPrefix: "VITE_",
+
+ client: {},
+
+ shared: {},
- /**
- * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
- * middlewares) or client-side so we need to destruct manually.
- */
runtimeEnv: {
- ADMIN_ORG_EMAIL: process.env.ADMIN_ORG_EMAIL,
- BACKEND_URL: process.env.BACKEND_URL,
- NODE_ENV: process.env.NODE_ENV,
+ ...process.env,
+ BACKEND_URL: resolveBackendUrl(process.env.BACKEND_URL, process.env.NODE_ENV, process.env.npm_lifecycle_event),
},
- /**
- * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
- * useful for Docker builds.
- */
- skipValidation: !!process.env.SKIP_ENV_VALIDATION,
- /**
- * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
- * `SOME_VAR=''` will throw an error.
- */
+
emptyStringAsUndefined: true,
})
diff --git a/src/features/account/account-page.tsx b/src/features/account/account-page.tsx
new file mode 100644
index 0000000..8d978c1
--- /dev/null
+++ b/src/features/account/account-page.tsx
@@ -0,0 +1,85 @@
+import { LoaderCircle, RefreshCw } from "lucide-react"
+import { PageHeader } from "@/components/page-header"
+import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert"
+import { Button } from "@/components/ui/button"
+import type { AdminSession } from "@/lib/auth"
+import { ProfileDetailsCard, ProfileSummaryCard, TelegramIdentityCard } from "./profile-sections"
+import { PasskeysCard, SessionsCard } from "./security-sections"
+import { useAccount } from "./use-account"
+
+export function AccountPage({
+ initialSession,
+ telegramRoles,
+}: {
+ initialSession: AdminSession
+ telegramRoles: string[]
+}) {
+ const account = useAccount(initialSession)
+
+ return (
+
+
+ {account.notice && (
+
+ {account.notice.text}
+
+ )}
+ {account.securityError && (
+
+ {account.securityError}
+
+ void account.refreshSecurityData(true)}
+ disabled={account.securityRefreshing}
+ >
+ {account.securityRefreshing ? (
+
+ ) : (
+
+ )}
+ Retry security data
+
+
+
+ )}
+
+
+
void account.uploadImage(file)}
+ onRemove={() => void account.removeImage()}
+ />
+ void account.updateName(event)}
+ />
+
+ void account.addPasskey()}
+ onDelete={(id) => void account.deletePasskey(id)}
+ />
+ void account.revokeOtherSessions()}
+ onLogout={() => void account.logout()}
+ />
+
+
+ )
+}
diff --git a/src/features/account/account.functions.ts b/src/features/account/account.functions.ts
new file mode 100644
index 0000000..18c1df1
--- /dev/null
+++ b/src/features/account/account.functions.ts
@@ -0,0 +1,15 @@
+import { createServerFn } from "@tanstack/react-start"
+import { authenticatedMiddleware } from "@/server/auth.middleware"
+import { parseProfilePictureForm } from "./account.validation"
+
+export const uploadProfilePicture = createServerFn({ method: "POST" })
+ .middleware([authenticatedMiddleware])
+ .validator(parseProfilePictureForm)
+ .handler(async ({ data: image, context }) => {
+ const formData = new FormData()
+ formData.set("userId", context.session.user.id)
+ formData.set("image", image)
+ const result = await context.backend.auth.updateProfilePic.mutate(formData)
+ if (!result.success) throw new Error("PROFILE_PICTURE_NOT_UPDATED")
+ return result
+ })
diff --git a/src/features/account/account.validation.ts b/src/features/account/account.validation.ts
new file mode 100644
index 0000000..3f262f3
--- /dev/null
+++ b/src/features/account/account.validation.ts
@@ -0,0 +1,7 @@
+export function parseProfilePictureForm(data: FormData) {
+ const image = data.get("image")
+ if (!(image instanceof File)) throw new Error("INVALID_IMAGE")
+ if (image.size > 1024 * 1024) throw new Error("IMAGE_TOO_LARGE")
+ if (!["image/png", "image/jpeg"].includes(image.type)) throw new Error("INVALID_IMAGE_TYPE")
+ return image
+}
diff --git a/src/features/account/confirmation-dialog.tsx b/src/features/account/confirmation-dialog.tsx
new file mode 100644
index 0000000..41b3f1a
--- /dev/null
+++ b/src/features/account/confirmation-dialog.tsx
@@ -0,0 +1,52 @@
+import { type ReactElement, type ReactNode, useState } from "react"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+
+export function ConfirmationDialog({
+ trigger,
+ title,
+ description,
+ actionLabel,
+ onConfirm,
+}: {
+ trigger: ReactElement
+ title: string
+ description: ReactNode
+ actionLabel: string
+ onConfirm: () => void
+}) {
+ const [open, setOpen] = useState(false)
+
+ return (
+
+
+
+
+ {title}
+ {description}
+
+
+ Cancel
+ {
+ setOpen(false)
+ onConfirm()
+ }}
+ >
+ {actionLabel}
+
+
+
+
+ )
+}
diff --git a/src/features/account/profile-sections.tsx b/src/features/account/profile-sections.tsx
new file mode 100644
index 0000000..4d75c8d
--- /dev/null
+++ b/src/features/account/profile-sections.tsx
@@ -0,0 +1,179 @@
+import { Camera, LoaderCircle, ShieldCheck, UserRound } from "lucide-react"
+import { useRef } from "react"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+import type { AdminSession } from "@/lib/auth"
+import { ConfirmationDialog } from "./confirmation-dialog"
+
+type User = AdminSession["user"]
+
+export function ProfileSummaryCard({
+ user,
+ busy,
+ onUpload,
+ onRemove,
+}: {
+ user: User
+ busy: string | null
+ onUpload: (file?: File) => void
+ onRemove: () => void
+}) {
+ const fileInput = useRef(null)
+
+ return (
+
+
+
+
+ {user.image && }
+
+ {avatarText(user.name, user.email)}
+
+
+
fileInput.current?.click()}
+ disabled={busy === "image"}
+ aria-label="Upload profile picture"
+ >
+
+
+
onUpload(event.target.files?.[0])}
+ />
+
+
+
{user.name || "Complete your profile"}
+
{user.email}
+
+ {user.telegramUsername
+ ? `@${user.telegramUsername}`
+ : user.telegramId
+ ? `Telegram ID ${user.telegramId}`
+ : "Telegram not linked"}
+
+
+ {user.image && (
+
+ Remove picture
+
+ }
+ title="Remove profile picture?"
+ description="Your current profile picture will be removed from this account."
+ actionLabel="Remove picture"
+ onConfirm={onRemove}
+ />
+ )}
+
+
+ )
+}
+
+export function ProfileDetailsCard({
+ user,
+ name,
+ busy,
+ onNameChange,
+ onSubmit,
+}: {
+ user: User
+ name: string
+ busy: string | null
+ onNameChange: (name: string) => void
+ onSubmit: (event: React.FormEvent) => void
+}) {
+ return (
+
+
+
+
+ Profile details
+ Displayed throughout the admin console.
+
+
+
+
+
+
+ )
+}
+
+export function TelegramIdentityCard({ user, roles }: { user: User; roles: readonly string[] }) {
+ return (
+
+
+
+
+ Telegram identity
+ Used to determine roles and permissions.
+
+
+
+
+
+
Username
+ {user.telegramUsername ? `@${user.telegramUsername}` : "Not available"}
+
+
+
Telegram ID
+ {user.telegramId ?? "Not linked"}
+
+
+
Roles
+
+ {roles.length ? (
+ roles.map((role) => (
+
+ {role}
+
+ ))
+ ) : (
+ No assigned roles
+ )}
+
+
+
+
+
+ )
+}
+
+function avatarText(name?: string | null, email?: string) {
+ return (name || email || "U")
+ .split(/[\s.@_-]+/)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("")
+}
diff --git a/src/features/account/security-sections.tsx b/src/features/account/security-sections.tsx
new file mode 100644
index 0000000..cf7c47c
--- /dev/null
+++ b/src/features/account/security-sections.tsx
@@ -0,0 +1,211 @@
+import { Calendar, KeyRound, LogOut, MonitorSmartphone, Shield, Trash2 } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
+import { Skeleton } from "@/components/ui/skeleton"
+import { ConfirmationDialog } from "./confirmation-dialog"
+import type { ActiveSession, Passkey } from "./types"
+
+export function PasskeysCard({
+ passkeys,
+ busy,
+ loading,
+ onAdd,
+ onDelete,
+}: {
+ passkeys: Passkey[]
+ busy: string | null
+ loading: boolean
+ onAdd: () => void
+ onDelete: (id: string) => void
+}) {
+ return (
+
+
+
+
+
+ Passkeys
+ Phishing-resistant access from your trusted devices.
+
+
+
+ Add passkey
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {passkeys.map((passkey) => (
+
+ Added{" "}
+ {passkey.createdAt ? new Date(passkey.createdAt).toLocaleDateString() : "recently"}
+ {passkey.deviceType ? ` · ${passkey.deviceType}` : ""}
+ >
+ }
+ action={
+
+
+
+ }
+ title="Delete passkey?"
+ description={`The passkey ${passkey.name || "Unnamed passkey"} will no longer sign in to this account.`}
+ actionLabel="Delete passkey"
+ onConfirm={() => onDelete(passkey.id)}
+ />
+ }
+ />
+ ))}
+ {!passkeys.length && (
+ No passkeys registered yet.
+ )}
+
+ )}
+
+
+ )
+}
+
+export function SessionsCard({
+ sessions,
+ currentSessionId,
+ busy,
+ loading,
+ onRevokeOthers,
+ onLogout,
+}: {
+ sessions: ActiveSession[]
+ currentSessionId: string
+ busy: string | null
+ loading: boolean
+ onRevokeOthers: () => void
+ onLogout: () => void
+}) {
+ return (
+
+
+
+
+
+ Active sessions
+ Devices currently signed in to your account.
+
+
+ {sessions.length > 1 && (
+
+ Sign out other sessions
+
+ }
+ title="Sign out other sessions?"
+ description={`This will sign out ${sessions.length - 1} other active ${sessions.length === 2 ? "session" : "sessions"}. Your current session will stay signed in.`}
+ actionLabel="Sign out sessions"
+ onConfirm={onRevokeOthers}
+ />
+ )}
+
+
+ {loading ? (
+
+ ) : (
+
+ {sessions.map((activeSession) => (
+
+ {activeSession.ipAddress || "Unknown IP"}
+ {activeSession.createdAt
+ ? ` · Since ${new Date(activeSession.createdAt).toLocaleDateString()}`
+ : ""}
+ >
+ }
+ action={
+ activeSession.id === currentSessionId ? (
+ Current
+ ) : undefined
+ }
+ />
+ ))}
+
+ )}
+
+
+
+ Sign out of this device
+
+
+
+ )
+}
+
+function SecurityList({ children }: { children: React.ReactNode }) {
+ return {children}
+}
+
+function SecurityLoading({ rows }: { rows: number }) {
+ return (
+
+
Loading security information
+ {Array.from({ length: rows }, (_, index) => (
+
+ ))}
+
+ )
+}
+
+function SecurityItem({
+ icon: Icon,
+ title,
+ description,
+ action,
+}: {
+ icon: typeof KeyRound
+ title: string
+ description: React.ReactNode
+ action?: React.ReactNode
+}) {
+ return (
+
+
+
+
+
+
{title}
+
{description}
+
+ {action}
+
+ )
+}
diff --git a/src/features/account/types.ts b/src/features/account/types.ts
new file mode 100644
index 0000000..21dab5f
--- /dev/null
+++ b/src/features/account/types.ts
@@ -0,0 +1,16 @@
+export type Passkey = {
+ id: string
+ name?: string | null
+ createdAt?: Date | string
+ deviceType?: string
+}
+
+export type ActiveSession = {
+ id: string
+ token: string
+ userAgent?: string | null
+ ipAddress?: string | null
+ createdAt?: Date | string
+}
+
+export type AccountNotice = { type: "success" | "error"; text: string } | null
diff --git a/src/features/account/use-account.ts b/src/features/account/use-account.ts
new file mode 100644
index 0000000..bf4dd02
--- /dev/null
+++ b/src/features/account/use-account.ts
@@ -0,0 +1,208 @@
+import { useRouter } from "@tanstack/react-router"
+import { useServerFn } from "@tanstack/react-start"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { type AdminSession, auth, useSession } from "@/lib/auth"
+import { uploadProfilePicture } from "./account.functions"
+import type { AccountNotice, ActiveSession, Passkey } from "./types"
+
+export function useAccount(initialSession: AdminSession) {
+ const router = useRouter()
+ const uploadProfilePictureFn = useServerFn(uploadProfilePicture)
+ const sessionQuery = useSession()
+ const session = sessionQuery.data ?? initialSession
+ const user = session.user
+ const [name, setName] = useState(user.name ?? "")
+ const [passkeys, setPasskeys] = useState([])
+ const [sessions, setSessions] = useState([])
+ const [securityLoading, setSecurityLoading] = useState(true)
+ const [securityRefreshing, setSecurityRefreshing] = useState(false)
+ const [securityError, setSecurityError] = useState("")
+ const [busy, setBusy] = useState(null)
+ const [notice, setNotice] = useState(null)
+
+ const sortedSessions = useMemo(
+ () => sessions.toSorted((item) => (item.id === session.session.id ? -1 : 0)),
+ [session.session.id, sessions]
+ )
+
+ const refreshSecurityData = useCallback(async (isRetry = false) => {
+ if (isRetry) setSecurityRefreshing(true)
+ try {
+ const [passkeyResult, sessionResult] = await Promise.all([auth.passkey.listUserPasskeys(), auth.listSessions()])
+ if (passkeyResult.error || sessionResult.error) {
+ setSecurityError("Could not load passkeys and active sessions. Your existing security data is still shown.")
+ return false
+ }
+ setPasskeys(passkeyResult.data ?? [])
+ setSessions(sessionResult.data ?? [])
+ setSecurityError("")
+ return true
+ } catch {
+ setSecurityError("Could not load passkeys and active sessions. Your existing security data is still shown.")
+ return false
+ } finally {
+ setSecurityLoading(false)
+ if (isRetry) setSecurityRefreshing(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void refreshSecurityData()
+ }, [refreshSecurityData])
+
+ useEffect(() => setName(user.name ?? ""), [user.name])
+
+ async function updateName(event: React.FormEvent) {
+ event.preventDefault()
+ setBusy("name")
+ setNotice(null)
+ try {
+ const result = await auth.updateUser({ name: name.trim() })
+ if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not update your name." })
+ else {
+ await sessionQuery.refetch()
+ setNotice({ type: "success", text: "Profile name updated." })
+ }
+ } catch {
+ setNotice({ type: "error", text: "Could not update your name." })
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ async function uploadImage(file?: File) {
+ if (!file) return
+ setNotice(null)
+ if (file.size > 1024 * 1024 || !["image/png", "image/jpeg"].includes(file.type)) {
+ setNotice({ type: "error", text: "Use a PNG or JPEG image smaller than 1 MB." })
+ return
+ }
+
+ setBusy("image")
+ const formData = new FormData()
+ formData.set("image", file)
+ try {
+ await uploadProfilePictureFn({ data: formData })
+ await sessionQuery.refetch()
+ setNotice({ type: "success", text: "Profile picture updated." })
+ } catch {
+ setNotice({ type: "error", text: "Could not update your profile picture." })
+ }
+ setBusy(null)
+ }
+
+ async function removeImage() {
+ setBusy("image")
+ setNotice(null)
+ try {
+ const result = await auth.updateUser({ image: null })
+ if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not remove the picture." })
+ else {
+ await sessionQuery.refetch()
+ setNotice({ type: "success", text: "Profile picture removed." })
+ }
+ } catch {
+ setNotice({ type: "error", text: "Could not remove the picture." })
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ async function addPasskey() {
+ setBusy("passkey")
+ setNotice(null)
+ try {
+ const result = await auth.passkey.addPasskey({ name: `Passkey ${passkeys.length + 1}` })
+ if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not create the passkey." })
+ else {
+ const refreshed = await refreshSecurityData()
+ setNotice({
+ type: "success",
+ text: refreshed ? "Passkey created." : "Passkey created. Refresh security data to see the updated list.",
+ })
+ }
+ } catch {
+ setNotice({ type: "error", text: "Could not create the passkey." })
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ async function deletePasskey(id: string) {
+ setBusy(id)
+ setNotice(null)
+ try {
+ const result = await auth.passkey.deletePasskey({ id })
+ if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not delete the passkey." })
+ else {
+ const refreshed = await refreshSecurityData()
+ setNotice({
+ type: "success",
+ text: refreshed ? "Passkey deleted." : "Passkey deleted. Refresh security data to see the updated list.",
+ })
+ }
+ } catch {
+ setNotice({ type: "error", text: "Could not delete the passkey." })
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ async function revokeOtherSessions() {
+ setBusy("sessions")
+ setNotice(null)
+ try {
+ const result = await auth.revokeOtherSessions()
+ if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not revoke other sessions." })
+ else {
+ const refreshed = await refreshSecurityData()
+ setNotice({
+ type: "success",
+ text: refreshed
+ ? "Other sessions signed out."
+ : "Sessions were signed out. Refresh security data to see the updated list.",
+ })
+ }
+ } catch {
+ setNotice({ type: "error", text: "Could not revoke other sessions." })
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ async function logout() {
+ setBusy("logout")
+ setNotice(null)
+ try {
+ const result = await auth.signOut()
+ if (result.error) throw new Error(result.error.message)
+ await router.invalidate()
+ await router.navigate({ to: "/login", replace: true })
+ } catch {
+ setNotice({ type: "error", text: "Could not sign out. Please try again." })
+ setBusy(null)
+ }
+ }
+
+ return {
+ user,
+ currentSessionId: session.session.id,
+ name,
+ setName,
+ passkeys,
+ sortedSessions,
+ securityLoading,
+ securityRefreshing,
+ securityError,
+ busy,
+ notice,
+ updateName,
+ uploadImage,
+ removeImage,
+ addPasskey,
+ deletePasskey,
+ revokeOtherSessions,
+ refreshSecurityData,
+ logout,
+ }
+}
diff --git a/src/features/auth/auth.functions.ts b/src/features/auth/auth.functions.ts
new file mode 100644
index 0000000..8b801f7
--- /dev/null
+++ b/src/features/auth/auth.functions.ts
@@ -0,0 +1,21 @@
+import { createServerFn } from "@tanstack/react-start"
+import { backendMiddleware, dashboardAccessMiddleware, sessionMiddleware } from "@/server/auth.middleware"
+
+export const getSessionState = createServerFn()
+ .middleware([sessionMiddleware])
+ .handler(({ context }) => ({ session: context.session, agentMode: context.agentMode }))
+
+export const getDashboardAccess = createServerFn()
+ .middleware([dashboardAccessMiddleware])
+ .handler(({ context }) => context.dashboardAccess)
+
+export const testBackend = createServerFn()
+ .middleware([backendMiddleware])
+ .handler(async ({ context }) => {
+ try {
+ await context.backend.test.dbQuery.query({ dbName: "web" })
+ return true
+ } catch {
+ return false
+ }
+ })
diff --git a/src/features/auth/login-page.tsx b/src/features/auth/login-page.tsx
new file mode 100644
index 0000000..193380f
--- /dev/null
+++ b/src/features/auth/login-page.tsx
@@ -0,0 +1,191 @@
+import { useRouter } from "@tanstack/react-router"
+import { ArrowRight, KeyRound, LoaderCircle, Mail, ShieldCheck } from "lucide-react"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import { AppMark } from "@/components/app-mark"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import { auth } from "@/lib/auth"
+import { testBackend } from "./auth.functions"
+
+export function LoginPage() {
+ const [email, setEmail] = useState("")
+ const [otp, setOtp] = useState("")
+ const [sent, setSent] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [notice, setNotice] = useState("")
+ const router = useRouter()
+
+ async function sendCode() {
+ setBusy(true)
+ setNotice("")
+ try {
+ const { data, error } = await auth.emailOtp.sendVerificationOtp({ type: "sign-in", email: email.trim() })
+ if (data?.success) setSent(true)
+ else setNotice(error?.message ?? "We could not send a code. Check your email and try again.")
+ } catch {
+ setNotice("We could not reach the authentication service. Please try again.")
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ async function verify() {
+ setBusy(true)
+ setNotice("")
+ try {
+ const { data, error } = await auth.signIn.emailOtp({ email: email.trim(), otp })
+ if (data) await router.navigate({ to: "/dashboard" })
+ else setNotice(error?.message ?? "That code is not valid. Please try again.")
+ } catch {
+ setNotice("We could not verify the code. Check your connection and try again.")
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ async function passkey() {
+ setBusy(true)
+ setNotice("")
+ try {
+ const { data, error } = await auth.signIn.passkey()
+ if (data) await router.navigate({ to: "/dashboard" })
+ else setNotice(error?.message ?? "Passkey sign in was cancelled or unavailable.")
+ } catch {
+ setNotice("Passkey sign in is unavailable right now. Please try again.")
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ useEffect(() => {
+ void testBackend().then((result) => {
+ if (!result) toast.error("Backend is offline or unavailable")
+ })
+ }, [])
+
+ return (
+
+
+
+
+
+ Internal operations
+
+
+ The work behind the network.
+
+
+ Protected access for managing association members, Telegram communities and administrative permissions.
+
+
+ © PoliNetwork APS
+
+
+
+
+
+
+ {sent ? "Check your inbox" : "Sign in"}
+
+ {sent
+ ? `Enter the six-digit code sent to ${email}.`
+ : "Use your PoliNetwork email address or a saved passkey."}
+
+
+
+ {notice && (
+
+ {notice}
+
+ )}
+ {!sent ? (
+
+ ) : (
+
+ )}
+
+ or
+
+ void passkey()} disabled={busy}>
+ Continue with passkey
+
+
+
+
+
+ )
+}
diff --git a/src/features/azure/azure.functions.ts b/src/features/azure/azure.functions.ts
new file mode 100644
index 0000000..3f4ca55
--- /dev/null
+++ b/src/features/azure/azure.functions.ts
@@ -0,0 +1,58 @@
+import { createServerFn } from "@tanstack/react-start"
+import { z } from "zod"
+import { adminMiddleware } from "@/server/auth.middleware"
+
+export const getAzureMembers = createServerFn()
+ .middleware([adminMiddleware])
+ .handler(({ context }) => context.backend.azure.members.getAll.query())
+
+export const getAzureDirectory = createServerFn()
+ .middleware([adminMiddleware])
+ .handler(async ({ context }) => {
+ const [groups, members] = await Promise.all([
+ context.backend.azure.groups.getAll.query(),
+ context.backend.azure.members.getAll.query(),
+ ])
+ return { groups, members }
+ })
+
+export const createAzureMember = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(
+ z.object({
+ firstName: z.string().trim().min(1),
+ lastName: z.string().trim().min(1),
+ assocNumber: z.number().int().positive(),
+ sendEmailTo: z.email(),
+ })
+ )
+ .handler(async ({ data, context }) => {
+ const result = await context.backend.azure.members.create.mutate(data)
+ if (result.error) throw new Error(result.error)
+ return result
+ })
+
+export const setAzureMemberNumber = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ userId: z.string().min(1), assocNumber: z.number().int().positive() }))
+ .handler(async ({ data, context }) => {
+ const result = await context.backend.azure.members.setAssocNumber.mutate(data)
+ if (result.error) throw new Error(result.error)
+ return result
+ })
+
+const azureGroupMembershipInput = z.object({ groupId: z.string().min(1), userId: z.string().min(1) })
+
+export const addAzureGroupMember = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(azureGroupMembershipInput)
+ .handler(async ({ data, context }) => ({
+ error: (await context.backend.azure.groups.addMember.mutate(data)) ? null : ("INTERNAL_SERVER_ERROR" as const),
+ }))
+
+export const removeAzureGroupMember = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(azureGroupMembershipInput)
+ .handler(async ({ data, context }) => ({
+ error: (await context.backend.azure.groups.removeMember.mutate(data)) ? null : ("INTERNAL_SERVER_ERROR" as const),
+ }))
diff --git a/src/features/azure/group-membership.tsx b/src/features/azure/group-membership.tsx
new file mode 100644
index 0000000..4d381dd
--- /dev/null
+++ b/src/features/azure/group-membership.tsx
@@ -0,0 +1,275 @@
+import { useRouter } from "@tanstack/react-router"
+import { useServerFn } from "@tanstack/react-start"
+import { LoaderCircle, UserMinus, UserPlus } from "lucide-react"
+import { useMemo, useState } from "react"
+import { toast } from "sonner"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar"
+import { Button } from "@/components/ui/button"
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { addAzureGroupMember, removeAzureGroupMember } from "@/features/azure/azure.functions"
+import type { AzureGroup, AzureMember } from "@/lib/api/types"
+
+const MAX_VISIBLE_MEMBERS = 7
+
+type MemberChoice = Pick
+
+export function GroupMembership({ group, directoryMembers }: { group: AzureGroup; directoryMembers: AzureMember[] }) {
+ const sortedMembers = [...group.members].sort((a, b) => a.displayName.localeCompare(b.displayName))
+ const visibleMembers = sortedMembers.slice(0, MAX_VISIBLE_MEMBERS)
+ const hiddenMembers = sortedMembers.slice(MAX_VISIBLE_MEMBERS)
+
+ return (
+
+ {group.members.length > 0 ? (
+
+ {visibleMembers.map((member) => (
+
+ ))}
+ {hiddenMembers.length > 0 && }
+
+ ) : (
+
No members yet
+ )}
+
+
+
+
+
+ )
+}
+
+function MemberAvatar({ member }: { member: AzureGroup["members"][number] }) {
+ return (
+
+
+
+ {initials(member.displayName)}
+
+
+ {member.displayName}
+
+ )
+}
+
+function HiddenMembersCount({ members }: { members: AzureGroup["members"] }) {
+ return (
+
+
+ +{members.length}
+
+
+ {members.map((member) => (
+
+
+ {initials(member.displayName)}
+
+
{member.displayName}
+
+ ))}
+
+
+ )
+}
+
+function MembershipDialog({
+ group,
+ directoryMembers,
+ mode,
+}: {
+ group: AzureGroup
+ directoryMembers: AzureMember[]
+ mode: "add" | "remove"
+}) {
+ const [open, setOpen] = useState(false)
+ const [pending, setPending] = useState(false)
+ const [selectedMember, setSelectedMember] = useState(null)
+ const [confirmRemoval, setConfirmRemoval] = useState(false)
+ const router = useRouter()
+ const addGroupMember = useServerFn(addAzureGroupMember)
+ const removeGroupMember = useServerFn(removeAzureGroupMember)
+ const adding = mode === "add"
+ const Icon = adding ? UserPlus : UserMinus
+
+ const choices = useMemo(() => {
+ const currentIds = new Set(group.members.map((member) => member.id))
+ const members = adding
+ ? directoryMembers.filter((member) => !currentIds.has(member.id))
+ : group.members.map((member) => {
+ const directoryMember = directoryMembers.find((candidate) => candidate.id === member.id)
+ return {
+ id: member.id,
+ displayName: member.displayName,
+ mail: directoryMember?.mail ?? null,
+ }
+ })
+ return members.sort((a, b) => (a.displayName ?? "").localeCompare(b.displayName ?? ""))
+ }, [adding, directoryMembers, group.members])
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (pending) return
+ setOpen(nextOpen)
+ if (!nextOpen) {
+ setSelectedMember(null)
+ setConfirmRemoval(false)
+ }
+ }
+
+ async function updateMembership() {
+ if (!selectedMember || pending) return
+ setPending(true)
+ try {
+ const input = { data: { groupId: group.id, userId: selectedMember.id } }
+ const result = adding ? await addGroupMember(input) : await removeGroupMember(input)
+ if (result.error) {
+ toast.error(mutationErrorMessage(result.error))
+ return
+ }
+
+ toast.success(
+ `${selectedMember.displayName ?? "Member"} ${adding ? "added to" : "removed from"} ${group.displayName}.`
+ )
+ setOpen(false)
+ setSelectedMember(null)
+ setConfirmRemoval(false)
+ try {
+ await router.invalidate({ sync: true })
+ } catch {
+ toast.warning("The membership was updated, but the latest group data could not be refreshed.")
+ }
+ } catch (error) {
+ console.error(error)
+ toast.error(`There was an unexpected error while ${adding ? "adding" : "removing"} the member.`)
+ } finally {
+ setPending(false)
+ }
+ }
+
+ const actionLabel = adding ? "Add member" : "Remove member"
+
+ return (
+
+
+
+ }
+ />
+ }
+ >
+
+
+ {actionLabel}
+
+
+
+
+ {adding ? "Add a group member" : "Remove a group member"}
+
+ Choose {adding ? "a directory user to add to" : "a member to remove from"}{" "}
+ {group.displayName} .
+ {!adding && " Their Microsoft 365 account will not be deleted."}
+
+
+
+
+
+
+ {adding ? "No available users found." : "No group members found."}
+
+ {choices.map((member) => (
+ {
+ setSelectedMember(member)
+ setConfirmRemoval(false)
+ }}
+ >
+
+ {initials(member.displayName)}
+
+
+ {member.displayName ?? "Unnamed user"}
+ {member.mail && {member.mail} }
+
+
+ ))}
+
+
+
+
+ {confirmRemoval && selectedMember && (
+
+ Remove {selectedMember.displayName ?? "this member"}?
+
+ They will lose access to this Microsoft 365 group. Their account will not be deleted.
+
+
+ )}
+
+
+ {confirmRemoval ? (
+ setConfirmRemoval(false)}>
+ Keep member
+
+ ) : (
+ }>Cancel
+ )}
+ {
+ if (!adding && !confirmRemoval) {
+ setConfirmRemoval(true)
+ return
+ }
+ void updateMembership()
+ }}
+ disabled={!selectedMember || pending}
+ >
+ {pending ? (
+
+ ) : (
+
+ )}
+ {pending ? (adding ? "Adding…" : "Removing…") : confirmRemoval ? "Remove member" : actionLabel}
+
+
+
+
+ )
+}
+
+function initials(name: string | null | undefined) {
+ const parts = name?.trim().split(/\s+/).filter(Boolean) ?? []
+ if (parts.length === 0) return "?"
+ return parts
+ .slice(0, 2)
+ .map((part) => part[0])
+ .join("")
+ .toUpperCase()
+}
+
+function mutationErrorMessage(error: string) {
+ if (error === "UNAUTHORIZED") return "You don't have permission to manage this group."
+ return "Microsoft 365 could not complete the change. Please try again."
+}
diff --git a/src/features/azure/groups-page.tsx b/src/features/azure/groups-page.tsx
new file mode 100644
index 0000000..50eeff6
--- /dev/null
+++ b/src/features/azure/groups-page.tsx
@@ -0,0 +1,190 @@
+import { ChevronDown, Info, Mail, UsersRound } from "lucide-react"
+import { useMemo } from "react"
+import { EmptyState } from "@/components/empty-state"
+import { PageHeader } from "@/components/page-header"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader } from "@/components/ui/card"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+import { Separator } from "@/components/ui/separator"
+import { Skeleton } from "@/components/ui/skeleton"
+import type { AzureGroup, AzureMember } from "@/lib/api/types"
+import { GroupMembership } from "./group-membership"
+
+export function AzureGroupsPage({
+ groups,
+ directoryMembers,
+}: {
+ groups: AzureGroup[]
+ directoryMembers: AzureMember[]
+}) {
+ return (
+
+
+ {groups.length ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function GroupsHeader({ groups }: { groups: AzureGroup[] }) {
+ const memberships = groups.reduce((count, group) => count + group.members.length, 0)
+
+ return (
+ <>
+
+ {groups.length} groups
+ {memberships} memberships
+
+ }
+ />
+
+
+ Looking for groups with zero or one member?
+ They are collected in the collapsed section at the bottom of the page.
+
+ >
+ )
+}
+
+function GroupsList({ groups, directoryMembers }: { groups: AzureGroup[]; directoryMembers: AzureMember[] }) {
+ const sortedGroups = useMemo(() => [...groups].sort((a, b) => a.displayName.localeCompare(b.displayName)), [groups])
+ const multiMemberGroups = sortedGroups.filter((group) => group.members.length > 1)
+ const singleMemberGroups = sortedGroups.filter((group) => group.members.length <= 1)
+
+ return (
+
+ {multiMemberGroups.length > 0 && (
+
+ )}
+ {singleMemberGroups.length > 0 && (
+
+ )}
+
+ )
+}
+
+function GroupSection({
+ title,
+ groups,
+ directoryMembers,
+ defaultOpen = false,
+}: {
+ title: string
+ groups: AzureGroup[]
+ directoryMembers: AzureMember[]
+ defaultOpen?: boolean
+}) {
+ return (
+
+
+
+ }
+ >
+
+ {title}
+
+ {groups.length} {groups.length === 1 ? "group" : "groups"}
+
+
+
+
+
+ }>
+ {groups.map((group) => (
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+function GroupRow({ group, directoryMembers }: { group: AzureGroup; directoryMembers: AzureMember[] }) {
+ return (
+
+
+
+
{group.displayName}
+ {group.mailAddress && (
+
+
+ {group.mailAddress}
+
+ )}
+
+
+ {group.members.length} {group.members.length === 1 ? "member" : "members"}
+
+
+
+
+
+ )
+}
+
+export function AzureGroupsSkeleton() {
+ return (
+
+
Loading Microsoft 365 groups
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }, (_, avatarIndex) => (
+
+ ))}
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/features/azure/member-dialog.tsx b/src/features/azure/member-dialog.tsx
new file mode 100644
index 0000000..368d863
--- /dev/null
+++ b/src/features/azure/member-dialog.tsx
@@ -0,0 +1,162 @@
+import { useServerFn } from "@tanstack/react-start"
+import { LoaderCircle } from "lucide-react"
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+import { createAzureMember, setAzureMemberNumber } from "@/features/azure/azure.functions"
+import type { AzureMember } from "@/lib/api/types"
+
+export type MemberDialogState = { mode: "create" } | { mode: "edit"; member: AzureMember }
+
+export function MemberDialog({
+ dialog,
+ onClose,
+ onOptimisticUpdate,
+ onSaved,
+}: {
+ dialog: MemberDialogState
+ onClose: () => void
+ onOptimisticUpdate: (member: AzureMember) => () => void
+ onSaved: (mode: "create" | "edit") => Promise
+}) {
+ const editing = dialog.mode === "edit"
+ const [firstName, setFirstName] = useState("")
+ const [lastName, setLastName] = useState("")
+ const [email, setEmail] = useState("")
+ const [memberId, setMemberId] = useState(editing ? (dialog.member.employeeId ?? "") : "")
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState("")
+ const createMember = useServerFn(createAzureMember)
+ const setMemberNumber = useServerFn(setAzureMemberNumber)
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault()
+ setPending(true)
+ setError("")
+ const assocNumber = Number.parseInt(memberId, 10)
+ if (!Number.isInteger(assocNumber) || assocNumber <= 0) {
+ setError("Enter a valid positive member ID.")
+ setPending(false)
+ return
+ }
+ let rollback: (() => void) | undefined
+ try {
+ if (editing) {
+ rollback = onOptimisticUpdate({ ...dialog.member, employeeId: String(assocNumber), isMember: true })
+ await setMemberNumber({ data: { userId: dialog.member.id, assocNumber } })
+ await onSaved("edit")
+ } else {
+ await createMember({ data: { firstName, lastName, assocNumber, sendEmailTo: email } })
+ await onSaved("create")
+ }
+ } catch {
+ rollback?.()
+ setError("The member could not be saved. Check the values and your permissions.")
+ setPending(false)
+ }
+ }
+
+ return (
+ {
+ if (!open) onClose()
+ }}
+ >
+
+
+
+ AZURE MEMBERS
+
+
+ {editing ? "Set member ID" : "Create a member"}
+
+
+ {editing
+ ? "Update the association number linked to this Azure account."
+ : "Create a member association and send a welcome email."}
+
+
+
+
+
+ )
+}
diff --git a/src/features/azure/members-page.tsx b/src/features/azure/members-page.tsx
new file mode 100644
index 0000000..60e6f69
--- /dev/null
+++ b/src/features/azure/members-page.tsx
@@ -0,0 +1,282 @@
+import { useRouter } from "@tanstack/react-router"
+import type { Column } from "@tanstack/react-table"
+import { ArrowDown, ArrowUp, Building2, Check, ChevronsUpDown, Plus, UsersRound } from "lucide-react"
+import { useEffect, useMemo, useState } from "react"
+import { toast } from "sonner"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { Pagination } from "@/components/pagination"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table"
+import type { AzureMember } from "@/lib/api/types"
+import { createAppColumnHelper, type dashboardFeatures, useAppTable } from "@/lib/table"
+import { cn } from "@/lib/utils"
+import { MemberDialog, type MemberDialogState } from "./member-dialog"
+
+const memberColumnHelper = createAppColumnHelper()
+
+type MemberFilter = { query: string; membersOnly: boolean }
+
+function memberFilterFrom(value: unknown): MemberFilter {
+ if (!value || typeof value !== "object") return { query: "", membersOnly: false }
+ return {
+ query: "query" in value && typeof value.query === "string" ? value.query : "",
+ membersOnly: "membersOnly" in value && value.membersOnly === true,
+ }
+}
+
+export function AzureMembersPage({ initialMembers }: { initialMembers: AzureMember[] }) {
+ const router = useRouter()
+ const [dialog, setDialog] = useState(null)
+ const [members, setMembers] = useState(initialMembers)
+
+ useEffect(() => setMembers(initialMembers), [initialMembers])
+
+ const columns = useMemo(() => {
+ const header = (
+ label: string,
+ column: Pick, "getIsSorted" | "getToggleSortingHandler">
+ ) => {
+ const sorted = column.getIsSorted()
+ const Icon = !sorted ? ChevronsUpDown : sorted === "asc" ? ArrowUp : ArrowDown
+ return (
+
+ {label}
+
+
+ )
+ }
+ return memberColumnHelper.columns([
+ memberColumnHelper.accessor((member) => member.employeeId ?? undefined, {
+ id: "employeeId",
+ header: ({ column }) => header("Member ID", column),
+ sortUndefined: "last",
+ sortFn: (rowA, rowB, columnId) => {
+ const a = Number(rowA.getValue(columnId))
+ const b = Number(rowB.getValue(columnId))
+ return a - b
+ },
+ cell: ({ getValue }) => getValue() ?? "—",
+ }),
+ memberColumnHelper.accessor("displayName", {
+ header: ({ column }) => header("Member", column),
+ cell: ({ row }) => {
+ const member = row.original
+ return (
+
+
+ {member.displayName?.[0] ?? "?"}
+
+
+ {member.displayName ?? "Unnamed member"}
+ {member.isMember && (
+ Association member
+ )}
+
+
+ )
+ },
+ }),
+ memberColumnHelper.accessor("mail", {
+ header: ({ column }) => header("Email", column),
+ cell: ({ getValue }) =>
+ getValue() ?? Not assigned ,
+ }),
+ memberColumnHelper.accessor((member) => member.assignedLicensesIds?.length ?? 0, {
+ id: "licenses",
+ header: ({ column }) => header("Licenses", column),
+ cell: ({ row }) => (
+
+ {row.original.assignedLicensesIds?.length ? (
+ row.original.assignedLicensesIds.map((license) => (
+
+ {license.replaceAll("_", " ")}
+
+ ))
+ ) : (
+ No licenses
+ )}
+
+ ),
+ }),
+ memberColumnHelper.display({
+ id: "actions",
+ header: "",
+ cell: ({ row }) => (
+ setDialog({ mode: "edit", member: row.original })}
+ >
+ Manage
+
+ ),
+ }),
+ ])
+ }, [])
+ const table = useAppTable({
+ key: "azure-members",
+ columns,
+ data: members,
+ initialState: {
+ sorting: [{ id: "employeeId", desc: false }],
+ pagination: { pageIndex: 0, pageSize: 25 },
+ globalFilter: { query: "", membersOnly: false },
+ },
+ autoResetPageIndex: false,
+ globalFilterFn: (row, _columnId, value) => {
+ const filter = memberFilterFrom(value)
+ const member = row.original
+ return (
+ (!filter.membersOnly || !!member.isMember) &&
+ `${member.displayName ?? ""} ${member.mail ?? ""} ${member.employeeId ?? ""}`
+ .toLocaleLowerCase()
+ .includes(filter.query.toLocaleLowerCase())
+ )
+ },
+ })
+ const memberFilter = memberFilterFrom(table.state.globalFilter)
+ const membersOnly = memberFilter.membersOnly
+ const filteredRows = table.getFilteredRowModel().rows
+
+ return (
+
+
{
+ table.setGlobalFilter({ ...memberFilter, query: value })
+ table.setPageIndex(0)
+ }}
+ action={
+ setDialog({ mode: "create" })}>
+ Add member
+
+ }
+ >
+ {
+ table.setGlobalFilter({ ...memberFilter, membersOnly: !membersOnly })
+ table.setPageIndex(0)
+ }}
+ >
+ {membersOnly && } Members only
+
+
+
+
+
+
+ {members.filter((member) => member.isMember).length} association members
+
+
+
+
+
+
+ {members.filter((member) => member.assignedLicensesIds?.includes("OFFICE_365")).length}
+ {" "}
+ Office 365 licenses
+
+
+
+ {filteredRows.length ? (
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : }
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getAllCells().map((cell) => (
+
+
+
+ ))}
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+ {filteredRows.length > 0 && (
+
table.setPageIndex(page - 1)}
+ onPageSizeChange={(pageSize) => table.setPageSize(pageSize)}
+ />
+ )}
+ {dialog && (
+ setDialog(null)}
+ onOptimisticUpdate={(member) => {
+ const previous = members.find((current) => current.id === member.id)
+ setMembers((current) => current.map((item) => (item.id === member.id ? member : item)))
+ return () => {
+ if (previous) setMembers((current) => current.map((item) => (item.id === member.id ? previous : item)))
+ }
+ }}
+ onSaved={async (mode) => {
+ setDialog(null)
+ toast.success(mode === "create" ? "Member created." : "Member ID updated.")
+ if (mode === "create") {
+ try {
+ await router.invalidate({ sync: true })
+ } catch {
+ toast.warning("The member was created, but the latest directory data could not be refreshed.")
+ }
+ }
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/src/features/dashboard/overview-page.tsx b/src/features/dashboard/overview-page.tsx
new file mode 100644
index 0000000..bd06232
--- /dev/null
+++ b/src/features/dashboard/overview-page.tsx
@@ -0,0 +1,119 @@
+import { Link } from "@tanstack/react-router"
+import { ArrowRight, BookOpen, Database, ShieldCheck, UsersRound } from "lucide-react"
+import { PageHeader } from "@/components/page-header"
+import { Card, CardContent } from "@/components/ui/card"
+
+const areas = [
+ {
+ title: "Telegram users",
+ description: "Search people and inspect membership details, roles, messages, and audit history.",
+ to: "/dashboard/telegram/users",
+ icon: UsersRound,
+ service: "Telegram",
+ },
+ {
+ title: "Telegram groups",
+ description: "Review community metadata, invite links, and public visibility.",
+ to: "/dashboard/telegram/groups",
+ icon: Database,
+ service: "Telegram",
+ },
+ {
+ title: "Grants",
+ description: "Review active and scheduled Telegram grants authorized by the association board.",
+ to: "/dashboard/telegram/grants",
+ icon: ShieldCheck,
+ service: "Telegram",
+ },
+ {
+ title: "Microsoft 365 groups",
+ description: "Review directory groups and manage their Microsoft 365 memberships.",
+ to: "/dashboard/azure/groups",
+ icon: Database,
+ service: "Azure",
+ },
+ {
+ title: "Azure members",
+ description: "Manage association numbers and Microsoft 365 license information.",
+ to: "/dashboard/azure/members",
+ icon: UsersRound,
+ service: "Azure",
+ },
+ {
+ title: "Web guides",
+ description: "Read the operational notes for maintaining PoliNetwork public content.",
+ to: "/dashboard/web/guides",
+ icon: BookOpen,
+ service: "Web",
+ },
+] as const
+
+export function DashboardOverviewPage() {
+ return (
+
+
+
+
+
+
+
Administrator workspace
+
Everything starts with a real workflow.
+
+ Every destination below opens a working view backed by the existing PoliNetwork services. Availability and
+ errors are reported inside each area instead of being represented by decorative status indicators.
+
+
+
+
+ Search and inspect records
+
+
+ Manage authorized changes
+
+
+ Review account security
+
+
+
+
+
+
+
+
+ Administrative areas
+
+
Open a focused workspace for the task at hand.
+
+
+ {areas.map(({ title, description, to, icon: Icon, service }) => (
+
+
+
+
+
+
+
+
+ {service}
+
+ {title}
+ {description}
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/features/guides/guide-dialogs.tsx b/src/features/guides/guide-dialogs.tsx
new file mode 100644
index 0000000..90f1e19
--- /dev/null
+++ b/src/features/guides/guide-dialogs.tsx
@@ -0,0 +1,251 @@
+import { useServerFn } from "@tanstack/react-start"
+import { format } from "date-fns"
+import { ChevronDownIcon, LoaderCircle, OctagonX, Upload } from "lucide-react"
+import { useRef, useState } from "react"
+import { toast } from "sonner"
+import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { createGuide, deleteGuide } from "./guides.functions"
+import type { Guide } from "./types"
+
+export function CreateGuideDialog({
+ existingVersions,
+ suggestedVersion,
+ onClose,
+ onCreated,
+}: {
+ existingVersions: string[]
+ suggestedVersion?: string
+ onClose: () => void
+ onCreated: (guide: Guide) => void
+}) {
+ const [version, setVersion] = useState(suggestedVersion ?? "")
+ const [date, setDate] = useState(new Date())
+ const [datePickerOpen, setDatePickerOpen] = useState(false)
+ const [file, setFile] = useState(null)
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState("")
+ const [confirmDiscard, setConfirmDiscard] = useState(false)
+ const fileInput = useRef(null)
+ const initialVersion = useRef(suggestedVersion ?? "")
+ const initialDate = useRef(date)
+ const createGuideFn = useServerFn(createGuide)
+ const trimmedVersion = version.trim()
+ const duplicate = Boolean(trimmedVersion && existingVersions.includes(trimmedVersion))
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault()
+ if (!file || duplicate) return
+ if (file.type !== "application/pdf" || file.size > 2 * 1024 * 1024) {
+ setError("Choose a PDF file no larger than 2 MB.")
+ return
+ }
+
+ setPending(true)
+ setError("")
+ try {
+ const formData = new FormData()
+ formData.set("version", trimmedVersion)
+ formData.set("date", date.toISOString())
+ formData.set("file", file)
+
+ onCreated(await createGuideFn({ data: formData }))
+ } catch (cause) {
+ const message = cause instanceof Error ? cause.message : ""
+
+ setError(
+ message.includes("DUPLICATE_VERSION")
+ ? "This version already exists."
+ : message.includes("UNAUTHORIZED")
+ ? "You do not have permission to publish guides."
+ : "The guide could not be published. Check the file and try again."
+ )
+ } finally {
+ setPending(false)
+ }
+ }
+
+ const dirty = version !== initialVersion.current || Boolean(file) || date.getTime() !== initialDate.current.getTime()
+
+ function requestClose() {
+ if (pending) return
+ if (dirty) setConfirmDiscard(true)
+ else onClose()
+ }
+
+ return (
+ !open && requestClose()}>
+
+
+ WEB · GUIDES
+ Publish a new edition
+ Upload a dated PDF version of the Guida della Matricola.
+
+
+
+
+ )
+}
+
+export function DeleteGuideDialog({
+ guide,
+ onClose,
+ onDeleted,
+}: {
+ guide: Guide
+ onClose: () => void
+ onDeleted: (id: number) => void
+}) {
+ const [pending, setPending] = useState(false)
+ const deleteGuideFn = useServerFn(deleteGuide)
+
+ async function remove() {
+ setPending(true)
+ try {
+ await deleteGuideFn({ data: { id: guide.id } })
+ onDeleted(guide.id)
+ } catch {
+ toast.error("The guide could not be deleted. Check your permissions and try again.")
+ } finally {
+ setPending(false)
+ }
+ }
+
+ return (
+ !open && onClose()}>
+
+
+
+
+
+ Delete Guide
+
+ Are you sure you want to delete version {guide.version} ?
+ This action cannot be undone.
+
+
+
+
+
+ Cancel
+
+ void remove()}>
+ {pending ? : "Confirm"}
+
+
+
+
+ )
+}
diff --git a/src/features/guides/guides-page.tsx b/src/features/guides/guides-page.tsx
new file mode 100644
index 0000000..4eb858c
--- /dev/null
+++ b/src/features/guides/guides-page.tsx
@@ -0,0 +1,145 @@
+import { useRouter } from "@tanstack/react-router"
+import { BookOpen, Download, FileText, Plus, Trash2 } from "lucide-react"
+import { useEffect, useMemo, useState } from "react"
+import { toast } from "sonner"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table"
+import { CreateGuideDialog, DeleteGuideDialog } from "./guide-dialogs"
+import type { Guide } from "./types"
+
+function displayDate(value: string) {
+ return new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", year: "numeric" }).format(new Date(value))
+}
+
+export function GuidesPage({ loadedGuides }: { loadedGuides: Guide[] }) {
+ const router = useRouter()
+ const [guides, setGuides] = useState(loadedGuides)
+ const [query, setQuery] = useState("")
+ const [creating, setCreating] = useState(false)
+ const [deleting, setDeleting] = useState(null)
+
+ useEffect(() => setGuides(loadedGuides), [loadedGuides])
+
+ const filteredGuides = useMemo(() => {
+ const normalized = query.trim().toLocaleLowerCase()
+ return normalized ? guides.filter((guide) => guide.version.toLocaleLowerCase().includes(normalized)) : guides
+ }, [guides, query])
+
+ async function refresh() {
+ try {
+ await router.invalidate({ sync: true })
+ } catch {
+ toast.warning("Your change was saved, but the latest guide list could not be refreshed.")
+ }
+ }
+
+ return (
+
+
setCreating(true)}>
+ Add guide
+
+ }
+ />
+ {filteredGuides.length ? (
+
+
+
+
+ Edition
+ Published
+ File
+ Actions
+
+
+
+ {filteredGuides.map((guide, index) => (
+
+
+
+
+
+
+ Version {guide.version}
+ {index === 0 && !query && Latest }
+
+
+
+ {displayDate(guide.date)}
+
+
+
+
+ Download
+
+
+
+
+ setDeleting(guide)}
+ >
+
+
+
+
+ ))}
+
+
+
+ ) : (
+ setCreating(true)}>Add first guide : undefined}
+ />
+ )}
+ {creating && (
+ guide.version)}
+ suggestedVersion={guides[0]?.version}
+ onClose={() => setCreating(false)}
+ onCreated={(guide) => {
+ setGuides((current) => [guide, ...current])
+ setCreating(false)
+ toast.success("Guide published successfully")
+ void refresh()
+ }}
+ />
+ )}
+ {deleting && (
+ setDeleting(null)}
+ onDeleted={(id) => {
+ setGuides((current) => current.filter((guide) => guide.id !== id))
+ setDeleting(null)
+ toast.success("Guide deleted")
+ void refresh()
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/src/features/guides/guides.functions.ts b/src/features/guides/guides.functions.ts
new file mode 100644
index 0000000..a78408a
--- /dev/null
+++ b/src/features/guides/guides.functions.ts
@@ -0,0 +1,32 @@
+import { createServerFn } from "@tanstack/react-start"
+import { z } from "zod"
+import { adminMiddleware } from "@/server/auth.middleware"
+import { parseGuideForm } from "./guides.validation"
+
+export const getGuides = createServerFn()
+ .middleware([adminMiddleware])
+ .handler(({ context }) => context.backend.web.guides_matricole.getAllGuides.query())
+
+export const createGuide = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(parseGuideForm)
+ .handler(async ({ data, context }) => {
+ const formData = new FormData()
+ formData.set("version", data.version)
+ formData.set("date", data.date)
+ formData.set("file", data.file)
+ formData.set("createdBy", String(context.telegramId))
+
+ const result = await context.backend.web.guides_matricole.addGuide.mutate(formData)
+ if ("error" in result) throw new Error(result.error)
+ return { id: result.id, version: result.version, date: result.date, file: result.file }
+ })
+
+export const deleteGuide = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ id: z.number().int().positive() }))
+ .handler(async ({ data, context }) => {
+ const result = await context.backend.web.guides_matricole.deleteGuide.mutate(data)
+ if (result.error) throw new Error(result.error)
+ return result
+ })
diff --git a/src/features/guides/guides.validation.ts b/src/features/guides/guides.validation.ts
new file mode 100644
index 0000000..1dd6d81
--- /dev/null
+++ b/src/features/guides/guides.validation.ts
@@ -0,0 +1,13 @@
+import { z } from "zod"
+
+export function parseGuideForm(data: FormData) {
+ const version = data.get("version")
+ const date = data.get("date")
+ const file = data.get("file")
+
+ if (typeof version !== "string" || !version.trim() || version.trim().length > 100) throw new Error("INVALID_VERSION")
+ if (typeof date !== "string" || !z.iso.datetime().safeParse(date).success) throw new Error("INVALID_DATE")
+ if (!(file instanceof File) || file.type !== "application/pdf") throw new Error("INVALID_FILE_TYPE")
+ if (file.size > 2 * 1024 * 1024) throw new Error("FILE_TOO_LARGE")
+ return { version: version.trim(), date, file }
+}
diff --git a/src/features/guides/types.ts b/src/features/guides/types.ts
new file mode 100644
index 0000000..4168783
--- /dev/null
+++ b/src/features/guides/types.ts
@@ -0,0 +1,3 @@
+import type { ApiOutput } from "@/lib/api/types"
+
+export type Guide = ApiOutput["web"]["guides_matricole"]["getAllGuides"][number]
diff --git a/src/features/onboarding/telegram-link-page.tsx b/src/features/onboarding/telegram-link-page.tsx
new file mode 100644
index 0000000..8b482f0
--- /dev/null
+++ b/src/features/onboarding/telegram-link-page.tsx
@@ -0,0 +1,131 @@
+import { ArrowRight, CheckCircle2, Clock3, Copy, LoaderCircle, LogOut, MessageCircle, RotateCcw } from "lucide-react"
+import { AppMark } from "@/components/app-mark"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { Button, buttonVariants } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
+import { Input } from "@/components/ui/input"
+import type { AdminSession } from "@/lib/auth"
+import { useTelegramLink } from "./use-telegram-link"
+
+const botUsername = import.meta.env.PROD ? "pn_ts_dev_bot" : "pn_ts_devlocal_bot"
+
+export function TelegramLinkPage({ initialSession }: { initialSession: AdminSession }) {
+ const link = useTelegramLink(initialSession)
+
+ return (
+
+
+
+
+
+
+
+ Link your Telegram account
+
+ PoliNetwork uses your Telegram identity to check which administrative roles you can access.
+
+
+
+ {link.notice && (
+
+ {link.notice.kind === "success" && }
+ {link.notice.text}
+
+ )}
+
+ {!link.ready ? (
+
+ Restoring link status…
+
+ ) : link.savedLink ? (
+
+
+
Link code for @{link.savedLink.username}
+
void link.copyCode()}
+ aria-label={`Copy Telegram link code ${link.savedLink.code}`}
+ >
+ {link.savedLink.code}
+
+
+ Expires in {formatRemaining(link.remainingSeconds)}
+
+
+
+
+
+ Open the PoliNetwork bot, send /link,
+ then use the code above. This page checks the result automatically.
+
+
+
+ ) : link.phase === "verified" ? (
+
+ Opening the dashboard…
+
+ ) : (
+
+ )}
+
+
+
Signed in as {link.session.user.email}
+
void link.logout()} disabled={link.loggingOut}>
+ {link.loggingOut ? (
+
+ ) : (
+
+ )}
+ Use another account
+
+
+
+
+
+ )
+}
+
+function formatRemaining(seconds: number) {
+ const minutes = Math.floor(seconds / 60)
+ const remainder = seconds % 60
+ return `${minutes}:${String(remainder).padStart(2, "0")}`
+}
diff --git a/src/features/onboarding/use-telegram-link.ts b/src/features/onboarding/use-telegram-link.ts
new file mode 100644
index 0000000..a1da933
--- /dev/null
+++ b/src/features/onboarding/use-telegram-link.ts
@@ -0,0 +1,231 @@
+import { useRouter } from "@tanstack/react-router"
+import { type FormEvent, useCallback, useEffect, useState } from "react"
+import { z } from "zod"
+import { type AdminSession, auth, useSession } from "@/lib/auth"
+
+const storageKey = "linktg"
+
+const savedLinkSchema = z.object({
+ username: z.string().min(1),
+ code: z.string().min(1),
+ ttl: z.number().positive(),
+ startTime: z.number().positive(),
+})
+
+type SavedLink = z.infer
+type Phase = "idle" | "starting" | "polling" | "expired" | "verified"
+type Notice = { kind: "error" | "success"; text: string } | null
+
+export function useTelegramLink(initialSession: AdminSession) {
+ const { data: liveSession, refetch: refetchSession } = useSession()
+ const router = useRouter()
+ const session = liveSession ?? initialSession
+ const [username, setUsername] = useState("")
+ const [savedLink, setSavedLink] = useState(null)
+ const [phase, setPhase] = useState("idle")
+ const [notice, setNotice] = useState(null)
+ const [now, setNow] = useState(0)
+ const [ready, setReady] = useState(false)
+ const [loggingOut, setLoggingOut] = useState(false)
+
+ const clearSavedLink = useCallback(() => {
+ try {
+ window.localStorage.removeItem(storageKey)
+ } catch {
+ // The in-memory flow can still be reset if storage is unavailable.
+ }
+ setSavedLink(null)
+ }, [])
+
+ const expireLink = useCallback(
+ (link: SavedLink) => {
+ clearSavedLink()
+ setUsername(link.username)
+ setPhase("expired")
+ setNotice({ kind: "error", text: "This link code expired. Generate a new code to continue." })
+ },
+ [clearSavedLink]
+ )
+
+ const completeLink = useCallback(async () => {
+ clearSavedLink()
+ setPhase("verified")
+ setNotice({ kind: "success", text: "Telegram linked. Opening the dashboard…" })
+ await refetchSession()
+ await router.invalidate()
+ await router.navigate({ to: "/dashboard", replace: true })
+ }, [clearSavedLink, refetchSession, router])
+
+ useEffect(() => {
+ try {
+ const stored = window.localStorage.getItem(storageKey)
+ if (stored) {
+ const parsed = savedLinkSchema.safeParse(JSON.parse(stored))
+ if (parsed.success) {
+ const link = parsed.data
+ setUsername(link.username)
+ if (expiresAt(link) > Date.now()) {
+ setSavedLink(link)
+ setPhase("polling")
+ } else {
+ expireLink(link)
+ }
+ } else {
+ window.localStorage.removeItem(storageKey)
+ }
+ }
+ } catch {
+ setNotice({ kind: "error", text: "Saved Telegram link state could not be restored. Generate a new code." })
+ } finally {
+ setNow(Date.now())
+ setReady(true)
+ }
+ }, [expireLink])
+
+ useEffect(() => {
+ if (!savedLink) return
+
+ const updateClock = () => {
+ const currentTime = Date.now()
+ setNow(currentTime)
+ if (currentTime >= expiresAt(savedLink)) expireLink(savedLink)
+ }
+
+ updateClock()
+ const interval = window.setInterval(updateClock, 1_000)
+ return () => window.clearInterval(interval)
+ }, [expireLink, savedLink])
+
+ useEffect(() => {
+ if (!savedLink || phase !== "polling") return
+
+ let stopped = false
+ let timeout: number | undefined
+
+ const poll = async () => {
+ if (Date.now() >= expiresAt(savedLink)) {
+ expireLink(savedLink)
+ return
+ }
+
+ try {
+ const result = await auth.telegram.link.verify({ query: { code: savedLink.code } })
+ if (stopped) return
+ if (result.error) {
+ setNotice({ kind: "error", text: result.error.message || "Telegram verification could not be checked." })
+ } else if (result.data.verified) {
+ await completeLink()
+ return
+ } else if (result.data.expired) {
+ expireLink(savedLink)
+ return
+ } else {
+ setNotice(null)
+ }
+ } catch {
+ if (!stopped) {
+ setNotice({
+ kind: "error",
+ text: "Telegram verification could not be checked. We will try again automatically.",
+ })
+ }
+ }
+
+ if (!stopped) timeout = window.setTimeout(() => void poll(), 5_000)
+ }
+
+ timeout = window.setTimeout(() => void poll(), 5_000)
+ return () => {
+ stopped = true
+ if (timeout !== undefined) window.clearTimeout(timeout)
+ }
+ }, [completeLink, expireLink, phase, savedLink])
+
+ async function startLink(event: FormEvent) {
+ event.preventDefault()
+ const telegramUsername = username.trim().replace(/^@+/, "")
+ if (!telegramUsername) return
+
+ setPhase("starting")
+ setNotice(null)
+ try {
+ const result = await auth.telegram.link.start({ telegramUsername })
+ if (result.error) {
+ setPhase("idle")
+ setNotice({ kind: "error", text: result.error.message || "A Telegram link code could not be created." })
+ return
+ }
+
+ const link = { username: telegramUsername, code: result.data.code, ttl: result.data.ttl, startTime: Date.now() }
+ try {
+ window.localStorage.setItem(storageKey, JSON.stringify(link))
+ } catch {
+ setNotice({ kind: "error", text: "The code could not be saved in this browser, but it remains usable now." })
+ }
+ setUsername(telegramUsername)
+ setSavedLink(link)
+ setNow(Date.now())
+ setPhase("polling")
+ } catch {
+ setPhase("idle")
+ setNotice({ kind: "error", text: "The authentication service could not create a Telegram link code." })
+ }
+ }
+
+ async function copyCode() {
+ if (!savedLink) return
+ try {
+ await navigator.clipboard.writeText(savedLink.code)
+ setNotice({ kind: "success", text: "Code copied to the clipboard." })
+ } catch {
+ setNotice({ kind: "error", text: "The code could not be copied. Select it and copy it manually." })
+ }
+ }
+
+ function resetLink() {
+ clearSavedLink()
+ setPhase("idle")
+ setNotice(null)
+ }
+
+ async function logout() {
+ if (loggingOut) return
+ setLoggingOut(true)
+ setNotice(null)
+ try {
+ const result = await auth.signOut()
+ if (result.error) throw new Error(result.error.message)
+ clearSavedLink()
+ await refetchSession()
+ await router.invalidate()
+ await router.navigate({ to: "/login", replace: true })
+ } catch {
+ setNotice({ kind: "error", text: "Could not sign out. Please try again." })
+ setLoggingOut(false)
+ }
+ }
+
+ const remainingSeconds = savedLink ? Math.max(0, Math.ceil((expiresAt(savedLink) - now) / 1_000)) : 0
+ const progress = savedLink ? Math.max(0, Math.min(100, (remainingSeconds / savedLink.ttl) * 100)) : 0
+
+ return {
+ session,
+ username,
+ setUsername,
+ savedLink,
+ phase,
+ notice,
+ ready,
+ loggingOut,
+ remainingSeconds,
+ progress,
+ startLink,
+ copyCode,
+ resetLink,
+ logout,
+ }
+}
+
+function expiresAt(link: SavedLink) {
+ return link.startTime + link.ttl * 1_000
+}
diff --git a/src/features/projects/project-card.tsx b/src/features/projects/project-card.tsx
new file mode 100644
index 0000000..48108c4
--- /dev/null
+++ b/src/features/projects/project-card.tsx
@@ -0,0 +1,402 @@
+import { useSortable } from "@dnd-kit/react/sortable"
+import {
+ ExternalLink,
+ GripVertical,
+ Languages,
+ Link as LinkIcon,
+ LoaderCircle,
+ MoreHorizontal,
+ Pencil,
+ Save,
+ Trash2,
+ Upload,
+ X,
+} from "lucide-react"
+import { type ChangeEvent, useEffect, useId, useState } from "react"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { cn } from "@/lib/utils"
+import { PROJECT_CATEGORIES } from "./projects.constants"
+import type { Project, ProjectCategory, ProjectFormValues } from "./types"
+
+type ProjectCardProps = {
+ project: Project
+ draft: boolean
+ initialEditActive: boolean
+ sortableIndex: number
+ onCancelDraft: () => void
+ onDelete: () => Promise
+ onCategoryChange: (category: ProjectCategory) => Promise
+ onSave: (values: ProjectFormValues) => Promise
+}
+
+function initials(value: string) {
+ return value
+ .trim()
+ .split(/\s+/)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("")
+}
+
+export function ProjectCard({
+ project,
+ draft,
+ initialEditActive,
+ sortableIndex,
+ onCancelDraft,
+ onDelete,
+ onCategoryChange,
+ onSave,
+}: ProjectCardProps) {
+ const { ref, handleRef, isDragging } = useSortable({
+ id: project.id,
+ index: sortableIndex,
+ group: project.category,
+ disabled: draft,
+ })
+ const logoInputId = useId()
+ const [editing, setEditing] = useState(initialEditActive)
+ const [title, setTitle] = useState(project.title)
+ const [descriptionIt, setDescriptionIt] = useState(project.descriptionIt)
+ const [descriptionEn, setDescriptionEn] = useState(project.descriptionEn)
+ const [link, setLink] = useState(project.link ?? "")
+ const [logoFile, setLogoFile] = useState(null)
+ const [logoPreview, setLogoPreview] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+ const [deleteOpen, setDeleteOpen] = useState(false)
+
+ useEffect(() => {
+ return () => {
+ if (logoPreview) URL.revokeObjectURL(logoPreview)
+ }
+ }, [logoPreview])
+
+ function resetFields() {
+ setTitle(project.title)
+ setDescriptionIt(project.descriptionIt)
+ setDescriptionEn(project.descriptionEn)
+ setLink(project.link ?? "")
+ setLogoFile(null)
+ setLogoPreview(null)
+ }
+
+ function cancelEdit() {
+ if (draft) {
+ onCancelDraft()
+ return
+ }
+ resetFields()
+ setEditing(false)
+ }
+
+ function selectLogo(event: ChangeEvent) {
+ const file = event.target.files?.[0]
+ if (!file) return
+ setLogoFile(file)
+ setLogoPreview(URL.createObjectURL(file))
+ }
+
+ async function save() {
+ if (saving || !title.trim() || !descriptionIt.trim() || !descriptionEn.trim()) return
+ setSaving(true)
+ try {
+ const saved = await onSave({
+ title: title.trim(),
+ descriptionIt: descriptionIt.trim(),
+ descriptionEn: descriptionEn.trim(),
+ link: link.trim() || null,
+ logo: project.logo,
+ logoFile,
+ category: project.category,
+ })
+ if (saved) {
+ setLogoFile(null)
+ setLogoPreview(null)
+ setEditing(false)
+ }
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function remove() {
+ setDeleting(true)
+ try {
+ if (await onDelete()) setDeleteOpen(false)
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ const logo = logoPreview ?? project.logo
+
+ return (
+ <>
+ ref(element)}
+ className={cn("transition-opacity", isDragging && "relative z-10 opacity-60")}
+ >
+
+ handleRef(element)}
+ type="button"
+ size="icon-sm"
+ variant="ghost"
+ className="cursor-grab self-center text-muted-foreground active:cursor-grabbing"
+ aria-label={`Reorder ${project.title}`}
+ disabled={draft || editing}
+ >
+
+
+
+ {editing ? (
+ <>
+
+
+
+
+
+
+
+ setTitle(event.target.value)}
+ className="min-w-0 text-base font-medium"
+ maxLength={160}
+ required
+ />
+ >
+ ) : (
+ <>
+
+ {title}
+ >
+ )}
+
+
+ {editing ? (
+ <>
+ void save()}
+ disabled={saving}
+ aria-label={`Save ${title}`}
+ >
+ {saving ? : }
+
+
+
+
+ >
+ ) : (
+ <>
+
+ setEditing(true)}
+ aria-label={`Edit ${title}`}
+ >
+
+
+ setDeleteOpen(true)}
+ aria-label={`Delete ${title}`}
+ >
+
+
+ >
+ )}
+
+
+
+
+ } className="md:col-span-2">
+ {editing ? (
+ setLink(event.target.value)}
+ maxLength={2048}
+ placeholder="https://…"
+ />
+ ) : project.link ? (
+
+ {project.link}
+
+ ) : (
+
+ No link provided
+
+ )}
+
+ }>
+ {editing ? (
+
+
+
+ !deleting && setDeleteOpen(open)}>
+
+
+
+
+
+ Delete project
+
+ Are you sure you want to delete {project.title} ? This action cannot be undone.
+
+
+
+ setDeleteOpen(false)}>
+ Cancel
+
+ void remove()}>
+ {deleting && } Delete
+
+
+
+
+ >
+ )
+}
+
+function ProjectLogo({ logo, title }: { logo: string | null; title: string }) {
+ if (logo) return
+ return (
+
+ {initials(title) || "PR"}
+
+ )
+}
+
+function ProjectField({
+ label,
+ icon,
+ className,
+ children,
+}: {
+ label: string
+ icon: React.ReactNode
+ className?: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {icon} {label}
+
+ {children}
+
+ )
+}
+
+function ProjectCategoryMenu({
+ category,
+ onCategoryChange,
+}: {
+ category: ProjectCategory
+ onCategoryChange: (category: ProjectCategory) => Promise
+}) {
+ return (
+
+ }
+ >
+
+
+
+ void onCategoryChange(value as ProjectCategory)}
+ >
+ {PROJECT_CATEGORIES.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/features/projects/projects-page.tsx b/src/features/projects/projects-page.tsx
new file mode 100644
index 0000000..65f7bb5
--- /dev/null
+++ b/src/features/projects/projects-page.tsx
@@ -0,0 +1,301 @@
+import { DragDropProvider, type DragEndEvent } from "@dnd-kit/react"
+import { useRouter } from "@tanstack/react-router"
+import { useServerFn } from "@tanstack/react-start"
+import { FolderKanban, Plus } from "lucide-react"
+import { useEffect, useRef, useState } from "react"
+import { flushSync } from "react-dom"
+import { toast } from "sonner"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { Button } from "@/components/ui/button"
+import { ProjectCard } from "./project-card"
+import { DEFAULT_PROJECT, PROJECT_CATEGORIES } from "./projects.constants"
+import { createProject, deleteProject, editProject, reorderProjects } from "./projects.functions"
+import type { Project, ProjectCategory, ProjectFormValues, ProjectReorder } from "./types"
+
+function formDataForProject(values: ProjectFormValues, id?: number) {
+ const data = new FormData()
+ if (id !== undefined) data.set("id", String(id))
+ data.set("title", values.title)
+ data.set("descriptionIt", values.descriptionIt)
+ data.set("descriptionEn", values.descriptionEn)
+ data.set("link", values.link ?? "")
+ data.set("logo", values.logo ?? "")
+ data.set("category", values.category)
+ if (values.logoFile) data.set("logoFile", values.logoFile)
+ return data
+}
+
+function moveProjectInCategory(items: Project[], category: ProjectCategory, sourceIndex: number, targetIndex: number) {
+ const categoryProjects = items.filter((project) => project.category === category)
+ if (
+ sourceIndex === targetIndex ||
+ sourceIndex < 0 ||
+ targetIndex < 0 ||
+ sourceIndex >= categoryProjects.length ||
+ targetIndex >= categoryProjects.length
+ ) {
+ return null
+ }
+
+ const reorderedCategory = [...categoryProjects]
+ const [movedProject] = reorderedCategory.splice(sourceIndex, 1)
+ if (!movedProject) return null
+ reorderedCategory.splice(targetIndex, 0, movedProject)
+
+ let categoryIndex = 0
+ const nextProjects = items.map((project) => {
+ if (project.category !== category) return project
+ return reorderedCategory[categoryIndex++] ?? project
+ })
+
+ return { nextProjects, orderedIds: reorderedCategory.map((project) => project.id) }
+}
+
+export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] }) {
+ const router = useRouter()
+ const createProjectFn = useServerFn(createProject)
+ const editProjectFn = useServerFn(editProject)
+ const deleteProjectFn = useServerFn(deleteProject)
+ const reorderProjectsFn = useServerFn(reorderProjects)
+ const [projects, setProjects] = useState(loadedProjects)
+ const [activeCategory, setActiveCategory] = useState(DEFAULT_PROJECT.category)
+ const [editingProjectId, setEditingProjectId] = useState(null)
+ const [draftProjectIds, setDraftProjectIds] = useState>(new Set())
+ const reorderRequestId = useRef(0)
+
+ useEffect(() => setProjects(loadedProjects), [loadedProjects])
+
+ const visibleProjects = projects.filter((project) => project.category === activeCategory)
+
+ async function refresh() {
+ try {
+ await router.invalidate({ sync: true })
+ } catch {
+ toast.warning("Your change was saved, but the latest project list could not be refreshed.")
+ }
+ }
+
+ function persistedIds(items: Project[], category: ProjectCategory) {
+ return items
+ .filter((project) => project.category === category && !draftProjectIds.has(project.id))
+ .map((project) => project.id)
+ }
+
+ async function persistOrder(projectIds: number[], rollback: Project[], requestId: number) {
+ if (projectIds.length < 2) return true
+ try {
+ await reorderProjectsFn({ data: { projectIds } })
+ if (reorderRequestId.current === requestId) void refresh()
+ return true
+ } catch {
+ if (reorderRequestId.current === requestId) {
+ setProjects(rollback)
+ toast.error("The project order could not be saved.")
+ }
+ return false
+ }
+ }
+
+ function handleDragEnd(event: DragEndEvent) {
+ if (event.canceled) return
+ const source = event.operation.source
+ if (
+ !source ||
+ !("initialIndex" in source) ||
+ !("index" in source) ||
+ typeof source.initialIndex !== "number" ||
+ typeof source.index !== "number"
+ ) {
+ return
+ }
+
+ const reordered = moveProjectInCategory(projects, activeCategory, source.initialIndex, source.index)
+ if (!reordered) return
+ const change: ProjectReorder = { ...reordered, previousProjects: projects }
+ const requestId = reorderRequestId.current + 1
+ reorderRequestId.current = requestId
+
+ flushSync(() => setProjects(change.nextProjects))
+ const ids = change.orderedIds.filter((id) => !draftProjectIds.has(id))
+ void persistOrder(ids, change.previousProjects, requestId)
+ }
+
+ function addProject() {
+ const draft: Project = { ...DEFAULT_PROJECT, id: -Date.now(), category: activeCategory }
+ setProjects((current) => [draft, ...current])
+ setDraftProjectIds((current) => new Set(current).add(draft.id))
+ setEditingProjectId(draft.id)
+ }
+
+ function cancelDraft(id: number) {
+ setProjects((current) => current.filter((project) => project.id !== id))
+ setDraftProjectIds((current) => {
+ const next = new Set(current)
+ next.delete(id)
+ return next
+ })
+ setEditingProjectId((current) => (current === id ? null : current))
+ }
+
+ async function saveProject(id: number, values: ProjectFormValues) {
+ const draft = draftProjectIds.has(id)
+ try {
+ const saved = draft
+ ? await createProjectFn({ data: formDataForProject(values) })
+ : await editProjectFn({ data: formDataForProject(values, id) })
+ const nextProjects = projects.map((project) => (project.id === id ? saved : project))
+ const nextDraftIds = new Set(draftProjectIds)
+ nextDraftIds.delete(id)
+
+ setProjects(nextProjects)
+ setDraftProjectIds(nextDraftIds)
+ setEditingProjectId((current) => (current === id ? null : current))
+ toast.success(`Project ${draft ? "created" : "updated"}.`)
+
+ if (draft) {
+ const requestId = reorderRequestId.current + 1
+ reorderRequestId.current = requestId
+ const ids = nextProjects
+ .filter((project) => project.category === saved.category && !nextDraftIds.has(project.id))
+ .map((project) => project.id)
+ await persistOrder(ids, nextProjects, requestId)
+ } else {
+ void refresh()
+ }
+ return true
+ } catch (cause) {
+ const message = cause instanceof Error ? cause.message : ""
+ toast.error(
+ message.includes("LOGO_TOO_LARGE")
+ ? "The logo must be no larger than 1 MB."
+ : message.includes("INVALID_LOGO_TYPE")
+ ? "Choose an SVG, PNG, or JPEG logo."
+ : "The project could not be saved. Check the fields and try again."
+ )
+ return false
+ }
+ }
+
+ async function removeProject(id: number) {
+ if (draftProjectIds.has(id)) {
+ cancelDraft(id)
+ return true
+ }
+
+ const previousProjects = projects
+ const project = projects.find((item) => item.id === id)
+ if (!project) return false
+ const nextProjects = projects.filter((item) => item.id !== id)
+ const requestId = reorderRequestId.current + 1
+ reorderRequestId.current = requestId
+ setProjects(nextProjects)
+
+ try {
+ await deleteProjectFn({ data: { id } })
+ toast.success("Project deleted.")
+ const ids = persistedIds(nextProjects, project.category)
+ if (ids.length > 1) await persistOrder(ids, nextProjects, requestId)
+ else void refresh()
+ return true
+ } catch {
+ if (reorderRequestId.current === requestId) setProjects(previousProjects)
+ toast.error("The project could not be deleted. Check your permissions and try again.")
+ return false
+ }
+ }
+
+ async function changeCategory(id: number, category: ProjectCategory) {
+ const project = projects.find((item) => item.id === id)
+ if (!project || project.category === category) return
+
+ const previousProjects = projects
+ const movedProject = { ...project, category }
+ const nextProjects = projects.map((item) => (item.id === id ? movedProject : item))
+ const requestId = reorderRequestId.current + 1
+ reorderRequestId.current = requestId
+ setProjects(nextProjects)
+ setActiveCategory(category)
+
+ if (draftProjectIds.has(id)) return
+
+ try {
+ const saved = await editProjectFn({ data: formDataForProject(movedProject, id) })
+ const savedProjects = nextProjects.map((item) => (item.id === id ? saved : item))
+ setProjects(savedProjects)
+ toast.success("Project moved.")
+ const ids = persistedIds(savedProjects, category)
+ if (ids.length > 1) await persistOrder(ids, savedProjects, requestId)
+ else void refresh()
+ } catch {
+ if (reorderRequestId.current === requestId) {
+ setProjects(previousProjects)
+ setActiveCategory(project.category)
+ }
+ toast.error("The project could not be moved.")
+ }
+ }
+
+ return (
+
+
+ Add project
+
+ }
+ >
+
+ {PROJECT_CATEGORIES.map((category) => (
+ setActiveCategory(category.value)}
+ >
+ {category.label}
+
+ {projects.filter((project) => project.category === category.value).length}
+
+
+ ))}
+
+
+
+ {visibleProjects.length ? (
+
+
+ {visibleProjects.map((project, index) => (
+
cancelDraft(project.id)}
+ onDelete={() => removeProject(project.id)}
+ onCategoryChange={(category) => changeCategory(project.id, category)}
+ onSave={(values) => saveProject(project.id, values)}
+ />
+ ))}
+
+
+ ) : (
+
Add project}
+ />
+ )}
+
+ )
+}
diff --git a/src/features/projects/projects.constants.ts b/src/features/projects/projects.constants.ts
new file mode 100644
index 0000000..3f000ba
--- /dev/null
+++ b/src/features/projects/projects.constants.ts
@@ -0,0 +1,20 @@
+import type { Project, ProjectCategory } from "./types"
+
+export const PROJECT_CATEGORIES = [
+ { value: "news", label: "News" },
+ { value: "general", label: "General" },
+ { value: "deprecated", label: "Deprecated" },
+] as const satisfies readonly { value: ProjectCategory; label: string }[]
+
+export const DEFAULT_PROJECT: Omit = {
+ title: "New project",
+ logo: null,
+ descriptionIt: "Descrizione in italiano",
+ descriptionEn: "Description in English",
+ link: null,
+ category: "general",
+}
+
+export function getProjectCategoryLabel(category: ProjectCategory) {
+ return PROJECT_CATEGORIES.find((item) => item.value === category)?.label ?? category
+}
diff --git a/src/features/projects/projects.functions.ts b/src/features/projects/projects.functions.ts
new file mode 100644
index 0000000..dee2255
--- /dev/null
+++ b/src/features/projects/projects.functions.ts
@@ -0,0 +1,79 @@
+import { createServerFn } from "@tanstack/react-start"
+import { z } from "zod"
+import { adminMiddleware } from "@/server/auth.middleware"
+import { parseProjectForm } from "./projects.validation"
+import type { Project } from "./types"
+
+function projectFields(project: Project) {
+ return {
+ id: project.id,
+ title: project.title,
+ descriptionIt: project.descriptionIt,
+ descriptionEn: project.descriptionEn,
+ logo: project.logo,
+ link: project.link,
+ category: project.category,
+ }
+}
+
+async function resolveLogo(currentLogo: string | null, logoFile: File | null) {
+ if (!logoFile) return currentLogo
+ const content = Buffer.from(await logoFile.arrayBuffer()).toString("base64")
+ return `data:${logoFile.type};base64,${content}`
+}
+
+export const getProjects = createServerFn()
+ .middleware([adminMiddleware])
+ .handler(async ({ context }) => {
+ const projects = await context.backend.web.projects.getAllProjects.query()
+ return projects.map(projectFields)
+ })
+
+export const createProject = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(parseProjectForm)
+ .handler(async ({ data, context }) => {
+ const { logoFile, ...fields } = data
+ const project = await context.backend.web.projects.addProject.mutate({
+ ...fields,
+ logo: await resolveLogo(fields.logo, logoFile),
+ createdBy: context.telegramId,
+ })
+ return projectFields(project)
+ })
+
+export const editProject = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator((data: FormData) => {
+ const id = Number(data.get("id"))
+ if (!Number.isInteger(id) || id <= 0) throw new Error("INVALID_ID")
+ return { id, ...parseProjectForm(data) }
+ })
+ .handler(async ({ data, context }) => {
+ const { logoFile, ...fields } = data
+ const result = await context.backend.web.projects.editProject.mutate({
+ ...fields,
+ logo: await resolveLogo(fields.logo, logoFile),
+ modifiedBy: context.telegramId,
+ })
+ if ("error" in result) throw new Error(result.error)
+ return projectFields(result)
+ })
+
+export const deleteProject = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ id: z.number().int().positive() }))
+ .handler(async ({ data, context }) => {
+ const result = await context.backend.web.projects.deleteProject.mutate(data)
+ if (result.error) throw new Error(result.error)
+ return result
+ })
+
+export const reorderProjects = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ projectIds: z.array(z.number().int().positive()).min(1) }))
+ .handler(async ({ data, context }) => {
+ const result = await context.backend.web.projects.reorderProjects.mutate(data)
+ if (result.error) throw new Error(result.error)
+ return result
+ })
diff --git a/src/features/projects/projects.validation.ts b/src/features/projects/projects.validation.ts
new file mode 100644
index 0000000..58375f2
--- /dev/null
+++ b/src/features/projects/projects.validation.ts
@@ -0,0 +1,52 @@
+const PROJECT_CATEGORIES = new Set(["news", "general", "deprecated"])
+const IMAGE_TYPES = new Set(["image/svg+xml", "image/png", "image/jpeg"])
+const MAX_LOGO_SIZE = 1024 * 1024
+
+function requiredText(data: FormData, key: string, maxLength: number) {
+ const value = data.get(key)
+ if (typeof value !== "string" || !value.trim() || value.trim().length > maxLength) {
+ throw new Error(`INVALID_${key.toUpperCase()}`)
+ }
+ return value.trim()
+}
+
+function optionalText(data: FormData, key: string, maxLength: number) {
+ const value = data.get(key)
+ if (typeof value !== "string") return null
+ const trimmed = value.trim()
+ if (trimmed.length > maxLength) throw new Error(`INVALID_${key.toUpperCase()}`)
+ return trimmed || null
+}
+
+function optionalLink(data: FormData) {
+ const link = optionalText(data, "link", 2048)
+ if (!link) return null
+ try {
+ const url = new URL(link)
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("INVALID_LINK")
+ } catch {
+ throw new Error("INVALID_LINK")
+ }
+ return link
+}
+
+export function parseProjectForm(data: FormData) {
+ const category = data.get("category")
+ if (typeof category !== "string" || !PROJECT_CATEGORIES.has(category)) throw new Error("INVALID_CATEGORY")
+
+ const logoValue = data.get("logoFile")
+ const logoFile = logoValue instanceof File && logoValue.size > 0 ? logoValue : null
+ if (logoFile && (!IMAGE_TYPES.has(logoFile.type) || logoFile.size > MAX_LOGO_SIZE)) {
+ throw new Error(logoFile.size > MAX_LOGO_SIZE ? "LOGO_TOO_LARGE" : "INVALID_LOGO_TYPE")
+ }
+
+ return {
+ title: requiredText(data, "title", 160),
+ descriptionIt: requiredText(data, "descriptionIt", 5000),
+ descriptionEn: requiredText(data, "descriptionEn", 5000),
+ link: optionalLink(data),
+ logo: optionalText(data, "logo", 4 * 1024 * 1024),
+ logoFile,
+ category: category as "news" | "general" | "deprecated",
+ }
+}
diff --git a/src/features/projects/types.ts b/src/features/projects/types.ts
new file mode 100644
index 0000000..fadc229
--- /dev/null
+++ b/src/features/projects/types.ts
@@ -0,0 +1,15 @@
+import type { WebProject } from "@/lib/api/types"
+
+export type Project = WebProject
+export type ProjectCategory = Project["category"]
+
+export type ProjectFormValues = Omit & {
+ logo: string | null
+ logoFile?: File | null
+}
+
+export type ProjectReorder = {
+ nextProjects: Project[]
+ orderedIds: number[]
+ previousProjects: Project[]
+}
diff --git a/src/features/telegram/grants-page.tsx b/src/features/telegram/grants-page.tsx
new file mode 100644
index 0000000..4a3b89e
--- /dev/null
+++ b/src/features/telegram/grants-page.tsx
@@ -0,0 +1,253 @@
+import { Link } from "@tanstack/react-router"
+import type { Column } from "@tanstack/react-table"
+import { ArrowDown, ArrowUp, CalendarClock, ChevronsUpDown } from "lucide-react"
+import { useMemo, useState } from "react"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { Pagination } from "@/components/pagination"
+import { CreateGrantDialog } from "@/components/telegram/create-grant-dialog"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import type { ApiOutput } from "@/lib/api/types"
+import { createAppColumnHelper, type dashboardFeatures, useAppTable } from "@/lib/table"
+
+type GrantRecord = ApiOutput["tg"]["grants"]["getOngoing"]["grants"][number]
+type GrantRow = GrantRecord & { status: "active" | "scheduled" }
+
+type TelegramGrants = {
+ ongoing: ApiOutput["tg"]["grants"]["getOngoing"]
+ scheduled: ApiOutput["tg"]["grants"]["getScheduled"]
+}
+
+const grantColumnHelper = createAppColumnHelper()
+
+function formatDate(value: Date | string) {
+ return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value))
+}
+
+export function TelegramGrantsPage({ grants: { ongoing, scheduled } }: { grants: TelegramGrants }) {
+ const [tab, setTab] = useState<"all" | "ongoing" | "scheduled">("all")
+ const ongoingGrants = ongoing.grants.map((record): GrantRow => ({ ...record, status: "active" }))
+ const scheduledGrants = scheduled.grants.map((record): GrantRow => ({ ...record, status: "scheduled" }))
+ const grants = useMemo(
+ () =>
+ tab === "ongoing"
+ ? ongoingGrants
+ : tab === "scheduled"
+ ? scheduledGrants
+ : [...ongoingGrants, ...scheduledGrants],
+ [ongoingGrants, scheduledGrants, tab]
+ )
+ const columns = useMemo(() => {
+ const sortableHeader = (
+ label: string,
+ column: Pick, "getIsSorted" | "getToggleSortingHandler">
+ ) => {
+ const sorted = column.getIsSorted()
+ const Icon = !sorted ? ChevronsUpDown : sorted === "asc" ? ArrowUp : ArrowDown
+ return (
+
+ {label}
+
+
+ )
+ }
+
+ return grantColumnHelper.columns([
+ grantColumnHelper.display({
+ id: "user",
+ header: "User",
+ cell: ({ row }) => {
+ const { user, grant } = row.original
+ const name = user ? [user.firstName, user.lastName].filter(Boolean).join(" ") : `User ${grant.userId}`
+ return (
+
+ {name}
+
+ {user?.username ? `@${user.username}` : `Telegram ID ${grant.userId}`}
+
+
+ )
+ },
+ }),
+ grantColumnHelper.display({
+ id: "reason",
+ header: "Reason",
+ cell: ({ row }) =>
+ row.original.grant.reason || Not provided ,
+ }),
+ grantColumnHelper.display({
+ id: "grantor",
+ header: "Authorized by",
+ cell: ({ row }) => (
+
+ {row.original.grant.grantedBy}
+
+ ),
+ }),
+ grantColumnHelper.accessor((row) => new Date(row.grant.validSince).getTime(), {
+ id: "validSince",
+ header: ({ column }) => sortableHeader("Starts", column),
+ cell: ({ row }) => {formatDate(row.original.grant.validSince)} ,
+ }),
+ grantColumnHelper.accessor((row) => new Date(row.grant.validUntil).getTime(), {
+ id: "validUntil",
+ header: ({ column }) => sortableHeader("Expires", column),
+ cell: ({ row }) => {formatDate(row.original.grant.validUntil)} ,
+ }),
+ grantColumnHelper.accessor("status", {
+ header: ({ column }) => sortableHeader("Status", column),
+ cell: ({ getValue }) => (
+
+ {getValue() === "scheduled" ? "Scheduled" : "Active"}
+
+ ),
+ }),
+ ])
+ }, [])
+ const table = useAppTable({
+ key: "telegram-grants",
+ columns,
+ data: grants,
+ initialState: { sorting: [{ id: "validSince", desc: false }], pagination: { pageIndex: 0, pageSize: 20 } },
+ globalFilterFn: (row, _columnId, value) => {
+ const { grant, user, status } = row.original
+ const query = String(value ?? "")
+ .trim()
+ .toLocaleLowerCase()
+ const searchable = [
+ grant.userId,
+ grant.grantedBy,
+ grant.reason,
+ user?.firstName,
+ user?.lastName,
+ user?.username,
+ formatDate(grant.validSince),
+ formatDate(grant.validUntil),
+ status,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLocaleLowerCase()
+ return !query || searchable.includes(query)
+ },
+ })
+ const filteredCount = table.getFilteredRowModel().rows.length
+ const hasSearch = Boolean(String(table.state.globalFilter ?? "").trim())
+
+ return (
+
+
}
+ onSearch={(value) => {
+ table.setGlobalFilter(value)
+ table.setPageIndex(0)
+ }}
+ />
+
{
+ const next = value[0]
+ if (next === "all" || next === "ongoing" || next === "scheduled") {
+ setTab(next)
+ table.setPageIndex(0)
+ }
+ }}
+ className="mb-4"
+ aria-label="Grant status"
+ >
+
+ All {ongoingGrants.length + scheduledGrants.length}
+
+
+ Ongoing {ongoingGrants.length}
+
+
+ Scheduled {scheduledGrants.length}
+
+
+ {filteredCount ? (
+ <>
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : }
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getAllCells().map((cell) => (
+
+
+
+ ))}
+
+ ))}
+
+
+
+
table.setPageIndex(page - 1)}
+ onPageSizeChange={(pageSize) => table.setPageSize(pageSize)}
+ />
+ >
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/features/telegram/grants.functions.ts b/src/features/telegram/grants.functions.ts
new file mode 100644
index 0000000..8dff694
--- /dev/null
+++ b/src/features/telegram/grants.functions.ts
@@ -0,0 +1,44 @@
+import { createServerFn } from "@tanstack/react-start"
+import { z } from "zod"
+import { adminMiddleware } from "@/server/auth.middleware"
+
+export const getTelegramGrants = createServerFn()
+ .middleware([adminMiddleware])
+ .handler(async ({ context }) => {
+ const [ongoing, scheduled] = await Promise.all([
+ context.backend.tg.grants.getOngoing.query(),
+ context.backend.tg.grants.getScheduled.query(),
+ ])
+ return { ongoing, scheduled }
+ })
+
+const grantInput = z
+ .object({
+ userId: z.number().int().positive(),
+ since: z.date(),
+ until: z.date(),
+ reason: z.string().trim().max(500).optional(),
+ })
+ .refine(({ since, until }) => until > since, { message: "The grant end must be after its start.", path: ["until"] })
+
+export const createTelegramGrant = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(grantInput)
+ .handler(({ data, context }) =>
+ context.backend.tg.grants.create.mutate({
+ ...data,
+ adderId: context.telegramId,
+ sendTgLog: true,
+ })
+ )
+
+export const interruptTelegramGrant = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ userId: z.number().int().positive() }))
+ .handler(({ data, context }) =>
+ context.backend.tg.grants.interrupt.mutate({
+ userId: data.userId,
+ interruptedById: context.telegramId,
+ sendTgLog: true,
+ })
+ )
diff --git a/src/features/telegram/groups-page.tsx b/src/features/telegram/groups-page.tsx
new file mode 100644
index 0000000..32a99e4
--- /dev/null
+++ b/src/features/telegram/groups-page.tsx
@@ -0,0 +1,275 @@
+import { useRouter } from "@tanstack/react-router"
+import { useServerFn } from "@tanstack/react-start"
+import type { Column } from "@tanstack/react-table"
+import {
+ ArrowDown,
+ ArrowUp,
+ ChevronsUpDown,
+ ExternalLink,
+ Eye,
+ EyeOff,
+ LoaderCircle,
+ MessageCircleMore,
+} from "lucide-react"
+import { useMemo, useState } from "react"
+import { toast } from "sonner"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { Pagination } from "@/components/pagination"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table"
+import { setGroupVisibility } from "@/features/telegram/groups.functions"
+import { LeaveGroupDialog } from "@/features/telegram/leave-group-dialog"
+import type { TgGroup } from "@/lib/api/types"
+import { createAppColumnHelper, type dashboardFeatures, useAppTable } from "@/lib/table"
+import { cn } from "@/lib/utils"
+
+const groupColumnHelper = createAppColumnHelper()
+
+export function TelegramGroupsPage({ loadedGroups }: { loadedGroups: TgGroup[] }) {
+ const router = useRouter()
+ const setGroupVisibilityFn = useServerFn(setGroupVisibility)
+ const [visibilityOverrides, setVisibilityOverrides] = useState>({})
+ const [updatingId, setUpdatingId] = useState(null)
+ const [mutationError, setMutationError] = useState("")
+ const [refreshError, setRefreshError] = useState("")
+
+ const groups = loadedGroups.map((group) => ({
+ ...group,
+ hide: visibilityOverrides[group.telegramId] ?? group.hide,
+ }))
+
+ async function toggleVisibility(group: TgGroup) {
+ if (updatingId !== null) return
+ const hide = !group.hide
+ setUpdatingId(group.telegramId)
+ setMutationError("")
+ setVisibilityOverrides((current) => ({ ...current, [group.telegramId]: hide }))
+
+ try {
+ await setGroupVisibilityFn({ data: { telegramId: group.telegramId, hide } })
+ toast.success(`${group.title} is now ${hide ? "hidden" : "visible"}.`)
+ try {
+ await router.invalidate({ sync: true })
+ setRefreshError("")
+ setVisibilityOverrides((current) => {
+ const { [group.telegramId]: _removed, ...remaining } = current
+ return remaining
+ })
+ } catch {
+ setRefreshError("The visibility was updated, but the latest group data could not be refreshed.")
+ }
+ } catch {
+ setVisibilityOverrides((current) => {
+ const { [group.telegramId]: _removed, ...remaining } = current
+ return remaining
+ })
+ setMutationError("The visibility setting could not be updated. Check your permissions and try again.")
+ } finally {
+ setUpdatingId(null)
+ }
+ }
+
+ const columns = useMemo(() => {
+ const sortableHeader = (
+ label: string,
+ column: Pick, "getIsSorted" | "getToggleSortingHandler">
+ ) => {
+ const sorted = column.getIsSorted()
+ const Icon = !sorted ? ChevronsUpDown : sorted === "asc" ? ArrowUp : ArrowDown
+ return (
+
+ {label}
+
+
+ )
+ }
+
+ return groupColumnHelper.columns([
+ groupColumnHelper.accessor("title", {
+ header: ({ column }) => sortableHeader("Group", column),
+ cell: ({ row }) => (
+
+
+
+
+ {row.original.title}
+
+ ),
+ }),
+ groupColumnHelper.accessor("telegramId", {
+ header: ({ column }) => sortableHeader("Telegram ID", column),
+ cell: ({ getValue }) => {getValue()} ,
+ }),
+ groupColumnHelper.accessor("tag", {
+ header: ({ column }) => sortableHeader("Tag", column),
+ cell: ({ getValue }) =>
+ getValue() ? (
+
+ @{getValue()}
+
+ ) : (
+ Not set
+ ),
+ }),
+ groupColumnHelper.display({
+ id: "visibility",
+ header: "Visibility",
+ cell: ({ row }) => {
+ const group = row.original
+ const pending = updatingId === group.telegramId
+ const visible = !group.hide
+ return (
+ void toggleVisibility(group)}
+ >
+ {pending ? : visible ? : }
+ {visible ? "Visible" : "Hidden"}
+
+ )
+ },
+ }),
+ groupColumnHelper.display({
+ id: "invite",
+ header: "Invite",
+ cell: ({ row }) => {
+ const link = row.original.link
+ return link ? (
+
+
+ Open invite link
+
+ ) : (
+ Not shared
+ )
+ },
+ }),
+ groupColumnHelper.display({
+ id: "leave",
+ header: "",
+ cell: ({ row }) => ,
+ }),
+ ])
+ }, [updatingId])
+ const table = useAppTable({
+ key: "telegram-groups",
+ columns,
+ data: groups,
+ initialState: { sorting: [{ id: "title", desc: false }], pagination: { pageIndex: 0, pageSize: 20 } },
+ globalFilterFn: (row, _columnId, value) => {
+ const group = row.original
+ const query = String(value ?? "")
+ .trim()
+ .toLocaleLowerCase()
+ .replace(/^@/, "")
+ return !query || [group.title, group.tag].filter(Boolean).join(" ").toLocaleLowerCase().includes(query)
+ },
+ })
+ const filteredCount = table.getFilteredRowModel().rows.length
+ const hasSearch = Boolean(String(table.state.globalFilter ?? "").trim())
+
+ return (
+
+
{
+ table.setGlobalFilter(value)
+ table.setPageIndex(0)
+ }}
+ />
+ {mutationError && (
+
+ {mutationError}
+
+ )}
+ {refreshError && (
+
+ {refreshError}
+
+ )}
+ {filteredCount ? (
+ <>
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : }
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getAllCells().map((cell) => (
+
+
+
+ ))}
+
+ ))}
+
+
+
+ table.setPageIndex(page - 1)}
+ onPageSizeChange={(pageSize) => table.setPageSize(pageSize)}
+ />
+ >
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/features/telegram/groups.functions.ts b/src/features/telegram/groups.functions.ts
new file mode 100644
index 0000000..d5630bc
--- /dev/null
+++ b/src/features/telegram/groups.functions.ts
@@ -0,0 +1,23 @@
+import { createServerFn } from "@tanstack/react-start"
+import { z } from "zod"
+import { adminMiddleware } from "@/server/auth.middleware"
+
+export const getTelegramGroups = createServerFn()
+ .middleware([adminMiddleware])
+ .handler(({ context }) => context.backend.tg.groups.getAll.query())
+
+export const setGroupVisibility = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ telegramId: z.number().int(), hide: z.boolean() }))
+ .handler(async ({ data, context }) => {
+ const updated = await context.backend.tg.groups.setHide.mutate(data)
+ if (!updated) throw new Error("GROUP_VISIBILITY_NOT_UPDATED")
+ return { updated: true as const }
+ })
+
+export const leaveTelegramGroup = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ chatId: z.number().int() }))
+ .handler(({ data, context }) =>
+ context.backend.tg.groups.leaveChat.mutate({ chatId: data.chatId, performerId: context.telegramId })
+ )
diff --git a/src/features/telegram/leave-group-dialog.tsx b/src/features/telegram/leave-group-dialog.tsx
new file mode 100644
index 0000000..855fb25
--- /dev/null
+++ b/src/features/telegram/leave-group-dialog.tsx
@@ -0,0 +1,74 @@
+import { useRouter } from "@tanstack/react-router"
+import { useServerFn } from "@tanstack/react-start"
+import { LoaderCircle, Trash2 } from "lucide-react"
+import { useState } from "react"
+import { toast } from "sonner"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+import { leaveTelegramGroup } from "@/features/telegram/groups.functions"
+
+export function LeaveGroupDialog({ chatId, title }: { chatId: number; title: string }) {
+ const router = useRouter()
+ const leaveTelegramGroupFn = useServerFn(leaveTelegramGroup)
+ const [open, setOpen] = useState(false)
+ const [pending, setPending] = useState(false)
+
+ async function leaveGroup() {
+ setPending(true)
+ try {
+ const result = await leaveTelegramGroupFn({ data: { chatId } })
+ if (result.error === "BOT_ERROR") throw new Error("The Telegram bot could not leave this group.")
+ if (result.error && result.error !== "NOT_FOUND") {
+ throw new Error(
+ result.error === "UNAUTHORIZED"
+ ? "You do not have permission to leave this group."
+ : "The group could not be left."
+ )
+ }
+ if (result.error === "NOT_FOUND") {
+ toast.warning("The bot left the group, but its database record was already missing.")
+ } else {
+ toast.success(`Left ${title}.`)
+ }
+ setOpen(false)
+ await router.invalidate({ sync: true })
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "The group could not be left.")
+ } finally {
+ setPending(false)
+ }
+ }
+
+ return (
+ !pending && setOpen(nextOpen)}>
+ }>
+ Leave
+
+
+
+ Leave {title}?
+
+ The bot will leave this Telegram group and the backend will delete its group record. This action affects one
+ group and cannot be undone here.
+
+
+
+ Cancel
+ void leaveGroup()}>
+ {pending && } Confirm leave
+
+
+
+
+ )
+}
diff --git a/src/features/telegram/user-detail/grant-dialogs.tsx b/src/features/telegram/user-detail/grant-dialogs.tsx
new file mode 100644
index 0000000..0ed8897
--- /dev/null
+++ b/src/features/telegram/user-detail/grant-dialogs.tsx
@@ -0,0 +1,83 @@
+import { useRouter } from "@tanstack/react-router"
+import { useServerFn } from "@tanstack/react-start"
+import { LoaderCircle, ShieldX } from "lucide-react"
+import { useState } from "react"
+import { toast } from "sonner"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+import { interruptTelegramGrant } from "@/features/telegram/grants.functions"
+
+export function InterruptGrantDialog({ userId, displayName }: { userId: number; displayName: string }) {
+ const [open, setOpen] = useState(false)
+ const [pending, setPending] = useState(false)
+ const router = useRouter()
+ const interruptGrant = useServerFn(interruptTelegramGrant)
+
+ async function interrupt() {
+ if (pending) return
+ setPending(true)
+ try {
+ const result = await interruptGrant({ data: { userId } })
+ if (result.error) {
+ toast.error(grantMutationError(result.error))
+ return
+ }
+
+ toast.success(`Grant interrupted for ${displayName}.`)
+ setOpen(false)
+ try {
+ await router.invalidate({ sync: true })
+ } catch {
+ toast.warning("The grant was ended, but the latest user data could not be refreshed.")
+ }
+ } catch (error) {
+ console.error(error)
+ toast.error("The grant could not be ended. Check your permissions and try again.")
+ } finally {
+ setPending(false)
+ }
+ }
+
+ return (
+ !pending && setOpen(nextOpen)}>
+ }>
+ End grant
+
+
+
+
+
+
+ End grant?
+
+ {displayName} will immediately return to the normal automatic moderation rules.
+
+
+
+ Cancel
+ void interrupt()}>
+ {pending && }
+ End grant
+
+
+
+
+ )
+}
+
+function grantMutationError(error: string) {
+ if (error === "NOT_FOUND") return "This grant has already expired or been removed."
+ if (error === "UNAUTHORIZED") return "You do not have permission to manage grants."
+ return "The grant update could not be completed."
+}
diff --git a/src/features/telegram/user-detail/group-admin-dialog.tsx b/src/features/telegram/user-detail/group-admin-dialog.tsx
new file mode 100644
index 0000000..31ed099
--- /dev/null
+++ b/src/features/telegram/user-detail/group-admin-dialog.tsx
@@ -0,0 +1,206 @@
+import { useServerFn } from "@tanstack/react-start"
+import { LoaderCircle } from "lucide-react"
+import { useEffect, useState } from "react"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from "@/components/ui/combobox"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Field, FieldLabel } from "@/components/ui/field"
+import { addTelegramGroupAdmin, removeTelegramGroupAdmin } from "@/features/telegram/users.functions"
+import type { TelegramUserDetail } from "./types"
+
+export function AddGroupAdminDialog({
+ open,
+ userId,
+ groups,
+ administeredGroupIds,
+ onClose,
+ onSaved,
+}: {
+ open: boolean
+ userId: number
+ groups: TelegramUserDetail["groups"]
+ administeredGroupIds: Set
+ onClose: () => void
+ onSaved: () => Promise
+}) {
+ const [groupId, setGroupId] = useState("")
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState("")
+ const addGroupAdmin = useServerFn(addTelegramGroupAdmin)
+ const availableGroups = groups.filter((group) => !administeredGroupIds.has(group.telegramId))
+
+ useEffect(() => {
+ if (open) {
+ setGroupId("")
+ setError("")
+ }
+ }, [open])
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault()
+ if (!groupId) return
+ setPending(true)
+ setError("")
+ try {
+ await addGroupAdmin({ data: { userId, groupId: Number(groupId) } })
+ await onSaved()
+ } catch {
+ setError("The user could not be added as a group administrator.")
+ setPending(false)
+ }
+ }
+
+ return (
+ !nextOpen && onClose()}>
+
+
+
+ GROUP ADMINISTRATION
+
+ Add group administrator
+
+ Choose a group this user should administer.
+
+
+ void submit(event)}>
+
+
+ Group
+
+ String(group.telegramId) === groupId) ?? null}
+ onValueChange={(group) => setGroupId(group ? String(group.telegramId) : "")}
+ itemToStringLabel={(group) => group.title}
+ itemToStringValue={(group) => String(group.telegramId)}
+ disabled={!availableGroups.length}
+ >
+
+
+ No matching groups
+
+ {(group) => (
+
+ {group.title}
+
+ )}
+
+
+
+
+ {!availableGroups.length && (
+
+ This user already administers every available group.
+
+ )}
+ {error && {error}
}
+
+
+ Cancel
+
+
+ {pending && }
+ Add administrator
+
+
+
+
+
+ )
+}
+
+export function RemoveGroupAdminDialog({
+ userId,
+ groupId,
+ groupTitle,
+ onSaved,
+}: {
+ userId: number
+ groupId: number
+ groupTitle: string
+ onSaved: () => Promise
+}) {
+ const [open, setOpen] = useState(false)
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState("")
+ const removeGroupAdmin = useServerFn(removeTelegramGroupAdmin)
+
+ async function remove() {
+ setPending(true)
+ setError("")
+ try {
+ const result = await removeGroupAdmin({ data: { userId, groupId } })
+ if (result.error) {
+ setError(groupAdminMutationError(result.error))
+ return
+ }
+ setOpen(false)
+ await onSaved()
+ } catch {
+ setError("The group administrator assignment could not be removed.")
+ } finally {
+ setPending(false)
+ }
+ }
+
+ return (
+ {
+ if (pending) return
+ setOpen(nextOpen)
+ if (!nextOpen) setError("")
+ }}
+ >
+ }>Remove
+
+
+ Remove group administrator?
+
+ This user will no longer administer {groupTitle}. Their other group assignments will not change.
+
+
+ {error && {error}
}
+
+ Cancel
+ void remove()}>
+ {pending && }
+ Remove assignment
+
+
+
+
+ )
+}
+
+function groupAdminMutationError(error: string) {
+ if (error === "NOT_FOUND") return "This assignment has already been removed."
+ if (error === "UNAUTHORIZED_SELF_ASSIGN") return "You cannot remove this assignment from yourself."
+ if (error === "UNAUTHORIZED") return "You do not have permission to remove this assignment."
+ return "The group administrator assignment could not be removed."
+}
diff --git a/src/features/telegram/user-detail/profile.tsx b/src/features/telegram/user-detail/profile.tsx
new file mode 100644
index 0000000..db2a296
--- /dev/null
+++ b/src/features/telegram/user-detail/profile.tsx
@@ -0,0 +1,283 @@
+import { useRouter } from "@tanstack/react-router"
+import {
+ CalendarClock,
+ ExternalLink,
+ History,
+ MessageCircle,
+ ShieldCheck,
+ UserPlus,
+ UserRound,
+ UsersRound,
+} from "lucide-react"
+import { useState } from "react"
+import { toast } from "sonner"
+import { CreateGrantDialog } from "@/components/telegram/create-grant-dialog"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader } from "@/components/ui/card"
+import { InterruptGrantDialog } from "./grant-dialogs"
+import { AddGroupAdminDialog, RemoveGroupAdminDialog } from "./group-admin-dialog"
+import { RoleDialog } from "./role-dialog"
+import { Definition, DetailSection, SectionEmpty, SummaryCard } from "./sections"
+import type { TelegramUserDetail } from "./types"
+
+function formatDate(value: Date | string | null | undefined) {
+ if (!value) return "—"
+ return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value))
+}
+
+function messageLink(chatId: number, messageId: number) {
+ return `https://t.me/c/${String(chatId).replace("-100", "")}/${messageId}`
+}
+
+function groupLink(chatId: number, messageId: number, inviteLink?: string | null) {
+ return inviteLink ?? messageLink(chatId, messageId)
+}
+
+function GrantDetails({
+ label,
+ status,
+ validSince,
+ validUntil,
+}: {
+ label: string
+ status: "Active" | "Scheduled"
+ validSince: Date | string
+ validUntil: Date | string
+}) {
+ return (
+
+
+ {status}
+
+ {formatDate(validSince)}
+ {formatDate(validUntil)}
+
+ )
+}
+
+export function TelegramUserProfile({ data }: { data: TelegramUserDetail }) {
+ const router = useRouter()
+ const [adminDialogOpen, setAdminDialogOpen] = useState(false)
+ 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"
+
+ return (
+ <>
+
+
+
+
+ {(user.firstName?.[0] ?? user.username?.[0] ?? String(user.id).slice(-2)).toUpperCase()}
+
+
+
+
+ Telegram profile · {user.id}
+
+
{displayName}
+
+ {user.username ? `@${user.username}` : "No Telegram username"}
+
+
+
+ {roles.length} role{roles.length === 1 ? "" : "s"}
+
+
+
+
+
+
+
+
+ {user.id}
+
+ {displayName}
+ {user.username ? `@${user.username}` : "—"}
+
+
+
+
+
+ >
+ }
+ >
+
+ {roles.length ? (
+ roles.map((role) => (
+
+ {role}
+
+ ))
+ ) : (
+ No assigned roles
+ )}
+
+
+
+
+ {ongoingGrant && }
+ >
+ }
+ >
+ {ongoingGrant || scheduledGrants.length ? (
+
+ {ongoingGrant && (
+
+ )}
+ {scheduledGrants.map((scheduledGrant, index) => (
+
+ ))}
+
+ ) : (
+ No active or scheduled grants
+ )}
+
+
+
+ setAdminDialogOpen(true)}>
+ Add group
+
+ }
+ >
+
+ {administeredGroups.map((entry) => (
+
+
+ {entry.group.title}
+ {entry.group.id}
+
+ Added by {entry.addedBy.firstName}
+ {entry.addedBy.username ? ` · @${entry.addedBy.username}` : ""}
+
+
+ {
+ toast.success("Group administrator removed.")
+ try {
+ await router.invalidate({ sync: true })
+ } catch {
+ toast.warning("The assignment was removed, but the latest user data could not be refreshed.")
+ }
+ }}
+ />
+
+
+
+ ))}
+ {!administeredGroups.length &&
}
+
+
+ entry.group.id))}
+ onClose={() => setAdminDialogOpen(false)}
+ onSaved={async () => {
+ setAdminDialogOpen(false)
+ toast.success("Group administrator added.")
+ try {
+ await router.invalidate({ sync: true })
+ } catch {
+ toast.warning("The administrator was added, but the latest user data could not be refreshed.")
+ }
+ }}
+ />
+
+
+ {messages.map((message) => (
+
+
+
+
+
+ {formatDate(message.timestamp)}
+
+
+
+
+ {message.message}
+
+
+
+
+
+ ))}
+ {!messages.length &&
}
+
+
+
+
+ {audits.map((audit) => (
+
+
+
+
{audit.type}
+ {formatDate(audit.createdAt)}
+
+ {audit.reason ?? "No reason provided"}
+ {audit.groupTitle && (
+
+ {audit.groupTitle} · {audit.groupId}
+
+ )}
+
+
+ ))}
+ {!audits.length &&
}
+
+
+ >
+ )
+}
diff --git a/src/features/telegram/user-detail/role-dialog.tsx b/src/features/telegram/user-detail/role-dialog.tsx
new file mode 100644
index 0000000..7f0fbc8
--- /dev/null
+++ b/src/features/telegram/user-detail/role-dialog.tsx
@@ -0,0 +1,178 @@
+import { useRouter } from "@tanstack/react-router"
+import { useServerFn } from "@tanstack/react-start"
+import { LoaderCircle, Minus, Plus } from "lucide-react"
+import { useState } from "react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from "@/components/ui/combobox"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Field, FieldLabel } from "@/components/ui/field"
+import { addTelegramUserRole, removeTelegramUserRole } from "@/features/telegram/users.functions"
+import type { TgUserRole } from "@/lib/api/types"
+
+function roleLabel(role: TgUserRole) {
+ return role
+ .split("_")
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
+ .join(" ")
+}
+
+export function RoleDialog({
+ mode,
+ userId,
+ roles,
+ configuredRoles,
+}: {
+ mode: "add" | "remove"
+ userId: number
+ roles: TgUserRole[]
+ configuredRoles: TgUserRole[]
+}) {
+ const [open, setOpen] = useState(false)
+ const [selectedRole, setSelectedRole] = useState(null)
+ const [pending, setPending] = useState(false)
+ const router = useRouter()
+ const addUserRole = useServerFn(addTelegramUserRole)
+ const removeUserRole = useServerFn(removeTelegramUserRole)
+ const adding = mode === "add"
+ const choices = (adding ? configuredRoles.filter((role) => !roles.includes(role)) : roles).map((role) => ({
+ value: role,
+ label: roleLabel(role),
+ }))
+ const selectedChoice = choices.find((choice) => choice.value === selectedRole) ?? null
+ const Icon = adding ? Plus : Minus
+
+ function handleOpenChange(nextOpen: boolean) {
+ if (pending) return
+ setOpen(nextOpen)
+ if (!nextOpen) setSelectedRole(null)
+ }
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault()
+ if (!selectedRole || pending) return
+ setPending(true)
+
+ try {
+ const action = adding ? addUserRole : removeUserRole
+ const result = await action({ data: { userId, role: selectedRole } })
+ if (result.error) {
+ toast.error(roleMutationError(result.error, adding))
+ return
+ }
+
+ toast.success(`${roleLabel(selectedRole)} role ${adding ? "assigned" : "removed"}.`)
+ setOpen(false)
+ setSelectedRole(null)
+ try {
+ await router.invalidate({ sync: true })
+ } catch {
+ toast.warning("The role was updated, but the latest user data could not be refreshed.")
+ }
+ } catch (error) {
+ console.error(error)
+ toast.error(`The role could not be ${adding ? "assigned" : "removed"}. Check your permissions and try again.`)
+ } finally {
+ setPending(false)
+ }
+ }
+
+ const actionLabel = adding ? "Assign role" : "Remove role"
+
+ return (
+
+
+ }
+ >
+ {actionLabel}
+
+
+
+ USER ROLES
+ {actionLabel}
+
+ {adding
+ ? "Choose one of the roles currently configured for Telegram users."
+ : "Choose an assigned role to remove from this user."}
+
+
+ void submit(event)}>
+
+
+ Role
+
+ setSelectedRole(choice?.value ?? null)}
+ itemToStringLabel={(choice) => choice.label}
+ itemToStringValue={(choice) => choice.value}
+ >
+
+
+ {adding ? "No available roles" : "No assigned roles"}
+
+ {(choice) => (
+
+ {choice.label}
+
+ )}
+
+
+
+
+
+ handleOpenChange(false)}>
+ Cancel
+
+
+ {pending && }
+ {actionLabel}
+
+
+
+
+
+ )
+}
+
+function roleMutationError(error: string, adding: boolean) {
+ if (error === "UNAUTHORIZED_SELF_ASSIGN") return `You cannot ${adding ? "assign" : "remove"} this role on yourself.`
+ if (error === "NOT_FOUND") return "That role is no longer assigned to this user."
+ if (error === "UNAUTHORIZED") return "You do not have permission to manage this role."
+ return "The role update could not be completed."
+}
diff --git a/src/features/telegram/user-detail/sections.tsx b/src/features/telegram/user-detail/sections.tsx
new file mode 100644
index 0000000..b090c2f
--- /dev/null
+++ b/src/features/telegram/user-detail/sections.tsx
@@ -0,0 +1,88 @@
+import { Link } from "@tanstack/react-router"
+import { ArrowLeft, type LucideIcon } from "lucide-react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+
+export function UserDetailBackLink() {
+ return (
+
+ Back to users
+
+ )
+}
+
+export function SummaryCard({
+ icon: Icon,
+ label,
+ actions,
+ children,
+}: {
+ icon: LucideIcon
+ label: string
+ actions?: React.ReactNode
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+
+ {children}
+ {actions && {actions}
}
+
+
+ )
+}
+
+export function Definition({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ )
+}
+
+export function SectionEmpty({ text }: { text: string }) {
+ return (
+
+ {text}
+
+ )
+}
+
+export function DetailSection({
+ icon: Icon,
+ title,
+ count,
+ action,
+ children,
+}: {
+ icon: LucideIcon
+ title: string
+ count: number
+ action?: React.ReactNode
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+ {action}
+ {count}
+
+
+ {children}
+
+ )
+}
diff --git a/src/features/telegram/user-detail/types.ts b/src/features/telegram/user-detail/types.ts
new file mode 100644
index 0000000..b3e4924
--- /dev/null
+++ b/src/features/telegram/user-detail/types.ts
@@ -0,0 +1,13 @@
+import type { ApiOutput, TgGrant, TgUser, TgUserRole } from "@/lib/api/types"
+
+export type TelegramUserDetail = {
+ user: TgUser
+ roles: NonNullable
+ configuredRoles: TgUserRole[]
+ groupAdmin: ApiOutput["tg"]["permissions"]["getRoles"]["groupAdmin"]
+ groups: ApiOutput["tg"]["groups"]["getAll"]
+ messages: NonNullable
+ audits: ApiOutput["tg"]["auditLog"]["getById"]
+ ongoingGrant: TgGrant | null
+ scheduledGrants: TgGrant[]
+}
diff --git a/src/features/telegram/users-page.tsx b/src/features/telegram/users-page.tsx
new file mode 100644
index 0000000..3405d7d
--- /dev/null
+++ b/src/features/telegram/users-page.tsx
@@ -0,0 +1,166 @@
+import { useNavigate } from "@tanstack/react-router"
+import { ArrowRight, MessageCircle } from "lucide-react"
+import { useMemo } from "react"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { Pagination } from "@/components/pagination"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table"
+import type { TgUser } from "@/lib/api/types"
+import { createAppColumnHelper, useAppTable } from "@/lib/table"
+
+const userColumnHelper = createAppColumnHelper()
+
+export function TelegramUsersPage({ users }: { users: TgUser[] }) {
+ const navigate = useNavigate()
+
+ const columns = useMemo(
+ () =>
+ userColumnHelper.columns([
+ userColumnHelper.display({
+ id: "name",
+ header: "Name",
+ cell: ({ row }) => {
+ const { firstName, lastName } = row.original
+ const name = [firstName, lastName].filter(Boolean).join(" ")
+ return (
+
+
+
+ {[firstName[0], lastName?.[0] ?? ""].join("").trim().toUpperCase()}
+
+
+
{name}
+
+ )
+ },
+ }),
+ userColumnHelper.accessor("id", {
+ header: "Telegram ID",
+ cell: ({ getValue }) => {getValue()} ,
+ }),
+ userColumnHelper.accessor("username", {
+ header: "Username",
+ cell: ({ getValue }) => {
+ const username = getValue()
+ return username ? (
+ @{username}
+ ) : (
+ Not set
+ )
+ },
+ }),
+ userColumnHelper.display({
+ id: "actions",
+ header: "",
+ cell: () => (
+
+
+
+ ),
+ }),
+ ]),
+ []
+ )
+ const table = useAppTable({
+ key: "telegram-users",
+ columns,
+ data: users,
+ initialState: { pagination: { pageIndex: 0, pageSize: 25 } },
+ globalFilterFn: (row, _columnId, value) => {
+ const user = row.original
+ const query = String(value ?? "")
+ .trim()
+ .toLocaleLowerCase()
+ .replace(/^@/, "")
+ const searchable = [user.username, user.firstName, user.lastName].filter(Boolean).join(" ").toLocaleLowerCase()
+ return !query || searchable.includes(query)
+ },
+ })
+ const filteredCount = table.getFilteredRowModel().rows.length
+ const hasSearch = Boolean(String(table.state.globalFilter ?? "").trim())
+
+ return (
+
+
{
+ table.setGlobalFilter(value)
+ table.setPageIndex(0)
+ }}
+ />
+ {filteredCount ? (
+ <>
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : }
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => {
+ const user = row.original
+ const openUser = () =>
+ navigate({
+ to: "/dashboard/telegram/users/$userId",
+ params: { userId: String(user.id) },
+ })
+
+ return (
+ void openUser()}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault()
+ void openUser()
+ }
+ }}
+ >
+ {row.getAllCells().map((cell) => (
+
+
+
+ ))}
+
+ )
+ })}
+
+
+
+ table.setPageIndex(page - 1)}
+ onPageSizeChange={(pageSize) => table.setPageSize(pageSize)}
+ />
+ >
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/features/telegram/users.functions.ts b/src/features/telegram/users.functions.ts
new file mode 100644
index 0000000..6ef2f5e
--- /dev/null
+++ b/src/features/telegram/users.functions.ts
@@ -0,0 +1,112 @@
+import { USER_ROLE } from "@polinetwork/backend"
+import { notFound } from "@tanstack/react-router"
+import { createServerFn } from "@tanstack/react-start"
+import { z } from "zod"
+import type { TgUser, TgUserRole } from "@/lib/api/types"
+import { adminMiddleware } from "@/server/auth.middleware"
+
+export const getTelegramUsers = createServerFn()
+ .middleware([adminMiddleware])
+ .handler(async ({ context }) => {
+ const result = await context.backend.tg.users.getAll.query()
+ if (result.error) throw new Error(result.error)
+ return result.users ?? []
+ })
+
+const telegramUserLookupInput = z.discriminatedUnion("by", [
+ z.object({ by: z.literal("username"), username: z.string().trim().min(1).max(32) }),
+ z.object({ by: z.literal("id"), userId: z.number().int().positive() }),
+])
+
+type TelegramUserLookupResult = {
+ user: TgUser | null
+ status: "found" | "not-found" | "error"
+ message?: string
+}
+
+export const findTelegramUser = createServerFn()
+ .middleware([adminMiddleware])
+ .validator(telegramUserLookupInput)
+ .handler(async ({ data, context }): Promise => {
+ try {
+ const response =
+ data.by === "username"
+ ? await context.backend.tg.users.getByUsername.query({ username: data.username.replace(/^@/, "") })
+ : await context.backend.tg.users.get.query({ userId: data.userId })
+
+ if (response.error === "NOT_FOUND" || !response.user) return { user: null, status: "not-found" }
+ if (response.error) return { user: null, status: "error", message: "Telegram user lookup failed." }
+ return { user: response.user, status: "found" }
+ } catch (error) {
+ console.error(error)
+ return {
+ user: null,
+ status: "error",
+ message: "The PoliNetwork backend is currently unavailable.",
+ }
+ }
+ })
+
+export const getTelegramUserDetails = createServerFn()
+ .middleware([adminMiddleware])
+ .validator(z.object({ userId: z.number().int().positive() }))
+ .handler(async ({ data, context }) => {
+ const { user } = await context.backend.tg.users.get.query({ userId: data.userId })
+ if (!user) throw notFound()
+
+ const [permissions, messages, audits, ongoingGrant, scheduledGrants, groups] = await Promise.all([
+ context.backend.tg.permissions.getRoles.query({ userId: user.id }),
+ context.backend.tg.messages.getLastByUser.query({ userId: user.id, limit: 15 }),
+ context.backend.tg.auditLog.getById.query({ targetId: user.id }),
+ context.backend.tg.grants.checkUser.query({ userId: user.id }),
+ context.backend.tg.grants.getScheduled.query(),
+ context.backend.tg.groups.getAll.query(),
+ ])
+ const userScheduledGrants = scheduledGrants.grants
+ .filter((record) => record.grant.userId === user.id)
+ .map((record) => record.grant)
+ .sort((left, right) => new Date(left.validSince).getTime() - new Date(right.validSince).getTime())
+
+ return {
+ user,
+ roles: permissions.roles ?? [],
+ configuredRoles: Object.values(USER_ROLE),
+ groupAdmin: permissions.groupAdmin.filter((group) => group !== null),
+ groups,
+ messages: messages.messages ?? [],
+ audits,
+ ongoingGrant: ongoingGrant.grant ?? null,
+ scheduledGrants: userScheduledGrants,
+ }
+ })
+
+export const addTelegramGroupAdmin = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ userId: z.number().int().positive(), groupId: z.number().int() }))
+ .handler(({ data, context }) =>
+ context.backend.tg.permissions.addGroup.mutate({ ...data, adderId: context.telegramId })
+ )
+
+export const removeTelegramGroupAdmin = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(z.object({ userId: z.number().int().positive(), groupId: z.number().int() }))
+ .handler(({ data, context }) =>
+ context.backend.tg.permissions.removeGroup.mutate({ ...data, removerId: context.telegramId })
+ )
+
+const telegramRoleValues = Object.values(USER_ROLE) as [TgUserRole, ...TgUserRole[]]
+const telegramRoleInput = z.object({ userId: z.number().int().positive(), role: z.enum(telegramRoleValues) })
+
+export const addTelegramUserRole = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(telegramRoleInput)
+ .handler(({ data, context }) =>
+ context.backend.tg.permissions.addRole.mutate({ ...data, adderId: context.telegramId })
+ )
+
+export const removeTelegramUserRole = createServerFn({ method: "POST" })
+ .middleware([adminMiddleware])
+ .validator(telegramRoleInput)
+ .handler(({ data, context }) =>
+ context.backend.tg.permissions.removeRole.mutate({ ...data, removerId: context.telegramId })
+ )
diff --git a/src/hooks/use-cookie-storage.tsx b/src/hooks/use-cookie-storage.tsx
deleted file mode 100644
index a6339cc..0000000
--- a/src/hooks/use-cookie-storage.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { type Dispatch, type SetStateAction, useCallback, useMemo, useState } from "react"
-import { type CookieOptions, deleteCookie, getCookie, getDefaultCookieOptions, setCookie } from "@/utils/cookies"
-
-export function useCookieStorage(
- key: string,
- initialValue: T,
- options: CookieOptions = {}
-): [T, Dispatch>] {
- const envOptions = useMemo(() => getDefaultCookieOptions(), [])
- const mergedOptions = useMemo(() => ({ ...envOptions, ...options }), [options, envOptions])
-
- const readValue = useCallback((): T => {
- if (typeof document === "undefined") {
- return initialValue
- }
-
- try {
- const item = getCookie(key)
- return item ? (JSON.parse(item) as T) : initialValue
- } catch (error) {
- console.warn(`Error reading cookie "${key}":`, error)
- return initialValue
- }
- }, [key, initialValue])
-
- const [storedValue, setStoredValue] = useState(readValue)
-
- const setValue: Dispatch> = useCallback(
- (value) => {
- try {
- const valueToStore = value instanceof Function ? value(storedValue) : value
- setStoredValue(valueToStore)
-
- if (typeof document !== "undefined") {
- if (valueToStore === null || valueToStore === undefined) {
- deleteCookie(key, { path: mergedOptions.path, domain: mergedOptions.domain })
- } else {
- setCookie(key, JSON.stringify(valueToStore), mergedOptions)
- }
- }
- } catch (error) {
- console.warn(`Error setting cookie "${key}":`, error)
- }
- },
- [key, storedValue, mergedOptions]
- )
-
- return [storedValue, setValue]
-}
diff --git a/src/hooks/use-local-storage.tsx b/src/hooks/use-local-storage.tsx
deleted file mode 100644
index 7c4209e..0000000
--- a/src/hooks/use-local-storage.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useCallback, useEffect, useState } from "react"
-
-type Return = {
- data: T | null | undefined
- update: (newData: T) => void
- remove: () => void
-}
-
-export function useLocalStorage(key: string): Return {
- const [data, setData] = useState()
-
- useEffect(() => {
- const saved = window.localStorage.getItem(key)
- setData(saved ? (JSON.parse(saved) as T) : null)
- }, [key])
-
- const update = useCallback(
- (newData: T) => {
- setData(newData)
- localStorage.setItem(key, JSON.stringify(newData))
- },
- [key]
- )
-
- const remove = useCallback(() => {
- setData(null)
- localStorage.removeItem(key)
- }, [key])
-
- return { data, update, remove }
-}
diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx
deleted file mode 100644
index 2b0fe1d..0000000
--- a/src/hooks/use-mobile.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react"
-
-const MOBILE_BREAKPOINT = 768
-
-export function useIsMobile() {
- const [isMobile, setIsMobile] = React.useState(undefined)
-
- React.useEffect(() => {
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
- const onChange = () => {
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- }
- mql.addEventListener("change", onChange)
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- return () => mql.removeEventListener("change", onChange)
- }, [])
-
- return !!isMobile
-}
diff --git a/src/hooks/use-session-storage.tsx b/src/hooks/use-session-storage.tsx
deleted file mode 100644
index b1de618..0000000
--- a/src/hooks/use-session-storage.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { type Dispatch, type SetStateAction, useCallback, useState } from "react"
-
-export function useSessionStorage(key: string, initialValue: T): [T, Dispatch>] {
- // Read from sessionStorage on initialization
- const readValue = useCallback((): T => {
- if (typeof window === "undefined") {
- return initialValue
- }
-
- try {
- const item = window.sessionStorage.getItem(key)
- return item ? (JSON.parse(item) as T) : initialValue
- } catch (error) {
- console.warn(`Error reading sessionStorage key "${key}":`, error)
- return initialValue
- }
- }, [key, initialValue])
-
- const [storedValue, setStoredValue] = useState(readValue)
-
- // Return a memoized setter function
- const setValue: Dispatch> = useCallback(
- (value) => {
- try {
- const valueToStore = value instanceof Function ? value(storedValue) : value
- setStoredValue(valueToStore)
-
- if (typeof window !== "undefined") {
- window.sessionStorage.setItem(key, JSON.stringify(valueToStore))
- }
- } catch (error) {
- console.warn(`Error setting sessionStorage key "${key}":`, error)
- }
- },
- [key, storedValue]
- )
-
- return [storedValue, setValue]
-}
diff --git a/src/index.css b/src/index.css
deleted file mode 100644
index d09261d..0000000
--- a/src/index.css
+++ /dev/null
@@ -1,181 +0,0 @@
-@import "tailwindcss";
-@import "tw-animate-css";
-@import "shadcn/tailwind.css";
-
-@plugin "tailwindcss-animate";
-@plugin "tailwind-scrollbar" {
- preferredstrategy: "pseudoelements";
- nocompatible: true;
-}
-
-@custom-variant dark (&:is(.dark *));
-@layer base {
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- }
-}
-
-:root {
- --background: oklch(0.99 0 264.54);
- --foreground: oklch(0.31 0.06 255.87);
- --card: oklch(0.99 0 264.54);
- --card-foreground: oklch(0.31 0.06 255.87);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.36 0.08 255.16);
- --primary: oklch(0.47 0.16 257.42);
- --primary-foreground: oklch(1 0 0);
- --secondary: oklch(0.88 0.04 259.29);
- --secondary-foreground: oklch(0.35 0.07 255.46);
- --muted: oklch(0.92 0.02 256.73);
- --muted-foreground: oklch(0.6306 0.0324 255.03);
- --placeholder: oklch(0.7374 0.0489 255.03);
- --accent: oklch(0.91 0.03 258.37);
- --accent-foreground: oklch(0.3 0.09 256.65);
- --destructive: oklch(0.51 0.19 27.08);
- --destructive-foreground: oklch(1 0 0);
- --border: oklch(0.89 0.04 254.88);
- --input: oklch(0.88 0.02 254.92);
- --ring: oklch(0.57 0.18 257.25);
- --chart-1: oklch(0.59 0.18 256.99);
- --chart-2: oklch(0.39 0.23 268.12);
- --chart-3: oklch(0.38 0.04 226.15);
- --chart-4: oklch(0.83 0.12 88.35);
- --chart-5: oklch(0.78 0.13 59);
- --sidebar: oklch(0.93 0.02 255.54);
- --sidebar-foreground: oklch(0.47 0.16 257.42);
- --sidebar-primary: oklch(0.4 0.03 285.2);
- --sidebar-primary-foreground: oklch(0.97 0.01 278.64);
- --sidebar-accent: oklch(0.95 0.02 255.53);
- --sidebar-accent-foreground: oklch(0.44 0.14 257.11);
- --sidebar-border: oklch(0.93 0 286.32);
- --sidebar-ring: oklch(0.47 0.16 257.42);
- --font-sans: Poppins, sans-serif;
- --font-serif: Libre Baskerville, serif;
- --font-mono: Space Mono, monospace;
- --radius: 0.6rem;
- --shadow-2xs: 0 1.5px 10px 0px hsl(213.91 86.47% 26.08% / 0.07);
- --shadow-xs: 0 1.5px 10px 0px hsl(213.91 86.47% 26.08% / 0.07);
- --shadow-sm: 0 1.5px 10px 0px hsl(213.91 86.47% 26.08% / 0.15), 0 1px 2px -1px hsl(213.91 86.47% 26.08% / 0.15);
- --shadow: 0 1.5px 10px 0px hsl(213.91 86.47% 26.08% / 0.15), 0 1px 2px -1px hsl(213.91 86.47% 26.08% / 0.15);
- --shadow-md: 0 1.5px 10px 0px hsl(213.91 86.47% 26.08% / 0.15), 0 2px 4px -1px hsl(213.91 86.47% 26.08% / 0.15);
- --shadow-lg: 0 1.5px 10px 0px hsl(213.91 86.47% 26.08% / 0.15), 0 4px 6px -1px hsl(213.91 86.47% 26.08% / 0.15);
- --shadow-xl: 0 1.5px 10px 0px hsl(213.91 86.47% 26.08% / 0.15), 0 8px 10px -1px hsl(213.91 86.47% 26.08% / 0.15);
- --shadow-2xl: 0 1.5px 10px 0px hsl(213.91 86.47% 26.08% / 0.38);
-}
-
-.dark {
- --background: oklch(0.19 0.03 256.45);
- --foreground: oklch(0.88 0.03 277.2);
- --card: oklch(0.24 0.03 259.05);
- --card-foreground: oklch(0.9 0.03 255.59);
- --popover: oklch(0.26 0.03 258.31);
- --popover-foreground: oklch(0.96 0.01 278.64);
- --primary: oklch(0.47 0.16 257.42);
- --primary-foreground: oklch(0.93 0.03 252.46);
- --secondary: oklch(0.34 0.07 255.44);
- --secondary-foreground: oklch(0.85 0.03 275.09);
- --muted: oklch(0.3045 0.04 254.07);
- --muted-foreground: oklch(0.7077 0.04 280.47);
- --placeholder: oklch(0.5623 0.04 280.47);
- --accent: oklch(0.35 0.04 254.63);
- --accent-foreground: oklch(0.96 0.01 292.8);
- --destructive: oklch(0.61 0.19 27.08);
- --destructive-foreground: oklch(1 0 0);
- --border: oklch(0.35 0.04 254.63);
- --input: oklch(0.3961 0.03 259.05);
- --ring: oklch(0.47 0.16 257.42);
- --chart-1: oklch(0.49 0.12 267.94);
- --chart-2: oklch(0.56 0.19 306.86);
- --chart-3: oklch(0.72 0.15 58.79);
- --chart-4: oklch(0.66 0.2 335.4);
- --chart-5: oklch(0.76 0.12 214.15);
- --sidebar: oklch(0.24 0.03 259.05);
- --sidebar-foreground: oklch(0.82 0.02 256.74);
- --sidebar-primary: oklch(0.47 0.16 257.42);
- --sidebar-primary-foreground: oklch(0.96 0.01 278.64);
- --sidebar-accent: oklch(0.3 0.03 254.37);
- --sidebar-accent-foreground: oklch(0.96 0.01 292.8);
- --sidebar-border: oklch(0.35 0.04 254.63);
- --sidebar-ring: oklch(0.47 0.16 257.42);
- --font-sans: Poppins, sans-serif;
- --font-serif: Libre Baskerville, serif;
- --font-mono: Space Mono, monospace;
- --radius: 0.6rem;
- --shadow-2xs: 0 1.5px 10px 0px hsl(0 0% 0% / 0.07);
- --shadow-xs: 0 1.5px 10px 0px hsl(0 0% 0% / 0.07);
- --shadow-sm: 0 1.5px 10px 0px hsl(0 0% 0% / 0.15), 0 1px 2px -1px hsl(0 0% 0% / 0.15);
- --shadow: 0 1.5px 10px 0px hsl(0 0% 0% / 0.15), 0 1px 2px -1px hsl(0 0% 0% / 0.15);
- --shadow-md: 0 1.5px 10px 0px hsl(0 0% 0% / 0.15), 0 2px 4px -1px hsl(0 0% 0% / 0.15);
- --shadow-lg: 0 1.5px 10px 0px hsl(0 0% 0% / 0.15), 0 4px 6px -1px hsl(0 0% 0% / 0.15);
- --shadow-xl: 0 1.5px 10px 0px hsl(0 0% 0% / 0.15), 0 8px 10px -1px hsl(0 0% 0% / 0.15);
- --shadow-2xl: 0 1.5px 10px 0px hsl(0 0% 0% / 0.38);
-}
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-foreground: var(--popover-foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
- --color-placeholder: var(--placeholder);
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
- --color-destructive: var(--destructive);
- --color-destructive-foreground: var(--destructive-foreground);
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
- --color-sidebar: var(--sidebar);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
-
- --font-sans: var(--font-sans);
- --font-mono: var(--font-mono);
- --font-serif: var(--font-serif);
-
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
-
- --shadow-2xs: var(--shadow-2xs);
- --shadow-xs: var(--shadow-xs);
- --shadow-sm: var(--shadow-sm);
- --shadow: var(--shadow);
- --shadow-md: var(--shadow-md);
- --shadow-lg: var(--shadow-lg);
- --shadow-xl: var(--shadow-xl);
- --shadow-2xl: var(--shadow-2xl);
- --font-heading: var(--font-sans);
- --radius-2xl: calc(var(--radius) * 1.8);
- --radius-3xl: calc(var(--radius) * 2.2);
- --radius-4xl: calc(var(--radius) * 2.6);
-}
-
-@theme {
- --breakpoint-xs: 30rem;
-}
-
-@utility container {
- margin-inline: auto;
- @apply pb-8 flex flex-col items-start justify-start gap-6;
-}
diff --git a/src/server/trpc/types.tsx b/src/lib/api/types.ts
similarity index 74%
rename from src/server/trpc/types.tsx
rename to src/lib/api/types.ts
index 7962e62..5994e62 100644
--- a/src/server/trpc/types.tsx
+++ b/src/lib/api/types.ts
@@ -7,7 +7,9 @@ export type ApiError = inferRouterError
export type TgUser = NonNullable
export type TgGrant = NonNullable
-export type TgGroup = NonNullable