) {
)
}
-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 (
-
)
}
-export {
- Table,
- TableHeader,
- TableBody,
- TableFooter,
- TableHead,
- TableRow,
- TableCell,
- TableCaption,
+function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
+ return (
+
+ )
}
+
+export { Table, 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
deleted file mode 100644
index c0eacbf..0000000
--- a/src/components/ui/textarea.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import * as React from "react"
-
-import { cn } from "@/lib/utils/shadcn"
-
-function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
- return (
-
- )
-}
-
-export { Textarea }
diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx
index 34d955a..4b6ff1f 100644
--- a/src/components/ui/toggle-group.tsx
+++ b/src/components/ui/toggle-group.tsx
@@ -1,12 +1,11 @@
"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 +15,7 @@ const ToggleGroupContext = React.createContext<
>({
size: "default",
variant: "default",
- spacing: 0,
+ spacing: 2,
orientation: "horizontal",
})
@@ -24,7 +23,7 @@ function ToggleGroup({
className,
variant,
size,
- spacing = 0,
+ spacing = 2,
orientation = "horizontal",
children,
...props
@@ -47,9 +46,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
deleted file mode 100644
index b64148d..0000000
--- a/src/components/ui/tooltip.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client"
-
-import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
-
-import { cn } from "@/lib/utils/shadcn"
-
-function TooltipProvider({
- delay = 0,
- ...props
-}: TooltipPrimitive.Provider.Props) {
- return (
-
- )
-}
-
-function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
- return
-}
-
-function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
- return
-}
-
-function TooltipContent({
- className,
- side = "top",
- sideOffset = 4,
- align = "center",
- alignOffset = 0,
- children,
- ...props
-}: TooltipPrimitive.Popup.Props &
- Pick<
- TooltipPrimitive.Positioner.Props,
- "align" | "alignOffset" | "side" | "sideOffset"
- >) {
- return (
-
-
-
- {children}
-
-
-
-
- )
-}
-
-export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
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/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
deleted file mode 100644
index dfb523b..0000000
--- a/src/env.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { createEnv } from "@t3-oss/env-nextjs"
-import { z } from "zod"
-
-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"),
- },
-
- /**
- * 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(),
- },
-
- /**
- * 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,
- },
- /**
- * 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/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.ts b/src/hooks/use-mobile.ts
deleted file mode 100644
index 2b0fe1d..0000000
--- a/src/hooks/use-mobile.ts
+++ /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-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 40bce93..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.4659 0.1551 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.4659 0.1551 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.4659 0.1551 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.4659 0.1551 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.4659 0.1551 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.4659 0.1551 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.4659 0.1551 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/lib/auth.ts b/src/lib/auth.ts
index f00cd8e..c6f0c78 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -2,21 +2,15 @@ import { passkeyClient } from "@better-auth/passkey/client"
import { AUTH_PATH, type TelegramPlugin } from "@polinetwork/backend"
import type { BetterAuthClientPlugin } from "better-auth"
import { emailOTPClient } from "better-auth/client/plugins"
-import { nextCookies } from "better-auth/next-js"
import { createAuthClient } from "better-auth/react"
-import { getBaseUrl } from "./utils"
-const telegramPlugin = () => {
- return {
- id: "telegram",
- $InferServerPlugin: {} as ReturnType,
- } satisfies BetterAuthClientPlugin
-}
+const telegramPlugin = () =>
+ ({ id: "telegram", $InferServerPlugin: {} as ReturnType }) satisfies BetterAuthClientPlugin
export const auth = createAuthClient({
- baseURL: getBaseUrl(),
+ baseURL: process.env.BACKEND_URL ?? "http://localhost:3000",
basePath: AUTH_PATH,
- plugins: [telegramPlugin(), emailOTPClient(), nextCookies(), passkeyClient()],
+ plugins: [telegramPlugin(), emailOTPClient(), passkeyClient()],
})
-export const { signIn, signOut, getSession, useSession } = auth
+export const { signIn, signOut, useSession } = auth
diff --git a/src/lib/table.ts b/src/lib/table.ts
new file mode 100644
index 0000000..23cf8ea
--- /dev/null
+++ b/src/lib/table.ts
@@ -0,0 +1,31 @@
+import {
+ columnFilteringFeature,
+ createFilteredRowModel,
+ createPaginatedRowModel,
+ createSortedRowModel,
+ createTableHook,
+ filterFns,
+ globalFilteringFeature,
+ rowPaginationFeature,
+ rowSortingFeature,
+ sortFns,
+ tableFeatures,
+} from "@tanstack/react-table"
+
+export const dashboardFeatures = tableFeatures({
+ columnFilteringFeature,
+ globalFilteringFeature,
+ rowSortingFeature,
+ rowPaginationFeature,
+ filteredRowModel: createFilteredRowModel(),
+ sortedRowModel: createSortedRowModel(),
+ paginatedRowModel: createPaginatedRowModel(),
+ filterFns,
+ sortFns,
+})
+
+// One v9 table factory keeps the feature set and column-helper types consistent
+// across every data table in the dashboard.
+export const { useAppTable, createAppColumnHelper } = createTableHook({
+ features: dashboardFeatures,
+})
diff --git a/src/lib/utils/shadcn.ts b/src/lib/utils.ts
similarity index 100%
rename from src/lib/utils/shadcn.ts
rename to src/lib/utils.ts
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts
deleted file mode 100644
index 35a3c3e..0000000
--- a/src/lib/utils/index.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export function getBaseUrl() {
- if (typeof window !== "undefined") return window.location.origin
- if (process.env.PUBLIC_URL) return process.env.PUBLIC_URL
- return `http://localhost:${process.env.PORT ?? 3000}`
-}
-
-export function getInitials(name: string): string {
- const [first, second] = name.split(" ")
- if (first?.[0] && second?.[0]) return first[0].toUpperCase() + second[0].toUpperCase()
- if (first?.[0] && !second?.[0] && first?.[1]) return first[0].toUpperCase() + first[1].toLowerCase()
-
- return name.slice(0, 2) // fallback
-}
-
-export async function wait(ms: number): Promise {
- return new Promise((res) => setTimeout(res, ms))
-}
diff --git a/src/lib/utils/telegram.ts b/src/lib/utils/telegram.ts
deleted file mode 100644
index a7741cc..0000000
--- a/src/lib/utils/telegram.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { TgUser } from "../../server/trpc/types"
-
-export function stripChatId(chatId: number): number {
- if (chatId > 0) return chatId
- const positive = -chatId
-
- const str = positive.toString()
- if (str.length < 13) return positive
- return parseInt(str.slice(1), 10)
-}
-
-export function fmtUser(user: TgUser, printId: boolean = true) {
- let string = user.firstName
- if (user.lastName) string += ` ${user.lastName}`
- if (user.username) string += ` @${user.username}`
- if (printId) string += ` [${user.id}]`
- return string
-}
diff --git a/src/middleware.ts b/src/middleware.ts
deleted file mode 100644
index 95fd401..0000000
--- a/src/middleware.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { AUTH_PATH } from "@polinetwork/backend"
-import type { NextRequest } from "next/server"
-import { NextResponse } from "next/server"
-import { env } from "./env"
-
-export const config = {
- matcher: [`/api/auth/:path*`],
-}
-
-export function middleware(request: NextRequest) {
- if (request.nextUrl.pathname.startsWith(AUTH_PATH)) {
- return NextResponse.rewrite(new URL(request.nextUrl.pathname + request.nextUrl.search, env.BACKEND_URL))
- }
-}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
new file mode 100644
index 0000000..6b45f32
--- /dev/null
+++ b/src/routeTree.gen.ts
@@ -0,0 +1,334 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as LoginRouteImport } from './routes/login'
+import { Route as DashboardRouteImport } from './routes/dashboard'
+import { Route as IndexRouteImport } from './routes/index'
+import { Route as DashboardIndexRouteImport } from './routes/dashboard/index'
+import { Route as OnboardingLinkRouteImport } from './routes/onboarding/link'
+import { Route as DashboardAccountRouteImport } from './routes/dashboard/account'
+import { Route as DashboardWebGuidesRouteImport } from './routes/dashboard/web/guides'
+import { Route as DashboardTelegramGroupsRouteImport } from './routes/dashboard/telegram/groups'
+import { Route as DashboardTelegramGrantsRouteImport } from './routes/dashboard/telegram/grants'
+import { Route as DashboardAzureMembersRouteImport } from './routes/dashboard/azure/members'
+import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
+import { Route as DashboardTelegramUsersIndexRouteImport } from './routes/dashboard/telegram/users/index'
+import { Route as DashboardTelegramUsersUserIdRouteImport } from './routes/dashboard/telegram/users/$userId'
+
+const LoginRoute = LoginRouteImport.update({
+ id: '/login',
+ path: '/login',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const DashboardRoute = DashboardRouteImport.update({
+ id: '/dashboard',
+ path: '/dashboard',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const DashboardIndexRoute = DashboardIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => DashboardRoute,
+} as any)
+const OnboardingLinkRoute = OnboardingLinkRouteImport.update({
+ id: '/onboarding/link',
+ path: '/onboarding/link',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const DashboardAccountRoute = DashboardAccountRouteImport.update({
+ id: '/account',
+ path: '/account',
+ getParentRoute: () => DashboardRoute,
+} as any)
+const DashboardWebGuidesRoute = DashboardWebGuidesRouteImport.update({
+ id: '/web/guides',
+ path: '/web/guides',
+ getParentRoute: () => DashboardRoute,
+} as any)
+const DashboardTelegramGroupsRoute = DashboardTelegramGroupsRouteImport.update({
+ id: '/telegram/groups',
+ path: '/telegram/groups',
+ getParentRoute: () => DashboardRoute,
+} as any)
+const DashboardTelegramGrantsRoute = DashboardTelegramGrantsRouteImport.update({
+ id: '/telegram/grants',
+ path: '/telegram/grants',
+ getParentRoute: () => DashboardRoute,
+} as any)
+const DashboardAzureMembersRoute = DashboardAzureMembersRouteImport.update({
+ id: '/azure/members',
+ path: '/azure/members',
+ getParentRoute: () => DashboardRoute,
+} as any)
+const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
+ id: '/api/auth/$',
+ path: '/api/auth/$',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const DashboardTelegramUsersIndexRoute =
+ DashboardTelegramUsersIndexRouteImport.update({
+ id: '/telegram/users/',
+ path: '/telegram/users/',
+ getParentRoute: () => DashboardRoute,
+ } as any)
+const DashboardTelegramUsersUserIdRoute =
+ DashboardTelegramUsersUserIdRouteImport.update({
+ id: '/telegram/users/$userId',
+ path: '/telegram/users/$userId',
+ getParentRoute: () => DashboardRoute,
+ } as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/dashboard': typeof DashboardRouteWithChildren
+ '/login': typeof LoginRoute
+ '/dashboard/account': typeof DashboardAccountRoute
+ '/onboarding/link': typeof OnboardingLinkRoute
+ '/dashboard/': typeof DashboardIndexRoute
+ '/api/auth/$': typeof ApiAuthSplatRoute
+ '/dashboard/azure/members': typeof DashboardAzureMembersRoute
+ '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute
+ '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute
+ '/dashboard/web/guides': typeof DashboardWebGuidesRoute
+ '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute
+ '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/login': typeof LoginRoute
+ '/dashboard/account': typeof DashboardAccountRoute
+ '/onboarding/link': typeof OnboardingLinkRoute
+ '/dashboard': typeof DashboardIndexRoute
+ '/api/auth/$': typeof ApiAuthSplatRoute
+ '/dashboard/azure/members': typeof DashboardAzureMembersRoute
+ '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute
+ '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute
+ '/dashboard/web/guides': typeof DashboardWebGuidesRoute
+ '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute
+ '/dashboard/telegram/users': typeof DashboardTelegramUsersIndexRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/dashboard': typeof DashboardRouteWithChildren
+ '/login': typeof LoginRoute
+ '/dashboard/account': typeof DashboardAccountRoute
+ '/onboarding/link': typeof OnboardingLinkRoute
+ '/dashboard/': typeof DashboardIndexRoute
+ '/api/auth/$': typeof ApiAuthSplatRoute
+ '/dashboard/azure/members': typeof DashboardAzureMembersRoute
+ '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute
+ '/dashboard/telegram/groups': typeof DashboardTelegramGroupsRoute
+ '/dashboard/web/guides': typeof DashboardWebGuidesRoute
+ '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute
+ '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths:
+ | '/'
+ | '/dashboard'
+ | '/login'
+ | '/dashboard/account'
+ | '/onboarding/link'
+ | '/dashboard/'
+ | '/api/auth/$'
+ | '/dashboard/azure/members'
+ | '/dashboard/telegram/grants'
+ | '/dashboard/telegram/groups'
+ | '/dashboard/web/guides'
+ | '/dashboard/telegram/users/$userId'
+ | '/dashboard/telegram/users/'
+ fileRoutesByTo: FileRoutesByTo
+ to:
+ | '/'
+ | '/login'
+ | '/dashboard/account'
+ | '/onboarding/link'
+ | '/dashboard'
+ | '/api/auth/$'
+ | '/dashboard/azure/members'
+ | '/dashboard/telegram/grants'
+ | '/dashboard/telegram/groups'
+ | '/dashboard/web/guides'
+ | '/dashboard/telegram/users/$userId'
+ | '/dashboard/telegram/users'
+ id:
+ | '__root__'
+ | '/'
+ | '/dashboard'
+ | '/login'
+ | '/dashboard/account'
+ | '/onboarding/link'
+ | '/dashboard/'
+ | '/api/auth/$'
+ | '/dashboard/azure/members'
+ | '/dashboard/telegram/grants'
+ | '/dashboard/telegram/groups'
+ | '/dashboard/web/guides'
+ | '/dashboard/telegram/users/$userId'
+ | '/dashboard/telegram/users/'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ DashboardRoute: typeof DashboardRouteWithChildren
+ LoginRoute: typeof LoginRoute
+ OnboardingLinkRoute: typeof OnboardingLinkRoute
+ ApiAuthSplatRoute: typeof ApiAuthSplatRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/login': {
+ id: '/login'
+ path: '/login'
+ fullPath: '/login'
+ preLoaderRoute: typeof LoginRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/dashboard': {
+ id: '/dashboard'
+ path: '/dashboard'
+ fullPath: '/dashboard'
+ preLoaderRoute: typeof DashboardRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/dashboard/': {
+ id: '/dashboard/'
+ path: '/'
+ fullPath: '/dashboard/'
+ preLoaderRoute: typeof DashboardIndexRouteImport
+ parentRoute: typeof DashboardRoute
+ }
+ '/onboarding/link': {
+ id: '/onboarding/link'
+ path: '/onboarding/link'
+ fullPath: '/onboarding/link'
+ preLoaderRoute: typeof OnboardingLinkRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/dashboard/account': {
+ id: '/dashboard/account'
+ path: '/account'
+ fullPath: '/dashboard/account'
+ preLoaderRoute: typeof DashboardAccountRouteImport
+ parentRoute: typeof DashboardRoute
+ }
+ '/dashboard/web/guides': {
+ id: '/dashboard/web/guides'
+ path: '/web/guides'
+ fullPath: '/dashboard/web/guides'
+ preLoaderRoute: typeof DashboardWebGuidesRouteImport
+ parentRoute: typeof DashboardRoute
+ }
+ '/dashboard/telegram/groups': {
+ id: '/dashboard/telegram/groups'
+ path: '/telegram/groups'
+ fullPath: '/dashboard/telegram/groups'
+ preLoaderRoute: typeof DashboardTelegramGroupsRouteImport
+ parentRoute: typeof DashboardRoute
+ }
+ '/dashboard/telegram/grants': {
+ id: '/dashboard/telegram/grants'
+ path: '/telegram/grants'
+ fullPath: '/dashboard/telegram/grants'
+ preLoaderRoute: typeof DashboardTelegramGrantsRouteImport
+ parentRoute: typeof DashboardRoute
+ }
+ '/dashboard/azure/members': {
+ id: '/dashboard/azure/members'
+ path: '/azure/members'
+ fullPath: '/dashboard/azure/members'
+ preLoaderRoute: typeof DashboardAzureMembersRouteImport
+ parentRoute: typeof DashboardRoute
+ }
+ '/api/auth/$': {
+ id: '/api/auth/$'
+ path: '/api/auth/$'
+ fullPath: '/api/auth/$'
+ preLoaderRoute: typeof ApiAuthSplatRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/dashboard/telegram/users/': {
+ id: '/dashboard/telegram/users/'
+ path: '/telegram/users'
+ fullPath: '/dashboard/telegram/users/'
+ preLoaderRoute: typeof DashboardTelegramUsersIndexRouteImport
+ parentRoute: typeof DashboardRoute
+ }
+ '/dashboard/telegram/users/$userId': {
+ id: '/dashboard/telegram/users/$userId'
+ path: '/telegram/users/$userId'
+ fullPath: '/dashboard/telegram/users/$userId'
+ preLoaderRoute: typeof DashboardTelegramUsersUserIdRouteImport
+ parentRoute: typeof DashboardRoute
+ }
+ }
+}
+
+interface DashboardRouteChildren {
+ DashboardAccountRoute: typeof DashboardAccountRoute
+ DashboardIndexRoute: typeof DashboardIndexRoute
+ DashboardAzureMembersRoute: typeof DashboardAzureMembersRoute
+ DashboardTelegramGrantsRoute: typeof DashboardTelegramGrantsRoute
+ DashboardTelegramGroupsRoute: typeof DashboardTelegramGroupsRoute
+ DashboardWebGuidesRoute: typeof DashboardWebGuidesRoute
+ DashboardTelegramUsersUserIdRoute: typeof DashboardTelegramUsersUserIdRoute
+ DashboardTelegramUsersIndexRoute: typeof DashboardTelegramUsersIndexRoute
+}
+
+const DashboardRouteChildren: DashboardRouteChildren = {
+ DashboardAccountRoute: DashboardAccountRoute,
+ DashboardIndexRoute: DashboardIndexRoute,
+ DashboardAzureMembersRoute: DashboardAzureMembersRoute,
+ DashboardTelegramGrantsRoute: DashboardTelegramGrantsRoute,
+ DashboardTelegramGroupsRoute: DashboardTelegramGroupsRoute,
+ DashboardWebGuidesRoute: DashboardWebGuidesRoute,
+ DashboardTelegramUsersUserIdRoute: DashboardTelegramUsersUserIdRoute,
+ DashboardTelegramUsersIndexRoute: DashboardTelegramUsersIndexRoute,
+}
+
+const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
+ DashboardRouteChildren,
+)
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ DashboardRoute: DashboardRouteWithChildren,
+ LoginRoute: LoginRoute,
+ OnboardingLinkRoute: OnboardingLinkRoute,
+ ApiAuthSplatRoute: ApiAuthSplatRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+import type { getRouter } from './router.tsx'
+import type { createStart } from '@tanstack/react-start'
+declare module '@tanstack/react-start' {
+ interface Register {
+ ssr: true
+ router: Awaited>
+ }
+}
diff --git a/src/router.tsx b/src/router.tsx
new file mode 100644
index 0000000..40bb359
--- /dev/null
+++ b/src/router.tsx
@@ -0,0 +1,18 @@
+import { createRouter } from "@tanstack/react-router"
+import { routeTree } from "./routeTree.gen"
+
+export function getRouter() {
+ return createRouter({
+ routeTree,
+ defaultPreload: "intent",
+ defaultPreloadStaleTime: 0,
+ defaultPendingMs: 100,
+ scrollRestoration: true,
+ })
+}
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: ReturnType
+ }
+}
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
new file mode 100644
index 0000000..4732c43
--- /dev/null
+++ b/src/routes/__root.tsx
@@ -0,0 +1,36 @@
+import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"
+import { Toaster } from "../components/ui/sonner"
+import appCss from "../styles.css?url"
+
+export const Route = createRootRoute({
+ head: () => ({
+ meta: [
+ { charSet: "utf-8" },
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
+ { title: "PoliNetwork Admin" },
+ { name: "description", content: "PoliNetwork's internal operations console." },
+ ],
+ links: [{ rel: "stylesheet", href: appCss }],
+ }),
+ shellComponent: RootDocument,
+})
+
+function RootDocument({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/src/routes/api/auth/$.ts b/src/routes/api/auth/$.ts
new file mode 100644
index 0000000..492acb8
--- /dev/null
+++ b/src/routes/api/auth/$.ts
@@ -0,0 +1,25 @@
+import { AUTH_PATH } from "@polinetwork/backend"
+import { createFileRoute } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/api/auth/$")({
+ server: {
+ handlers: {
+ GET: proxyAuth,
+ POST: proxyAuth,
+ PUT: proxyAuth,
+ PATCH: proxyAuth,
+ DELETE: proxyAuth,
+ },
+ },
+})
+
+async function proxyAuth({ request }: { request: Request }) {
+ const incomingUrl = new URL(request.url)
+ const backendOrigin = process.env.BACKEND_URL ?? "http://localhost:3000"
+ const backendUrl = new URL(
+ `${AUTH_PATH}${incomingUrl.pathname.slice(AUTH_PATH.length)}${incomingUrl.search}`,
+ backendOrigin
+ )
+
+ return fetch(new Request(backendUrl, request))
+}
diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx
new file mode 100644
index 0000000..b940b0f
--- /dev/null
+++ b/src/routes/dashboard.tsx
@@ -0,0 +1,17 @@
+import { createFileRoute, redirect } from "@tanstack/react-router"
+import { DashboardFrame } from "@/components/dashboard-frame"
+import { getCurrentSession } from "@/server/api.functions"
+
+export const Route = createFileRoute("/dashboard")({
+ beforeLoad: async () => {
+ const session = await getCurrentSession()
+ if (!session?.user) throw redirect({ to: "/login" })
+ return { session }
+ },
+ component: DashboardLayout,
+})
+
+function DashboardLayout() {
+ const { session } = Route.useRouteContext()
+ return
+}
diff --git a/src/routes/dashboard/account.tsx b/src/routes/dashboard/account.tsx
new file mode 100644
index 0000000..8beb28b
--- /dev/null
+++ b/src/routes/dashboard/account.tsx
@@ -0,0 +1,446 @@
+import { createFileRoute, useRouter } from "@tanstack/react-router"
+import {
+ Calendar,
+ Camera,
+ KeyRound,
+ LoaderCircle,
+ LogOut,
+ Mail,
+ MonitorSmartphone,
+ Shield,
+ Trash2,
+ UserRound,
+} from "lucide-react"
+import { useCallback, useEffect, useRef, useState } from "react"
+import { PageHeader } from "@/components/page-header"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+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 { Separator } from "@/components/ui/separator"
+import { Skeleton } from "@/components/ui/skeleton"
+import { auth, useSession } from "@/lib/auth"
+import type { AdminSession } from "@/server/api.functions"
+import { uploadProfilePicture } from "@/server/api.functions"
+
+type Passkey = { id: string; name?: string | null; createdAt?: Date | string; deviceType?: string }
+type ActiveSession = {
+ id: string
+ token: string
+ userAgent?: string | null
+ ipAddress?: string | null
+ createdAt?: Date | string
+}
+
+export const Route = createFileRoute("/dashboard/account")({ component: Account })
+
+function avatarText(name?: string | null, email?: string) {
+ return (name || email || "U")
+ .split(/[\s.@_-]+/)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase())
+ .join("")
+}
+
+function Account() {
+ const router = useRouter()
+ const initial = Route.useRouteContext().session as AdminSession
+ const sessionQuery = useSession()
+ const session = (sessionQuery.data as AdminSession | null) ?? initial
+ const user = session.user
+ const fileInput = useRef(null)
+ const [name, setName] = useState(user?.name ?? "")
+ const [passkeys, setPasskeys] = useState([])
+ const [sessions, setSessions] = useState([])
+ const [securityLoading, setSecurityLoading] = useState(true)
+ const [busy, setBusy] = useState(null)
+ const [notice, setNotice] = useState<{ type: "success" | "error"; text: string } | null>(null)
+
+ const refreshSecurityData = useCallback(async () => {
+ try {
+ const [passkeyResult, sessionResult] = await Promise.all([auth.passkey.listUserPasskeys(), auth.listSessions()])
+ setPasskeys((passkeyResult.data ?? []) as Passkey[])
+ setSessions((sessionResult.data ?? []) as ActiveSession[])
+ } finally {
+ setSecurityLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void refreshSecurityData()
+ }, [refreshSecurityData])
+ useEffect(() => setName(user?.name ?? ""), [user?.name])
+
+ async function updateName(event: React.FormEvent) {
+ event.preventDefault()
+ setBusy("name")
+ setNotice(null)
+ 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." })
+ }
+ 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 uploadProfilePicture({ 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)
+ 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." })
+ }
+ setBusy(null)
+ }
+
+ async function addPasskey() {
+ setBusy("passkey")
+ setNotice(null)
+ 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 {
+ await refreshSecurityData()
+ setNotice({ type: "success", text: "Passkey created." })
+ }
+ setBusy(null)
+ }
+
+ async function deletePasskey(id: string) {
+ setBusy(id)
+ setNotice(null)
+ const result = await auth.passkey.deletePasskey({ id })
+ if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not delete the passkey." })
+ else {
+ await refreshSecurityData()
+ setNotice({ type: "success", text: "Passkey deleted." })
+ }
+ setBusy(null)
+ }
+
+ async function revokeOtherSessions() {
+ setBusy("sessions")
+ setNotice(null)
+ const result = await auth.revokeOtherSessions()
+ if (result.error) setNotice({ type: "error", text: result.error.message ?? "Could not revoke other sessions." })
+ else {
+ await refreshSecurityData()
+ setNotice({ type: "success", text: "Other sessions signed out." })
+ }
+ setBusy(null)
+ }
+
+ async function logout() {
+ setBusy("logout")
+ await auth.signOut()
+ await router.invalidate()
+ await router.navigate({ to: "/login", replace: true })
+ }
+
+ return (
+
+
+ {notice && (
+
+ {notice.text}
+
+ )}
+
+
+
+
+
+ {user?.image && }
+
+ {avatarText(user?.name, user?.email)}
+
+
+
fileInput.current?.click()}
+ disabled={busy === "image"}
+ aria-label="Upload profile picture"
+ >
+
+
+
void uploadImage(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 && (
+ void removeImage()}
+ disabled={busy === "image"}
+ >
+ Remove picture
+
+ )}
+
+
+
+
+
+
+
+
+ Profile details
+ Displayed throughout the admin console.
+
+
+
+
+
+
+
+
+
+
+
+ Telegram identity
+ Used to determine roles and permissions.
+
+
+
+
+
+
Username
+ {user?.telegramUsername ? `@${user.telegramUsername}` : "Not available"}
+
+
+
Telegram ID
+ {user?.telegramId ?? "Not linked"}
+
+
+
+
+
+
+
+
+
+
+ Passkeys
+ Phishing-resistant access from your trusted devices.
+
+
+ void addPasskey()} disabled={busy === "passkey"}>
+ Add passkey
+
+
+
+ {securityLoading ? (
+
+ ) : (
+
+ {passkeys.map((passkey) => (
+
+ Added{" "}
+ {passkey.createdAt ? new Date(passkey.createdAt).toLocaleDateString() : "recently"}
+ {passkey.deviceType ? ` · ${passkey.deviceType}` : ""}
+ >
+ }
+ action={
+ void deletePasskey(passkey.id)}
+ disabled={busy === passkey.id}
+ aria-label="Delete passkey"
+ >
+
+
+ }
+ />
+ ))}
+ {!passkeys.length && (
+ No passkeys registered yet.
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+ Active sessions
+ Devices currently signed in to your account.
+
+
+ {sessions.length > 1 && (
+ void revokeOtherSessions()}
+ disabled={busy === "sessions"}
+ >
+ Sign out other sessions
+
+ )}
+
+
+ {securityLoading ? (
+
+ ) : (
+
+ {sessions.map((activeSession) => (
+
+ {activeSession.ipAddress || "Unknown IP"}
+ {activeSession.createdAt
+ ? ` · Since ${new Date(activeSession.createdAt).toLocaleDateString()}`
+ : ""}
+ >
+ }
+ action={
+ activeSession.id === session.session?.id ? (
+ Current
+ ) : undefined
+ }
+ />
+ ))}
+
+ )}
+
+ void logout()}
+ disabled={busy === "logout"}
+ >
+ Sign out of this device
+
+
+
+
+
+ )
+}
+
+function SecurityList({ children }: { children: React.ReactNode }) {
+ return {children}
+}
+
+function SecurityLoading({ rows }: { rows: number }) {
+ return (
+
+ {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/routes/dashboard/azure/members.tsx b/src/routes/dashboard/azure/members.tsx
new file mode 100644
index 0000000..db71956
--- /dev/null
+++ b/src/routes/dashboard/azure/members.tsx
@@ -0,0 +1,405 @@
+import { createFileRoute, useRouter } from "@tanstack/react-router"
+import type { Column } from "@tanstack/react-table"
+import { ArrowDown, ArrowUp, Building2, Check, ChevronsUpDown, LoaderCircle, UsersRound } from "lucide-react"
+import { useEffect, useMemo, useState } from "react"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { LiveStatus } from "@/components/live-status"
+import { DataPageSkeleton } from "@/components/loading-skeleton"
+import { Pagination } from "@/components/pagination"
+import { Badge } from "@/components/ui/badge"
+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 { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table"
+import { createAppColumnHelper, type dashboardFeatures, useAppTable } from "@/lib/table"
+import { cn } from "@/lib/utils"
+import { createAzureMember, getAzureMembers, setAzureMemberNumber } from "@/server/api.functions"
+
+type AzureMember = {
+ id: string
+ employeeId?: string | null
+ displayName?: string | null
+ mail?: string | null
+ isMember?: boolean
+ assignedLicensesIds?: string[]
+}
+const memberColumnHelper = createAppColumnHelper()
+type MemberFilter = { query: string; membersOnly: boolean }
+
+export const Route = createFileRoute("/dashboard/azure/members")({
+ loader: () => getAzureMembers(),
+ pendingComponent: () => ,
+ component: AzureMembers,
+})
+
+function AzureMembers() {
+ const response = Route.useLoaderData()
+ const router = useRouter()
+ const [dialog, setDialog] = useState<{ mode: "create" } | { mode: "edit"; member: AzureMember } | null>(null)
+ const loadedMembers = response.data as AzureMember[]
+ const [members, setMembers] = useState(loadedMembers)
+
+ useEffect(() => setMembers(loadedMembers), [loadedMembers])
+
+ 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 = (value ?? {}) as Partial
+ const member = row.original
+ return (
+ (!filter.membersOnly || !!member.isMember) &&
+ `${member.displayName ?? ""} ${member.mail ?? ""} ${member.employeeId ?? ""}`
+ .toLocaleLowerCase()
+ .includes((filter.query ?? "").toLocaleLowerCase())
+ )
+ },
+ })
+ const memberFilter = table.state.globalFilter as MemberFilter
+ const membersOnly = memberFilter.membersOnly
+
+ return (
+
+
table.setGlobalFilter({ ...memberFilter, query: value })}
+ action="Add member"
+ onAction={() => setDialog({ mode: "create" })}
+ >
+ table.setGlobalFilter({ ...memberFilter, membersOnly: !membersOnly })}
+ >
+ Members only
+
+
+
+
+
+
+
+ {members.filter((member) => member.isMember).length} association members
+
+
+
+
+
+
+ {members.filter((member) => member.assignedLicensesIds?.includes("OFFICE_365")).length}
+ {" "}
+ Office 365 licenses
+
+
+
+ {table.getFilteredRowModel().rows.length ? (
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : }
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getAllCells().map((cell) => (
+
+
+
+ ))}
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+ {table.getFilteredRowModel().rows.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)
+ if (mode === "create") {
+ await new Promise((resolve) => setTimeout(resolve, 1500))
+ await router.invalidate({ sync: true })
+ }
+ }}
+ />
+ )}
+
+ )
+}
+
+function MemberDialog({
+ dialog,
+ onClose,
+ onOptimisticUpdate,
+ onSaved,
+}: {
+ dialog: { mode: "create" } | { mode: "edit"; member: AzureMember }
+ 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("")
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault()
+ setPending(true)
+ setError("")
+ const assocNumber = Number.parseInt(memberId, 10)
+ let rollback: (() => void) | undefined
+ try {
+ if (editing) {
+ rollback = onOptimisticUpdate({ ...dialog.member, employeeId: String(assocNumber), isMember: true })
+ await setAzureMemberNumber({ data: { userId: dialog.member.id, assocNumber } })
+ await onSaved("edit")
+ } else {
+ await createAzureMember({ 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/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx
new file mode 100644
index 0000000..cfd0f13
--- /dev/null
+++ b/src/routes/dashboard/index.tsx
@@ -0,0 +1,71 @@
+import { createFileRoute, Link } from "@tanstack/react-router"
+import { ArrowUpRight, BookOpen, Database, ShieldCheck, UsersRound } from "lucide-react"
+import { PageHeader } from "@/components/page-header"
+import { Button } from "@/components/ui/button"
+import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
+
+const areas = [
+ {
+ title: "Telegram users",
+ description: "Search people, inspect member details, roles, messages and audit history.",
+ to: "/dashboard/telegram/users",
+ icon: UsersRound,
+ },
+ {
+ title: "Telegram groups",
+ description: "Review group metadata, invite links and public visibility.",
+ to: "/dashboard/telegram/groups",
+ icon: Database,
+ },
+ {
+ title: "Access grants",
+ description: "Review ongoing and scheduled temporary access grants.",
+ to: "/dashboard/telegram/grants",
+ icon: ShieldCheck,
+ },
+ {
+ title: "Azure members",
+ description: "Manage association numbers and Microsoft 365 licenses.",
+ to: "/dashboard/azure/members",
+ icon: UsersRound,
+ },
+ {
+ title: "Web guides",
+ description: "Open the operational notes for maintaining public web content.",
+ to: "/dashboard/web/guides",
+ icon: BookOpen,
+ },
+] as const
+
+export const Route = createFileRoute("/dashboard/")({ component: Overview })
+
+function Overview() {
+ return (
+
+
+
+ {areas.map(({ title, description, to, icon: Icon }) => (
+
+
+
+
+
+ {title}
+ {description}
+
+
+ } nativeButton={false}>
+ Open area
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/routes/dashboard/telegram/grants.tsx b/src/routes/dashboard/telegram/grants.tsx
new file mode 100644
index 0000000..8abd47b
--- /dev/null
+++ b/src/routes/dashboard/telegram/grants.tsx
@@ -0,0 +1,166 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { CalendarClock } from "lucide-react"
+import { useState } from "react"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { LiveStatus } from "@/components/live-status"
+import { DataPageSkeleton } from "@/components/loading-skeleton"
+import { Pagination } from "@/components/pagination"
+import { Badge } from "@/components/ui/badge"
+import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import { createAppColumnHelper, useAppTable } from "@/lib/table"
+import { getOngoingGrants, getScheduledGrants } from "@/server/api.functions"
+
+type Grant = {
+ userId: number
+ grantorId?: number
+ role?: string
+ createdAt?: string
+ expiresAt?: string | null
+ scheduledAt?: string | null
+}
+const grantColumnHelper = createAppColumnHelper()
+export const Route = createFileRoute("/dashboard/telegram/grants")({
+ loader: async () => ({ ongoing: await getOngoingGrants(), scheduled: await getScheduledGrants() }),
+ pendingComponent: () => ,
+ component: Grants,
+})
+
+function Grants() {
+ const { ongoing, scheduled } = Route.useLoaderData()
+ const [tab, setTab] = useState<"all" | "ongoing" | "scheduled">("all")
+ const ongoingGrants = (ongoing.data as { grants?: Grant[] }).grants ?? []
+ const scheduledGrants = (scheduled.data as { grants?: Grant[] }).grants ?? []
+ const grants =
+ tab === "ongoing" ? ongoingGrants : tab === "scheduled" ? scheduledGrants : [...ongoingGrants, ...scheduledGrants]
+ const connected = ongoing.connected && scheduled.connected
+ const columns = grantColumnHelper.columns([
+ grantColumnHelper.accessor("userId", { header: "User", cell: ({ getValue }) => getValue() }),
+ grantColumnHelper.accessor("role", {
+ header: "Role",
+ cell: ({ getValue }) => (
+ {getValue() ?? "Access grant"}
+ ),
+ }),
+ grantColumnHelper.accessor("grantorId", { header: "Granted by", cell: ({ getValue }) => getValue() ?? "—" }),
+ grantColumnHelper.display({
+ id: "schedule",
+ header: "Schedule",
+ cell: ({ row }) => row.original.scheduledAt ?? row.original.createdAt ?? "—",
+ }),
+ grantColumnHelper.display({
+ id: "status",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {row.original.scheduledAt ? "Scheduled" : "Active"}
+
+ ),
+ }),
+ ])
+ const table = useAppTable({
+ key: "telegram-grants",
+ columns,
+ data: grants,
+ initialState: { sorting: [{ id: "userId", desc: false }], pagination: { pageIndex: 0, pageSize: 20 } },
+ globalFilterFn: (row, _columnId, value) => {
+ const grant = row.original
+ const query = String(value ?? "")
+ .trim()
+ .toLocaleLowerCase()
+ return (
+ !query ||
+ `${grant.userId} ${grant.grantorId ?? ""} ${grant.role ?? ""} ${grant.scheduledAt ?? ""} ${grant.createdAt ?? ""}`
+ .toLocaleLowerCase()
+ .includes(query)
+ )
+ },
+ })
+ return (
+
+
table.setGlobalFilter(value)}
+ />
+
+ {
+ const next = value[0]
+ if (next === "all" || next === "ongoing" || next === "scheduled") setTab(next)
+ }}
+ className="mb-4"
+ aria-label="Grant status"
+ >
+
+ All {ongoingGrants.length + scheduledGrants.length}
+
+
+ Ongoing {ongoingGrants.length}
+
+
+ Scheduled {scheduledGrants.length}
+
+
+ {table.getFilteredRowModel().rows.length ? (
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder ? null : }
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getAllCells().map((cell) => (
+
+
+
+ ))}
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+ {table.getFilteredRowModel().rows.length > 0 && (
+ table.setPageIndex(page - 1)}
+ onPageSizeChange={(pageSize) => table.setPageSize(pageSize)}
+ />
+ )}
+
+ )
+}
diff --git a/src/routes/dashboard/telegram/groups.tsx b/src/routes/dashboard/telegram/groups.tsx
new file mode 100644
index 0000000..e126f6c
--- /dev/null
+++ b/src/routes/dashboard/telegram/groups.tsx
@@ -0,0 +1,214 @@
+import { createFileRoute, useRouter } from "@tanstack/react-router"
+import { Eye, EyeOff, MessageCircleMore } from "lucide-react"
+import { useEffect, useMemo, useState } from "react"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { LiveStatus } from "@/components/live-status"
+import { DataPageSkeleton } from "@/components/loading-skeleton"
+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 } from "@/components/ui/table"
+import { createAppColumnHelper, useAppTable } from "@/lib/table"
+import { cn } from "@/lib/utils"
+import { getTelegramGroups, setGroupVisibility } from "@/server/api.functions"
+
+type TelegramGroup = {
+ telegramId: number
+ title: string
+ tag?: string | null
+ link?: string | null
+ inviteLink?: string | null
+ hide?: boolean | null
+}
+
+const groupColumnHelper = createAppColumnHelper()
+
+export const Route = createFileRoute("/dashboard/telegram/groups")({
+ loader: () => getTelegramGroups(),
+ pendingComponent: () => ,
+ component: TelegramGroups,
+})
+
+function TelegramGroups() {
+ const response = Route.useLoaderData()
+ const router = useRouter()
+ const loadedGroups = response.data as TelegramGroup[]
+ const [groups, setGroups] = useState(loadedGroups)
+ const [updatingId, setUpdatingId] = useState(null)
+ const [mutationError, setMutationError] = useState("")
+
+ useEffect(() => setGroups(loadedGroups), [loadedGroups])
+
+ async function toggleVisibility(group: TelegramGroup) {
+ if (updatingId !== null) return
+ const hide = !group.hide
+ setUpdatingId(group.telegramId)
+ setMutationError("")
+ setGroups((current) => current.map((item) => (item.telegramId === group.telegramId ? { ...item, hide } : item)))
+
+ try {
+ await setGroupVisibility({ data: { telegramId: group.telegramId, hide } })
+ await router.invalidate({ sync: true })
+ } catch {
+ setGroups((current) => current.map((item) => (item.telegramId === group.telegramId ? group : item)))
+ setMutationError("The visibility setting could not be updated. Check your permissions and try again.")
+ } finally {
+ setUpdatingId(null)
+ }
+ }
+
+ const columns = useMemo(
+ () =>
+ groupColumnHelper.columns([
+ groupColumnHelper.accessor("title", {
+ header: "Group",
+ cell: ({ row }) => (
+
+
+
+
+ {row.original.title}
+
+ ),
+ }),
+ groupColumnHelper.accessor("telegramId", { header: "Telegram ID", cell: ({ getValue }) => getValue() }),
+ groupColumnHelper.accessor("tag", {
+ header: "Tag",
+ cell: ({ getValue }) =>
+ getValue() ? (
+ @{getValue()}
+ ) : (
+ —
+ ),
+ }),
+ groupColumnHelper.display({
+ id: "visibility",
+ header: "Visibility",
+ cell: ({ row }) => {
+ const group = row.original
+ const pending = updatingId === group.telegramId
+ return (
+ void toggleVisibility(group)}
+ title="Toggle group visibility"
+ >
+ {group.hide ? (
+ <>
+ Hidden
+ >
+ ) : (
+ <>
+ Visible
+ >
+ )}
+
+ )
+ },
+ }),
+ groupColumnHelper.display({
+ id: "invite",
+ header: "Invite",
+ cell: ({ row }) => {
+ const link = row.original.link ?? row.original.inviteLink
+ return link ? (
+
+ Open link
+
+ ) : (
+ Not shared
+ )
+ },
+ }),
+ ]),
+ [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}\u0000${group.tag ?? ""}`.toLocaleLowerCase().includes(query)
+ },
+ })
+
+ return (
+
+
table.setGlobalFilter(value)}
+ />
+
+ {mutationError && (
+
+ {mutationError}
+
+ )}
+ {table.getFilteredRowModel().rows.length ? (
+ <>
+
+
+
+ {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/routes/dashboard/telegram/users/$userId.tsx b/src/routes/dashboard/telegram/users/$userId.tsx
new file mode 100644
index 0000000..cf1c53a
--- /dev/null
+++ b/src/routes/dashboard/telegram/users/$userId.tsx
@@ -0,0 +1,420 @@
+import { Combobox } from "@base-ui/react/combobox"
+import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
+import {
+ ArrowLeft,
+ CalendarClock,
+ Check,
+ ChevronDown,
+ ExternalLink,
+ History,
+ LoaderCircle,
+ MessageCircle,
+ ShieldCheck,
+ UserPlus,
+ UserRound,
+ UsersRound,
+} from "lucide-react"
+import { useEffect, useState } from "react"
+import { LiveStatus } from "@/components/live-status"
+import { DetailPageSkeleton } from "@/components/loading-skeleton"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Field, FieldLabel } from "@/components/ui/field"
+import { addTelegramGroupAdmin, getTelegramUserDetails } from "@/server/api.functions"
+
+export const Route = createFileRoute("/dashboard/telegram/users/$userId")({
+ loader: ({ params }) => getTelegramUserDetails({ data: { userId: Number.parseInt(params.userId, 10) } }),
+ pendingComponent: DetailPageSkeleton,
+ component: UserProfile,
+})
+
+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 UserProfile() {
+ const response = Route.useLoaderData()
+ const { userId } = Route.useParams()
+ const router = useRouter()
+ const [adminDialogOpen, setAdminDialogOpen] = useState(false)
+ const data = Array.isArray(response.data) ? null : response.data
+
+ if (!data) {
+ return (
+
+
+
+
+
+
+ User not found
+ No Telegram user exists with ID {userId}.
+
+
+
+ )
+ }
+
+ const { user, roles, groupAdmin, groups, messages, audits, grant } = data
+ 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
+ )}
+
+
+
+ {grant ? (
+
+ {formatDate(grant.validSince)}
+ {formatDate(grant.validUntil)}
+
+ ) : (
+ No ongoing grant
+ )}
+
+
+
+
setAdminDialogOpen(true)}>
+ Add group
+
+ }
+ >
+
+ {groupAdmin
+ .filter((entry) => entry !== null)
+ .map((entry) => (
+
+
+ {entry.group.title}
+ {entry.group.id}
+
+ Added by {entry.addedBy.firstName}
+ {entry.addedBy.username ? ` · @${entry.addedBy.username}` : ""}
+
+
+
+ ))}
+ {!groupAdmin.length &&
}
+
+
+
entry !== null).map((entry) => entry.group.id))}
+ onClose={() => setAdminDialogOpen(false)}
+ onSaved={async () => {
+ setAdminDialogOpen(false)
+ await router.invalidate({ sync: true })
+ }}
+ />
+
+
+ {messages.map((message) => (
+
+
+
+
{message.group?.title ?? `Chat ${message.chatId}`}
+ {formatDate(message.timestamp)}
+
+ {message.message}
+
+ Open message
+
+
+
+ ))}
+ {!messages.length &&
}
+
+
+
+
+ {audits.map((audit) => (
+
+
+
+
{audit.type}
+ {formatDate(audit.createdAt)}
+
+ {audit.reason ?? "No reason provided"}
+ {audit.groupTitle && (
+
+ {audit.groupTitle} · {audit.groupId}
+
+ )}
+
+
+ ))}
+ {!audits.length &&
}
+
+
+
+ )
+}
+
+function BackLink() {
+ return (
+
+ Back to users
+
+ )
+}
+
+function SummaryCard({
+ icon: Icon,
+ label,
+ children,
+}: {
+ icon: typeof UserRound
+ label: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+ {children}
+
+ )
+}
+
+function Definition({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ )
+}
+function SectionEmpty({ text }: { text: string }) {
+ return (
+ {text}
+ )
+}
+
+function DetailSection({
+ icon: Icon,
+ title,
+ count,
+ action,
+ children,
+}: {
+ icon: typeof UserRound
+ title: string
+ count: number
+ action?: React.ReactNode
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
+ {title}
+
+
+ {action}
+ {count}
+
+
+ {children}
+
+ )
+}
+
+function AddGroupAdminDialog({
+ open,
+ userId,
+ groups,
+ administeredGroupIds,
+ onClose,
+ onSaved,
+}: {
+ open: boolean
+ userId: number
+ groups: Array<{ telegramId: number; title: string }>
+ administeredGroupIds: Set
+ onClose: () => void
+ onSaved: () => Promise
+}) {
+ const [groupId, setGroupId] = useState("")
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState("")
+ 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 addTelegramGroupAdmin({ 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.
+
+
+
+
+
+ )
+}
diff --git a/src/routes/dashboard/telegram/users/index.tsx b/src/routes/dashboard/telegram/users/index.tsx
new file mode 100644
index 0000000..3e9c193
--- /dev/null
+++ b/src/routes/dashboard/telegram/users/index.tsx
@@ -0,0 +1,158 @@
+import { createFileRoute, Link } from "@tanstack/react-router"
+import { Eye, MessageCircle } from "lucide-react"
+import { useMemo } from "react"
+import { DataToolbar } from "@/components/data-toolbar"
+import { EmptyState } from "@/components/empty-state"
+import { LiveStatus } from "@/components/live-status"
+import { DataPageSkeleton } from "@/components/loading-skeleton"
+import { Pagination } from "@/components/pagination"
+import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table"
+import { createAppColumnHelper, useAppTable } from "@/lib/table"
+import { getTelegramUsers } from "@/server/api.functions"
+
+type TelegramUser = {
+ id: number
+ username?: string | null
+ firstName?: string | null
+ lastName?: string | null
+ profilePicUrl?: string | null
+}
+
+const userColumnHelper = createAppColumnHelper()
+
+export const Route = createFileRoute("/dashboard/telegram/users/")({
+ loader: () => getTelegramUsers(),
+ pendingComponent: () => ,
+ component: TelegramUsers,
+})
+
+function TelegramUsers() {
+ const response = Route.useLoaderData()
+ const users = (response.data as { users?: TelegramUser[] }).users ?? []
+
+ const columns = useMemo(
+ () =>
+ userColumnHelper.columns([
+ userColumnHelper.accessor("id", {
+ header: "Telegram ID",
+ cell: ({ getValue }) => getValue(),
+ }),
+ userColumnHelper.display({
+ id: "identity",
+ header: "Identity",
+ cell: ({ row }) => {
+ const user = row.original
+ return (
+
+
+ {(user.firstName?.[0] ?? user.username?.[0] ?? "?").toUpperCase()}
+
+ {[user.firstName, user.lastName].filter(Boolean).join(" ") || "Unnamed account"}
+
+ )
+ },
+ }),
+ userColumnHelper.accessor("username", {
+ header: "Username",
+ cell: ({ getValue }) => {
+ const username = getValue()
+ return username ? (
+ @{username}
+ ) : (
+ Not set
+ )
+ },
+ }),
+ userColumnHelper.display({
+ id: "actions",
+ header: "",
+ cell: ({ row }) => (
+
+
+
+ ),
+ }),
+ ]),
+ []
+ )
+ 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(/^@/, "")
+ return (
+ !query ||
+ `${user.username ?? ""}\u0000${user.firstName ?? ""}\u0000${user.lastName ?? ""}`
+ .toLocaleLowerCase()
+ .includes(query)
+ )
+ },
+ })
+
+ return (
+
+
table.setGlobalFilter(value)}
+ />
+
+ {table.getFilteredRowModel().rows.length ? (
+ <>
+
+
+
+ {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/routes/dashboard/web/guides.tsx b/src/routes/dashboard/web/guides.tsx
new file mode 100644
index 0000000..602e594
--- /dev/null
+++ b/src/routes/dashboard/web/guides.tsx
@@ -0,0 +1,54 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { BookOpen, Globe2, LifeBuoy } from "lucide-react"
+import { PageHeader } from "@/components/page-header"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+
+export const Route = createFileRoute("/dashboard/web/guides")({ component: WebGuides })
+
+const guides = [
+ {
+ title: "Homepage content",
+ description: "Keep the public website’s key information clear, current and easy to find.",
+ icon: Globe2,
+ },
+ {
+ title: "Publishing checklist",
+ description: "Review links, language, accessibility and page details before publishing.",
+ icon: BookOpen,
+ },
+ {
+ title: "Support",
+ description: "Contact the web team when content needs technical or editorial review.",
+ icon: LifeBuoy,
+ },
+] as const
+
+function WebGuides() {
+ return (
+
+
+
+ {guides.map(({ title, description, icon: Icon }) => (
+
+
+
+
+
+ {title}
+ {description}
+
+
+
+ Documentation content is maintained by the web operations team.
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
new file mode 100644
index 0000000..e753ac4
--- /dev/null
+++ b/src/routes/index.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute, redirect } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/")({
+ beforeLoad: () => {
+ throw redirect({ to: "/dashboard" })
+ },
+})
diff --git a/src/routes/login.tsx b/src/routes/login.tsx
new file mode 100644
index 0000000..ca75c63
--- /dev/null
+++ b/src/routes/login.tsx
@@ -0,0 +1,189 @@
+import { createFileRoute, Link, redirect, 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 { getCurrentSession, testBackend } from "@/server/api.functions"
+
+export const Route = createFileRoute("/login")({
+ beforeLoad: async () => {
+ if (await getCurrentSession()) throw redirect({ to: "/dashboard" })
+ },
+ component: Login,
+})
+
+function Login() {
+ 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("")
+ const { data, error } = await auth.emailOtp.sendVerificationOtp({ type: "sign-in", email })
+ setBusy(false)
+ if (data?.success) setSent(true)
+ else setNotice(error?.message ?? "We could not send a code. Check your email and try again.")
+ }
+
+ async function verify() {
+ setBusy(true)
+ setNotice("")
+ const { data, error } = await auth.signIn.emailOtp({ email, otp })
+ setBusy(false)
+ if (data) router.navigate({ to: "/dashboard" })
+ else setNotice(error?.message ?? "That code is not valid. Please try again.")
+ }
+
+ async function passkey() {
+ setBusy(true)
+ setNotice("")
+ const { data, error } = await auth.signIn.passkey()
+ setBusy(false)
+ if (data) router.navigate({ to: "/dashboard" })
+ else setNotice(error?.message ?? "Passkey sign in was cancelled or unavailable.")
+ }
+
+ 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
+
+
+ Need access?{" "}
+
+ Learn how access works
+
+
+
+
+
+
+ )
+}
diff --git a/src/routes/onboarding/link.tsx b/src/routes/onboarding/link.tsx
new file mode 100644
index 0000000..eb0da97
--- /dev/null
+++ b/src/routes/onboarding/link.tsx
@@ -0,0 +1,36 @@
+import { createFileRoute, Link } from "@tanstack/react-router"
+import { ArrowLeft, MessageCircle } from "lucide-react"
+import { AppMark } from "@/components/app-mark"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+
+export const Route = createFileRoute("/onboarding/link")({ component: LinkTelegram })
+
+function LinkTelegram() {
+ return (
+
+
+
+
+
+
+
+ Telegram access
+
+ Your Telegram identity is used to verify the administrative roles that unlock this workspace. Sign in first;
+ if your account is not linked, the administrator onboarding flow will guide you through the connection.
+
+
+
+ } nativeButton={false}>
+ Back to sign in
+
+
+
+
+ )
+}
diff --git a/src/server/actions/azure.ts b/src/server/actions/azure.ts
deleted file mode 100644
index 5c7594f..0000000
--- a/src/server/actions/azure.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-"use server"
-
-import { requireRole } from "../auth"
-import { trpc } from "../trpc"
-import type { ApiInput } from "../trpc/types"
-
-export async function getAzureMembers() {
- return await trpc.azure.members.getAll.query()
-}
-
-export async function createAzureMember(input: ApiInput["azure"]["members"]["create"]) {
- const { allowed } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return { error: "UNAUTHORIZED" }
-
- return await trpc.azure.members.create.mutate(input)
-}
-
-export async function setAzureMemberNumber(input: ApiInput["azure"]["members"]["setAssocNumber"]) {
- const { allowed } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return { error: "UNAUTHORIZED" }
-
- return await trpc.azure.members.setAssocNumber.mutate(input)
-}
diff --git a/src/server/actions/grants.ts b/src/server/actions/grants.ts
deleted file mode 100644
index 07f2769..0000000
--- a/src/server/actions/grants.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-"use server"
-
-import { requireRole } from "../auth"
-import { trpc } from "../trpc"
-import type { ApiInput } from "../trpc/types"
-
-export async function getUserGrant(userId: number) {
- return await trpc.tg.grants.checkUser.query({ userId })
-}
-
-export async function createGrant(input: Omit) {
- const { allowed, telegramId } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return { success: false, error: "UNAUTHORIZED" }
-
- return await trpc.tg.grants.create.mutate({ ...input, adderId: telegramId, sendTgLog: true })
-}
-
-export async function interruptGrant(userId: number) {
- const { allowed, telegramId } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed)
- return {
- success: false,
- error: "UNAUTHORIZED",
- }
-
- return await trpc.tg.grants.interrupt.mutate({ userId, interruptedById: telegramId, sendTgLog: true })
-}
diff --git a/src/server/actions/groups.ts b/src/server/actions/groups.ts
deleted file mode 100644
index f09c69e..0000000
--- a/src/server/actions/groups.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-"use server"
-
-import { requireRole } from "../auth"
-import { trpc } from "../trpc"
-
-export async function searchGroup(query: string) {
- return trpc.tg.groups.search.query({ query, limit: 20, showHidden: true })
-}
-
-export async function setGroupHide(groupId: number, hide: boolean) {
- const { allowed } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return false
-
- return trpc.tg.groups.setHide.mutate({ telegramId: groupId, hide })
-}
-
-export async function leaveChat(chatId: number) {
- const { allowed, telegramId } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return { error: "UNAUTHORIZED" }
-
- return trpc.tg.groups.leaveChat.mutate({ chatId, performerId: telegramId })
-}
diff --git a/src/server/actions/test.ts b/src/server/actions/test.ts
deleted file mode 100644
index 3925d43..0000000
--- a/src/server/actions/test.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-"use server"
-import { trpc } from "../trpc"
-
-export async function testDb() {
- return trpc.test.dbQuery.query({ dbName: "tg" })
-}
diff --git a/src/server/actions/users.ts b/src/server/actions/users.ts
deleted file mode 100644
index cb826d6..0000000
--- a/src/server/actions/users.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-"use server"
-import { getServerSession, requireRole } from "../auth"
-import { trpc } from "../trpc"
-import type { ApiOutput, TgUserRole } from "../trpc/types"
-import { getUserGrant } from "./grants"
-
-export async function updateProfilePic(file: File) {
- const session = await getServerSession()
- if (!session.data) return { success: false }
-
- const fd = new FormData()
- fd.set("userId", session.data.user.id)
- fd.set("image", file)
- return await trpc.auth.updateProfilePic.mutate(fd)
-}
-
-export async function getUserInfo(userId: number) {
- return (await trpc.tg.users.get.query({ userId })).user ?? null
-}
-
-export async function getUserRoles(userId: number) {
- return await trpc.tg.permissions.getRoles.query({ userId })
-}
-
-export async function searchUserInfo(username: string) {
- return (await trpc.tg.users.getByUsername.query({ username })).user ?? null
-}
-
-export async function getUserDetails(userId: number) {
- const { user } = await trpc.tg.users.get.query({ userId: userId })
- if (!user) return null
-
- const { roles, groupAdmin } = await trpc.tg.permissions.getRoles.query({ userId: user.id })
- const { messages } = await trpc.tg.messages.getLastByUser.query({ userId: user.id, limit: 15 })
- const audits = await trpc.tg.auditLog.getById.query({ targetId: user.id })
-
- const { grant } = await getUserGrant(user.id)
- return { roles, groupAdmin, user, messages, audits, grant }
-}
-
-export async function addGroupAdmin(userId: number, groupId: number) {
- const { allowed, telegramId } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return { error: "UNAUTHORIZED" }
-
- await trpc.tg.permissions.addGroup.mutate({ userId, adderId: telegramId, groupId })
- return { error: null }
-}
-
-export async function delGroupAdmin(
- userId: number,
- groupId: number
-): Promise {
- const { allowed, telegramId } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return { error: "UNAUTHORIZED" }
-
- return await trpc.tg.permissions.removeGroup.mutate({ userId, removerId: telegramId, groupId })
-}
-
-export async function addUserRole(
- userId: number,
- role: TgUserRole
-): Promise {
- const { allowed, telegramId } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return { roles: null, error: "UNAUTHORIZED" }
-
- return await trpc.tg.permissions.addRole.mutate({ userId, role, adderId: telegramId })
-}
-
-export async function delUserRole(
- userId: number,
- role: TgUserRole
-): Promise {
- const { allowed, telegramId } = await requireRole(["owner", "direttivo", "president"])
- if (!allowed) return { roles: null, error: "UNAUTHORIZED" }
-
- return await trpc.tg.permissions.removeRole.mutate({ userId, role, removerId: telegramId })
-}
diff --git a/src/server/api.functions.ts b/src/server/api.functions.ts
new file mode 100644
index 0000000..4d6669c
--- /dev/null
+++ b/src/server/api.functions.ts
@@ -0,0 +1,190 @@
+import { type AppRouter, TRPC_PATH } from "@polinetwork/backend"
+import { createServerFn } from "@tanstack/react-start"
+import { getRequestHeader } from "@tanstack/react-start/server"
+import { createTRPCClient, httpBatchLink, httpLink, isNonJsonSerializable, splitLink } from "@trpc/client"
+import SuperJSON from "superjson"
+import { z } from "zod"
+import { auth } from "@/lib/auth"
+
+export type BackendState = { data: T; connected: boolean; message?: string }
+
+function backendOrigin() {
+ return process.env.BACKEND_URL ?? "http://localhost:3000"
+}
+
+function requestHeaders(): Record {
+ const cookie = getRequestHeader("cookie")
+ return cookie ? { cookie } : {}
+}
+
+function client() {
+ const url = `${backendOrigin()}${TRPC_PATH}`
+ const headers = requestHeaders()
+
+ return createTRPCClient({
+ links: [
+ splitLink({
+ condition: (operation) => isNonJsonSerializable(operation.input),
+ true: httpLink({
+ url,
+ headers,
+ transformer: {
+ serialize: (data) => data,
+ deserialize: (data) => SuperJSON.deserialize(data),
+ },
+ }),
+ false: httpBatchLink({ url, headers, transformer: SuperJSON }),
+ }),
+ ],
+ })
+}
+
+export const testBackend = createServerFn().handler(async () => {
+ try {
+ await client().test.dbQuery.query({ dbName: "web" })
+ return true
+ } catch (e) {
+ console.error("Backend error: ", e)
+ return false
+ }
+})
+
+async function readSession() {
+ try {
+ const response = await auth.getSession({ fetchOptions: { headers: requestHeaders() } })
+ if (response.error) {
+ console.error("SESSION ERROR", response.error.statusText, response.error.message)
+ }
+
+ return response.data
+ } catch (err) {
+ console.error(err)
+ return null
+ }
+}
+
+async function requireSession() {
+ const session = await readSession()
+ if (!session?.user) throw new Error("UNAUTHORIZED")
+ return session
+}
+
+async function requireAdminRole() {
+ const session = await requireSession()
+ const telegramId = session.user?.telegramId
+ if (!telegramId) throw new Error("TELEGRAM_NOT_LINKED")
+
+ const { roles } = await client().tg.permissions.getRoles.query({ userId: telegramId })
+ if (!roles?.some((role) => ["owner", "direttivo", "president"].includes(role))) {
+ throw new Error("UNAUTHORIZED")
+ }
+
+ return { session, telegramId }
+}
+
+async function safely(request: () => Promise): Promise> {
+ try {
+ await requireSession()
+ return { data: await request(), connected: true }
+ } catch (error) {
+ const message =
+ error instanceof Error && error.message === "UNAUTHORIZED"
+ ? "Your session is no longer valid. Sign in again to load this data."
+ : "The PoliNetwork backend is currently unavailable."
+
+ return { data: [], connected: false, message }
+ }
+}
+
+export const getCurrentSession = createServerFn().handler(readSession)
+
+export const getTelegramUsers = createServerFn().handler(() => safely(() => client().tg.users.getAll.query()))
+
+export const getTelegramGroups = createServerFn().handler(() => safely(() => client().tg.groups.getAll.query()))
+
+export const getOngoingGrants = createServerFn().handler(() => safely(() => client().tg.grants.getOngoing.query()))
+
+export const getScheduledGrants = createServerFn().handler(() => safely(() => client().tg.grants.getScheduled.query()))
+
+export const getAzureMembers = createServerFn().handler(() => safely(() => client().azure.members.getAll.query()))
+
+export const getTelegramUserDetails = createServerFn()
+ .validator(z.object({ userId: z.number().int().positive() }))
+ .handler(async ({ data }) => {
+ return safely(async () => {
+ const api = client()
+ const { user } = await api.tg.users.get.query({ userId: data.userId })
+ if (!user) return null
+
+ const [permissions, messages, audits, grant] = await Promise.all([
+ api.tg.permissions.getRoles.query({ userId: user.id }),
+ api.tg.messages.getLastByUser.query({ userId: user.id, limit: 15 }),
+ api.tg.auditLog.getById.query({ targetId: user.id }),
+ api.tg.grants.checkUser.query({ userId: user.id }),
+ ])
+
+ return {
+ user,
+ roles: permissions.roles ?? [],
+ groupAdmin: permissions.groupAdmin.filter(Boolean),
+ groups: await api.tg.groups.getAll.query(),
+ messages: messages.messages ?? [],
+ audits,
+ grant: grant.grant ?? null,
+ }
+ })
+ })
+
+export const setGroupVisibility = createServerFn({ method: "POST" })
+ .validator(z.object({ telegramId: z.number().int(), hide: z.boolean() }))
+ .handler(async ({ data }) => {
+ await requireAdminRole()
+ return client().tg.groups.setHide.mutate(data)
+ })
+
+export const addTelegramGroupAdmin = createServerFn({ method: "POST" })
+ .validator(z.object({ userId: z.number().int().positive(), groupId: z.number().int() }))
+ .handler(async ({ data }) => {
+ const { telegramId } = await requireAdminRole()
+ return client().tg.permissions.addGroup.mutate({ ...data, adderId: telegramId })
+ })
+
+export const createAzureMember = createServerFn({ method: "POST" })
+ .validator(
+ z.object({
+ firstName: z.string().min(1),
+ lastName: z.string().min(1),
+ assocNumber: z.number().int().positive(),
+ sendEmailTo: z.email(),
+ })
+ )
+ .handler(async ({ data }) => {
+ await requireAdminRole()
+ const result = await client().azure.members.create.mutate(data)
+ if (result.error) throw new Error(result.error)
+ return result
+ })
+
+export const setAzureMemberNumber = createServerFn({ method: "POST" })
+ .validator(z.object({ userId: z.string().min(1), assocNumber: z.number().int().positive() }))
+ .handler(async ({ data }) => {
+ await requireAdminRole()
+ const result = await client().azure.members.setAssocNumber.mutate(data)
+ if (result.error) throw new Error(result.error)
+ return result
+ })
+
+export const uploadProfilePicture = createServerFn({ method: "POST", strict: false })
+ .validator((data: FormData) => data)
+ .handler(async ({ data }) => {
+ const session = await requireSession()
+ 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")
+
+ const formData = new FormData()
+ formData.set("userId", session.user?.id ?? "")
+ formData.set("image", image)
+ return client().auth.updateProfilePic.mutate(formData)
+ })
diff --git a/src/server/auth/index.ts b/src/server/auth/index.ts
deleted file mode 100644
index 0c8c534..0000000
--- a/src/server/auth/index.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { headers } from "next/headers"
-import { getSession } from "@/lib/auth"
-import { trpc } from "../trpc"
-import type { TgUserRole } from "../trpc/types"
-
-export const getServerSession = async () => {
- const session = await getSession({
- fetchOptions: {
- headers: await headers(),
- },
- })
- // console.log(session);
- return session
-}
-
-export async function requireRole(allowedRoles: TgUserRole[]) {
- const session = await getServerSession()
- const telegramId = session.data?.user.telegramId
- if (!telegramId) return { allowed: false, telegramId: 0 }
-
- const { roles } = await trpc.tg.permissions.getRoles.query({ userId: telegramId })
- return { allowed: roles?.some((s) => allowedRoles.includes(s)), telegramId }
-}
diff --git a/src/server/trpc/index.tsx b/src/server/trpc/index.tsx
deleted file mode 100644
index e53ad45..0000000
--- a/src/server/trpc/index.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import "server-only"
-
-import { type AppRouter, TRPC_PATH } from "@polinetwork/backend"
-import { createTRPCClient, httpBatchLink, httpLink, isNonJsonSerializable, splitLink } from "@trpc/client"
-import SuperJSON from "superjson"
-import { env } from "@/env"
-
-const url = env.BACKEND_URL + TRPC_PATH
-export const trpc = createTRPCClient({
- links: [
- splitLink({
- condition: (op) => isNonJsonSerializable(op.input),
- true: httpLink({
- url,
- transformer: {
- // request - convert data before sending to the tRPC server
- serialize: (data) => data,
- // response - convert the tRPC response before using it in client
- deserialize: (data) => SuperJSON.deserialize(data), // or your other transformer
- },
- }),
- false: httpBatchLink({
- url,
- transformer: SuperJSON, // or your other transformer
- }),
- }),
- ],
-})
diff --git a/src/server/trpc/types.tsx b/src/server/trpc/types.tsx
deleted file mode 100644
index 7962e62..0000000
--- a/src/server/trpc/types.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { AppRouter } from "@polinetwork/backend"
-import type { inferRouterError, inferRouterInputs, inferRouterOutputs } from "@trpc/server"
-
-export type ApiOutput = inferRouterOutputs
-export type ApiInput = inferRouterInputs
-export type ApiError = inferRouterError
-
-export type TgUser = NonNullable
-export type TgGrant = NonNullable
-export type TgGroup = NonNullable
-export type TgUserRole = NonNullable
-
-export type AzureMember = ApiOutput["azure"]["members"]["getAll"][number]
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..7c12ee5
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,270 @@
+@import url("https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&display=swap");
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --ink: #0d1d36;
+ --paper: #f6f7f9;
+ --mist: #eef1f5;
+ --line: #dfe3e9;
+ --muted-blue: #657186;
+ --cobalt: #1156ae;
+ --mint: #e5eef9;
+ --sky: #78adf0;
+ --orange: #d86b3f;
+ --white: #ffffff;
+ --toast-success: #18794e;
+ --toast-success-soft: #e4f7ed;
+ --toast-info: #1156ae;
+ --toast-info-soft: #e4effd;
+ --toast-warning: #a15c00;
+ --toast-warning-soft: #fff1d9;
+ --toast-error: #b7472f;
+ --toast-error-soft: #fce8e4;
+
+ --background: var(--paper);
+ --foreground: var(--ink);
+ --card: var(--white);
+ --card-foreground: var(--ink);
+ --popover: var(--white);
+ --popover-foreground: var(--ink);
+ --primary: var(--cobalt);
+ --primary-foreground: #ffffff;
+ --secondary: var(--mist);
+ --secondary-foreground: var(--ink);
+ --muted: var(--mist);
+ --muted-foreground: var(--muted-blue);
+ --accent: var(--mint);
+ --accent-foreground: var(--cobalt);
+ --destructive: #b7472f;
+ --border: var(--line);
+ --input: var(--line);
+ --ring: var(--cobalt);
+ --chart-1: var(--cobalt);
+ --chart-2: var(--sky);
+ --chart-3: var(--ink);
+ --chart-4: var(--orange);
+ --chart-5: var(--muted-blue);
+ --radius: 0.625rem;
+ --sidebar: #f9fafc;
+ --sidebar-foreground: #19243a;
+ --sidebar-primary: #1156ae;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #ffffff;
+ --sidebar-accent-foreground: #0d1d36;
+ --sidebar-border: #dce2ec;
+ --sidebar-ring: #1156ae;
+}
+
+.dark {
+ color-scheme: dark;
+ --ink: #edf2f0;
+ --paper: #0d1219;
+ --mist: #171e28;
+ --line: #283241;
+ --muted-blue: #98a4b5;
+ --cobalt: #1156ae;
+ --mint: #1b3047;
+ --sky: #a5c9f7;
+ --orange: #ed9a70;
+ --white: #131a23;
+ --toast-success: #73d9a0;
+ --toast-success-soft: #112a24;
+ --toast-info: #a5c9f7;
+ --toast-info-soft: #13263b;
+ --toast-warning: #f3b66e;
+ --toast-warning-soft: #332516;
+ --toast-error: #ff9b86;
+ --toast-error-soft: #351d1d;
+
+ --background: var(--paper);
+ --foreground: var(--ink);
+ --card: #131a23;
+ --card-foreground: var(--ink);
+ --popover: #171f2a;
+ --popover-foreground: var(--ink);
+ --primary: var(--cobalt);
+ --primary-foreground: #ffffff;
+ --secondary: #182434;
+ --secondary-foreground: #dbe6f2;
+ --muted: #172332;
+ --muted-foreground: var(--muted-blue);
+ --accent: #192d43;
+ --accent-foreground: var(--sky);
+ --destructive: #ff9b86;
+ --border: var(--line);
+ --input: #2a3a50;
+ --ring: var(--sky);
+ --chart-1: var(--cobalt);
+ --chart-2: var(--sky);
+ --chart-3: #dce7e3;
+ --chart-4: var(--orange);
+ --chart-5: var(--muted-blue);
+ --sidebar: #0d151f;
+ --sidebar-foreground: #e8efec;
+ --sidebar-primary: #1156ae;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #172331;
+ --sidebar-accent-foreground: #ffffff;
+ --sidebar-border: rgba(165, 201, 247, 0.16);
+ --sidebar-ring: var(--sky);
+}
+
+@theme inline {
+ --font-sans: "DM Sans", sans-serif;
+ --font-serif: "DM Sans", sans-serif;
+ --font-mono: "DM Mono", monospace;
+ --font-heading: "DM Sans", sans-serif;
+ --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-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --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);
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+
+ html {
+ @apply scroll-smooth;
+ }
+
+ body {
+ @apply min-w-80 bg-background font-sans text-foreground antialiased;
+ font-synthesis: none;
+ }
+
+ [data-sonner-toaster] {
+ --normal-bg: var(--card);
+ --normal-text: var(--foreground);
+ --normal-border: var(--line);
+ }
+
+ [data-sonner-toast].sonner-toast {
+ border: 1px solid var(--line);
+ border-radius: 0.45rem;
+ box-shadow: 0 12px 30px rgb(0 0 0 / 22%);
+ font-family: "DM Sans", sans-serif;
+ }
+
+ [data-sonner-toast][data-type="success"] {
+ background: var(--toast-success-soft);
+ border-color: color-mix(in srgb, var(--toast-success) 28%, var(--card));
+ color: var(--toast-success);
+ }
+
+ [data-sonner-toast][data-type="info"] {
+ background: var(--toast-info-soft);
+ border-color: color-mix(in srgb, var(--toast-info) 28%, var(--card));
+ color: var(--toast-info);
+ }
+
+ [data-sonner-toast][data-type="warning"] {
+ background: var(--toast-warning-soft);
+ border-color: color-mix(in srgb, var(--toast-warning) 28%, var(--card));
+ color: var(--toast-warning);
+ }
+
+ [data-sonner-toast][data-type="error"] {
+ background: var(--toast-error-soft);
+ border-color: color-mix(in srgb, var(--toast-error) 28%, var(--card));
+ color: var(--toast-error);
+ }
+
+ [data-sonner-toast] [data-content] {
+ color: inherit;
+ }
+
+ [data-sonner-toast] [data-description] {
+ color: color-mix(in srgb, currentColor 72%, transparent);
+ }
+
+ button,
+ input {
+ font: inherit;
+ }
+
+ button {
+ @apply cursor-pointer;
+ }
+
+ a {
+ @apply text-inherit no-underline;
+ }
+
+ h1,
+ h2,
+ h3,
+ p {
+ @apply m-0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ transition-duration: 0.01ms;
+ }
+
+ html {
+ scroll-behavior: auto;
+ }
+
+ .animate-appear,
+ .animate-spin-slow {
+ animation: none;
+ }
+}
+
+@keyframes appear {
+ from {
+ opacity: 0;
+ transform: translateY(9px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@utility animate-appear {
+ animation: appear 0.45s ease both;
+}
+
+@utility animate-spin-slow {
+ animation: spin 1s linear infinite;
+}
diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts
deleted file mode 100644
index e424ead..0000000
--- a/src/utils/cookies.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-export type CookieOptions = {
- expires?: Date | number // Date or seconds from now
- path?: string
- domain?: string
- secure?: boolean
- sameSite?: "strict" | "lax" | "none"
-}
-
-export function setCookie(name: string, value: string, options: CookieOptions = {}): void {
- if (typeof document === "undefined") return
-
- const { expires, path, domain, secure, sameSite } = options
-
- let cookieString = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
-
- if (expires !== undefined) {
- const date = expires instanceof Date ? expires : new Date(Date.now() + expires * 1000)
- cookieString += `; expires=${date.toUTCString()}`
- }
-
- if (path) cookieString += `; path=${path}`
- if (domain) cookieString += `; domain=${domain}`
- if (secure) cookieString += "; secure"
- if (sameSite) cookieString += `; samesite=${sameSite}`
-
- // biome-ignore lint/suspicious/noDocumentCookie: same as @/ui/sidebar strategy
- document.cookie = cookieString
-}
-
-export function getCookie(name: string): string | null {
- if (typeof document === "undefined") return null
-
- const match = document.cookie.match(
- new RegExp(`(?:^|; )${encodeURIComponent(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`)
- )
-
- return match?.[1] ? decodeURIComponent(match[1]) : null
-}
-
-export function deleteCookie(name: string, options: Pick = {}): void {
- setCookie(name, "", { ...options, expires: new Date(0) })
-}
-
-export function getDefaultCookieOptions(): CookieOptions {
- return process.env.NODE_ENV === "development"
- ? {
- path: "/",
- sameSite: "lax",
- }
- : {
- path: "/",
- domain: ".polinetwork.org",
- secure: true,
- sameSite: "strict",
- }
-}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..6079bfd
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,5 @@
+import type { Config } from "tailwindcss"
+
+export default {
+ content: ["./src/**/*.{ts,tsx}"],
+} satisfies Config
diff --git a/tsconfig.json b/tsconfig.json
index 765e842..0df8c06 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,44 +1,23 @@
{
"compilerOptions": {
- /* Base Options: */
- "esModuleInterop": true,
+ "target": "ES2022",
+ "lib": ["DOM", "DOM.Iterable", "ES2023"],
"skipLibCheck": true,
- "target": "es2022",
- "allowJs": true,
- "resolveJsonModule": true,
- "moduleDetection": "force",
- "isolatedModules": true,
- /* Strictness */
"strict": true,
- "noUncheckedIndexedAccess": true,
- "checkJs": true,
- /* Bundled projects */
- "lib": ["dom", "dom.iterable", "ES2022"],
- "noEmit": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
"module": "ESNext",
"moduleResolution": "Bundler",
- "jsx": "preserve",
- "plugins": [
- {
- "name": "next"
- }
- ],
- "incremental": true,
- /* Path Aliases */
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noUncheckedSideEffectImports": true,
"baseUrl": ".",
- "paths": {
- "@/*": ["./src/*"]
- }
+ "ignoreDeprecations": "6.0",
+ "types": ["vite/client"],
+ "paths": { "@/*": ["./src/*"] }
},
- "include": [
- ".eslintrc.cjs",
- "next-env.d.ts",
- "**/*.ts",
- "**/*.tsx",
- "**/*.cjs",
- "**/*.js",
- ".next/types/**/*.ts",
- ".next/dev/types/**/*.ts"
- ],
- "exclude": ["node_modules"]
+ "include": ["src/**/*", "vite.config.ts", "tailwind.config.ts"]
}
diff --git a/tsr.config.json b/tsr.config.json
new file mode 100644
index 0000000..8b6b6ed
--- /dev/null
+++ b/tsr.config.json
@@ -0,0 +1,3 @@
+{
+ "target": "react"
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..fcbd8a9
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,10 @@
+import tailwindcss from "@tailwindcss/vite"
+import { tanstackStart } from "@tanstack/react-start/plugin/vite"
+import viteReact from "@vitejs/plugin-react"
+import { nitro } from "nitro/vite"
+import { defineConfig } from "vite"
+
+export default defineConfig({
+ resolve: { tsconfigPaths: true },
+ plugins: [tailwindcss(), tanstackStart(), nitro(), viteReact()],
+})