bun --bun run dev + haldus leht init

This commit is contained in:
2025-07-21 16:26:47 +03:00
parent efc0e34ae7
commit f87f8374f4
25 changed files with 4770 additions and 789 deletions

View File

@@ -0,0 +1,102 @@
// Fonts
import { vipnagorgialla } from "@/components/Vipnagorgialla";
// Database
import { db } from "@/db/drizzle";
import Link from "next/link";
// User interface
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Later on we can use a i8 solution?
function translateRole(role: string): string {
switch (role) {
case "CAPTAIN":
return "Kapten";
case "TEAMMATE":
return "Meeskonnaliige";
default:
return role;
}
}
export default async function AdminTeams() {
// Fetch teams with their members and member users
const teams = await db.query.teams.findMany({
with: {
members: {
with: {
user: true,
},
},
},
});
return (
<div className="flex flex-col min-h-[90vh] p-12 pt-18">
<div className="flex items-center gap-4">
<Link href={"/haldus"}>
<span className="material-symbols-outlined !text-[clamp(2rem,1.5rem+1.5vw,3.5rem)] !font-bold text-[#007CAB] dark:text-[#00A3E0] translate-y-2.5 hover:-translate-x-2 dark:hover:text-[#EEE5E5] hover:text-[#2A2C3F] transition">
arrow_left_alt
</span>
</Link>
<h1
className={`text-5xl sm:text-6xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`}
>
Haldus - Meeskonnad
</h1>
</div>
<div className="text-2xl text-[#2A2C3F] dark:text-[#EEE5E5]">
<div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Nimi</TableHead>
<TableHead>Liikmed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teams.map((team: any) => (
<TableRow key={team.id}>
<TableCell className="font-medium">{team.id}</TableCell>
<TableCell>{team.name}</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
{team.members && team.members.length > 0 ? (
team.members.map((member: any) => (
<div
key={member.id}
className="flex items-center gap-2 text-sm"
>
<span className="font-semibold">
{member.user.firstName} {member.user.lastName}
</span>
<span className="text-gray-500">
({translateRole(member.role)})
</span>
</div>
))
) : (
<span className="text-gray-500">Liikmeid puuduvad</span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

168
src/app/haldus/page.tsx Normal file
View File

@@ -0,0 +1,168 @@
// Fonts
import { vipnagorgialla } from "@/components/Vipnagorgialla";
// Database
import { db } from "@/db/drizzle";
import { syncFientaEvent } from "@/lib/fienta";
// Enviornment variables
import("dotenv");
// User interface
import {
Users,
IdCardLanyard,
DatabaseBackup,
CheckCircle2Icon,
X,
} from "lucide-react";
import Link from "next/link";
import { revalidatePath } from "next/cache";
import { redirect, RedirectType } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { DataTable } from "@/components/haldus/data-table";
import { columns } from "@/components/haldus/columns";
async function syncAction() {
"use server";
await syncFientaEvent(process.env.EVENT_ID!, process.env.FIENTA_API_KEY!);
// Revalidate due to data change
revalidatePath("/haldus");
redirect("/haldus?success=true");
}
async function dismissAlert() {
"use server";
redirect("/haldus", RedirectType.replace);
}
const SuccessAlertDB = () => {
return (
<Alert className="flex items-start mt-8">
<CheckCircle2Icon className="mt-0.5" />
<div className="flex-1">
<AlertTitle>Toiming oli edukas!</AlertTitle>
<AlertDescription>Andmebaasi andmed on uuendatud.</AlertDescription>
</div>
<form action={dismissAlert} className="ml-2">
<Button
type="submit"
variant="ghost"
size="icon"
className="cursor-pointer"
>
<X className="" />
</Button>
</form>
</Alert>
);
};
export default async function Admin({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const alarmStatus = await searchParams;
const showSuccess = alarmStatus.success === "true";
// Fetch users
const usersData = await db.query.users.findMany({
with: {
members: {
with: {
team: true,
},
},
},
});
const teamsData = await db.query.teams.findMany();
return (
<div className="flex flex-col min-h-[90vh] p-12 pt-18">
{showSuccess && <SuccessAlertDB />}
<div className="flex items-center gap-4">
<Link href={"/"}>
<span className="material-symbols-outlined !text-[clamp(2rem,1.5rem+1.5vw,3.5rem)] !font-bold text-[#007CAB] dark:text-[#00A3E0] translate-y-2.5 hover:-translate-x-2 dark:hover:text-[#EEE5E5] hover:text-[#2A2C3F] transition">
arrow_left_alt
</span>
</Link>
<h1
className={`text-5xl sm:text-6xl ${vipnagorgialla.className} font-bold italic uppercase text-[#2A2C3F] dark:text-[#EEE5E5] mt-8 mb-4`}
>
Haldus
</h1>
</div>
<div className="text-2xl text-[#2A2C3F] dark:text-[#EEE5E5]">
<div className="pl-2 flex gap-8 pb-4">
<div className="flex text-lg md:text-2xl flex-row items-center">
<Users className="mr-2" />
Kasutajaid: {usersData.length}
</div>
<Link href="/haldus/meeskonnad" className="flex items-center">
<div className="flex text-lg md:text-2xl flex-row items-center">
<IdCardLanyard className="mr-2" />
Meeskondasid: {teamsData.length}
</div>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<div className="ml-auto">
<Button
variant="ghost"
size="icon"
className="size-12 cursor-pointer"
>
<DatabaseBackup className="scale-150" />
</Button>
</div>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>
Kas soovite värskendada andmebaasi?
</AlertDialogTitle>
<AlertDialogDescription>
See tõmbab Fientast praegused andmed ning asendab{" "}
<span className="text-red-600 font-semibold">KÕIK</span>{" "}
olemasolevad andmed andmebaasis!
<br />
<br />
Kui sa ei ole kindel, vajuta &quot;Tühista&quot;.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">
Tühista
</AlertDialogCancel>
<form action={syncAction}>
<AlertDialogAction type="submit" className="cursor-pointer">
Värskenda
</AlertDialogAction>
</form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div>
<DataTable columns={columns} data={usersData} />
</div>
</div>
</div>
);
}

View File

@@ -1,16 +1,15 @@
// Head metadata
import type { Metadata } from "next";
import Head from 'next/head';
import Head from "next/head";
// Provides the theme context to the app
import { ThemeProvider } from "@/components/Theme-provider"
import { ThemeProvider } from "@/components/Theme-provider";
import "./globals.css";
import "material-symbols";
// Fonts
import { Work_Sans } from "next/font/google";
import SidebarParent from "@/components/SidebarParent";
import Footer from "@/components/Footer";
@@ -35,20 +34,23 @@ export default function RootLayout({
<Head>
<title>TipiLAN</title>
<meta property="og:title" content="TipiLAN 2025" key="title" />
<meta name="description" content="TipiLAN 2025 Eesti suurim tudengite korraldatud LAN!" />
<meta
name="description"
content="TipiLAN 2025 Eesti suurim tudengite korraldatud LAN!"
/>
</Head>
<body
className={`${workSans.className} antialiased bg-[#EEE5E5] dark:bg-[#0E0F19]`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SidebarParent />
{children}
<Footer />
<SidebarParent />
{children}
<Footer />
</ThemeProvider>
</body>
</html>

View File

@@ -0,0 +1,148 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import {
KeyIcon,
Mails,
Ticket,
IdCardLanyard,
ArrowUpDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
// Define the user type based on the database schema
export type User = {
id: string;
firstName: string;
lastName: string;
email: string;
steamId: string | null;
ticketId: string | null;
ticketType: string | null;
members: {
team: {
name: string;
} | null;
}[];
};
export const columns: ColumnDef<User>[] = [
{
accessorKey: "id",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-2 h-auto p-0"
>
<KeyIcon className="h-4 w-4" />
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <div className="font-medium">{row.getValue("id")}</div>,
},
{
accessorKey: "firstName",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0"
>
Eesnimi
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <div>{row.getValue("firstName")}</div>,
},
{
accessorKey: "lastName",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0"
>
Perenimi
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <div>{row.getValue("lastName")}</div>,
},
{
accessorKey: "email",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-2 h-auto p-0"
>
<Mails className="h-4 w-4" />
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => <div>{row.getValue("email")}</div>,
},
{
accessorKey: "steamId",
header: "Steam ID",
cell: ({ row }) => {
const steamId = row.getValue("steamId") as string | null;
return <div>{steamId || "-"}</div>;
},
},
{
accessorKey: "ticketId",
header: () => (
<div className="flex items-center gap-2">
<Ticket className="h-4 w-4" />
Pileti ID
</div>
),
cell: ({ row }) => {
const ticketId = row.getValue("ticketId") as string | null;
return <div>{ticketId || "-"}</div>;
},
},
{
accessorKey: "ticketType",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0"
>
Pileti tüüp
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const ticketType = row.getValue("ticketType") as string | null;
return <div>{ticketType || "-"}</div>;
},
},
{
id: "team",
accessorFn: (user) =>
user.members && user.members.length > 0 && user.members[0].team
? user.members[0].team.name
: "",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="flex items-center gap-2 h-auto p-0"
>
<IdCardLanyard className="h-4 w-4" />
Meeskond
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const teamName = row.getValue("team") as string;
return <div>{teamName || "-"}</div>;
},
},
];

View File

@@ -0,0 +1,220 @@
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Eraser, Funnel, Search } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
// Search input placeholders
function getPlaceholderText(column: string): string {
const placeholders = {
email: "Filtreeri emaile...",
firstName: "Filtreeri eesnimesid...",
ticketType: "Filtreeri pileti tüüpi...",
team: "Filtreeri tiimi nimesid...",
};
return placeholders[column as keyof typeof placeholders] || "Otsi...";
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [searchColumn, setSearchColumn] = React.useState<string>("email");
const [searchValue, setSearchValue] = React.useState<string>("");
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full">
<div className="flex items-center py-4">
<Search className="h-6 w-6 mr-2" />
<Input
placeholder={getPlaceholderText(searchColumn)}
value={searchValue}
onChange={(event) => {
const value = event.target.value;
setSearchValue(value);
// Clear all column filters first
table.getColumn("email")?.setFilterValue("");
table.getColumn("firstName")?.setFilterValue("");
table.getColumn("ticketType")?.setFilterValue("");
table.getColumn("team")?.setFilterValue("");
// Set filter for selected column
table.getColumn(searchColumn)?.setFilterValue(value);
}}
className="max-w-sm rounded-r-none"
/>
<Select value={searchColumn} onValueChange={setSearchColumn}>
<SelectTrigger className="w-48 border-l-0 rounded-l-none">
<Funnel />
<SelectValue placeholder="Vali otsingu väli" />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="firstName">Eesnimi</SelectItem>
<SelectItem value="ticketType">Pileti tüüp</SelectItem>
<SelectItem value="team">Tiim</SelectItem>
</SelectContent>
</Select>
{searchValue && (
<Button
variant="ghost"
size="lg"
onClick={() => {
setSearchValue("");
table.getColumn(searchColumn)?.setFilterValue("");
}}
className="ml-2"
>
<Eraser />
Tühjenda
</Button>
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
Tulemusi ei leitud.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between space-x-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
Näidatakse {table.getFilteredRowModel().rows.length} rida(de) kokku{" "}
{data.length} reast.
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<span>
Lehekülg {table.getState().pagination.pageIndex + 1} /{" "}
{table.getPageCount()}
</span>
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Eelmine
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Järgmine
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-transparent text-card-foreground flex flex-col gap-6 rounded-xl border-3 border-[#1F5673] py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,185 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

6
src/db/drizzle.ts Normal file
View File

@@ -0,0 +1,6 @@
import { drizzle } from "drizzle-orm/bun-sqlite";
import { Database } from "bun:sqlite";
import * as schema from "./schema";
const sqlite = new Database("data/tipilan.db");
export const db = drizzle(sqlite, { schema });

139
src/db/schema.ts Normal file
View File

@@ -0,0 +1,139 @@
import {
sqliteTable,
text,
integer,
primaryKey,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
// Roles enum equivalent
export const RoleEnum = {
VISITOR: "VISITOR",
PARTICIPANT: "PARTICIPANT",
TEAMMATE: "TEAMMATE",
CAPTAIN: "CAPTAIN",
ADMIN: "ADMIN",
} as const;
export type Role = (typeof RoleEnum)[keyof typeof RoleEnum];
// User table
export const users = sqliteTable("user", {
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
email: text("email").notNull(),
steamId: text("steam_id").unique(),
firstName: text("first_name").notNull(),
lastName: text("last_name").notNull(),
ticketId: text("ticket_id").unique(),
ticketType: text("ticket_type"),
});
// Team table
export const teams = sqliteTable("team", {
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
name: text("name").notNull(),
});
// Member table (join table for User and Team with role)
export const members = sqliteTable(
"member",
{
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
teamId: text("team_id").references(() => teams.id, { onDelete: "cascade" }),
role: text("role", {
enum: Object.values(RoleEnum) as [string, ...string[]],
}).notNull(),
},
(table) => {
return {
userTeamUnique: uniqueIndex("user_team_unique").on(
table.userId,
table.teamId,
),
};
},
);
// Tournament table
export const tournaments = sqliteTable("tournament", {
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
name: text("name").notNull(),
});
// TournamentTeam join table
export const tournamentTeams = sqliteTable(
"tournament_team",
{
id: text("id")
.primaryKey()
.notNull()
.$defaultFn(() => createId()),
tournamentId: text("tournament_id")
.notNull()
.references(() => tournaments.id, { onDelete: "cascade" }),
teamId: text("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
registrationDate: integer("registration_date", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => {
return {
tournamentTeamUnique: uniqueIndex("tournament_team_unique").on(
table.tournamentId,
table.teamId,
),
};
},
);
// Relations
export const usersRelations = relations(users, ({ many }) => ({
members: many(members),
}));
export const teamsRelations = relations(teams, ({ many }) => ({
members: many(members),
tournamentTeams: many(tournamentTeams),
}));
export const membersRelations = relations(members, ({ one }) => ({
user: one(users, { fields: [members.userId], references: [users.id] }),
team: one(teams, { fields: [members.teamId], references: [teams.id] }),
}));
export const tournamentsRelations = relations(tournaments, ({ many }) => ({
tournamentTeams: many(tournamentTeams),
}));
export const tournamentTeamsRelations = relations(
tournamentTeams,
({ one }) => ({
tournament: one(tournaments, {
fields: [tournamentTeams.tournamentId],
references: [tournaments.id],
}),
team: one(teams, {
fields: [tournamentTeams.teamId],
references: [teams.id],
}),
}),
);

350
src/lib/fienta.ts Normal file
View File

@@ -0,0 +1,350 @@
import { db } from "@/db/drizzle";
import { users, teams, members, tournamentTeams } from "@/db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { RoleEnum, type Role } from "@/db/schema";
// Types based on the Fienta API response
export interface FientaApiResponse {
success: {
code: number;
user_message: string;
internal_message: string;
};
time: {
timestamp: number;
date: string;
time: string;
full_datetime: string;
timezone: string;
timezone_short: string;
gmt: string;
};
count: number;
tickets: FientaTicket[];
}
export interface FientaTicket {
id: number;
organizer_id: number;
event_id: number;
order_id: number;
code: string;
status: string;
used_at: string | null;
created_at: string;
updated_at: string;
validated_by_id: number | null;
ip: string;
is_parent: boolean;
parent_id: number | null;
order_email: string;
order_phone: string;
contents_html: string;
nametag_html: string;
qty: number;
permissions: {
update: boolean;
};
rows: FientaTicketRow[];
}
export interface FientaTicketRow {
ticket_type: {
id: number;
title: string;
attendance_mode: string;
};
attendee: {
first_name: string;
last_name: string;
full_name: string;
email: string;
[key: string]: string; // To handle dynamic field names
};
}
/**
* Fetches tickets from the Fienta API for a specific event
*/
export async function fetchFientaTickets(
eventId: string,
apiKey: string,
): Promise<FientaApiResponse> {
const response = await fetch(
`https://fienta.com/api/v1/events/${eventId}/tickets/?attendees`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
},
);
if (!response.ok) {
throw new Error(`Failed to fetch tickets: ${response.statusText}`);
}
return response.json();
}
/**
* Extracts Steam ID from a Steam profile URL
*/
export function extractSteamId(steamUrl: string): string | null {
if (!steamUrl) return null;
// Use regex to handle both escaped and unescaped URLs, with or without trailing slash
const regex = /(?:\/|\\\/)(id|profiles)\/([^\/\\]+)(?:\/|\\"\/)?$/;
const match = steamUrl.match(regex);
// Return the matched ID or null if no match
return match && match[2] ? match[2] : null;
}
/**
* Finds an attendee field by prefix in the attendee data
* Fienta's API can return custom form fields with dynamic names
*/
function findAttendeeFieldByPrefix(
attendee: { [key: string]: string },
prefix: string,
): string | null {
// Find the first field that starts with the given prefix
const fieldKey = Object.keys(attendee).find((key) => key.startsWith(prefix));
// Return the value if found, or null otherwise
return fieldKey ? attendee[fieldKey] : null;
}
/**
* Determines the appropriate user role based on ticket type and captain status
*/
function determineUserRole(
ticketType: string,
isCaptain: boolean,
isTeamMember: boolean,
): Role {
// Case-insensitive check for tournament participants
const isTournamentParticipant = ticketType
.toLowerCase()
.includes("põhiturniiri osaleja");
const isParticipant = ticketType.toLowerCase().includes("arvutiga osaleja");
if (!isTeamMember && isParticipant) {
return RoleEnum.PARTICIPANT;
}
if (isTournamentParticipant) {
if (isCaptain) {
return RoleEnum.CAPTAIN;
} else if (isTeamMember) {
return RoleEnum.TEAMMATE;
}
return RoleEnum.PARTICIPANT;
}
return RoleEnum.VISITOR;
}
/**
* Upserts a user in the database (create or update)
*/
async function upsertUser(userData: {
email: string;
firstName: string;
lastName: string;
steamId: string | null;
ticketId: string;
ticketType: string;
}) {
// Try to find existing user by ticket ID (primary unique identifier)
const existingUser = await db.query.users.findFirst({
where: eq(users.ticketId, userData.ticketId),
});
if (existingUser) {
// Update existing user
await db
.update(users)
.set({
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
steamId: userData.steamId,
ticketType: userData.ticketType,
})
.where(eq(users.id, existingUser.id));
return existingUser;
} else {
// Create new user
const [newUser] = await db.insert(users).values(userData).returning();
return newUser;
}
}
/**
* Upserts a team member relationship
*/
async function upsertMember(userId: string, teamId: string | null, role: Role) {
// Try to find existing member
const whereCondition = teamId
? and(eq(members.userId, userId), eq(members.teamId, teamId))
: and(eq(members.userId, userId), isNull(members.teamId));
const existingMember = await db.query.members.findFirst({
where: whereCondition,
});
if (existingMember) {
// Update existing member
await db
.update(members)
.set({ role })
.where(eq(members.id, existingMember.id));
} else {
// Create new member
await db.insert(members).values({
userId,
teamId,
role,
});
}
}
/**
* Processes tickets from Fienta and updates the database
*/
export async function syncFientaTickets(
tickets: FientaTicket[],
): Promise<void> {
// Process each ticket to extract user and team information
for (const ticket of tickets) {
// Skip tickets with CANCELLED status or empty rows
if (ticket.rows.length === 0 || ticket.status === "CANCELLED") continue;
const ticketRow = ticket.rows[0];
const attendee = ticketRow.attendee;
const ticketType = ticketRow.ticket_type.title;
// Extract data
const email = attendee.email || ticket.order_email;
const firstName = attendee.first_name;
const lastName = attendee.last_name;
const steamUrl = findAttendeeFieldByPrefix(attendee, "steam_konto_link");
const teamName = findAttendeeFieldByPrefix(attendee, "tiimi_nimi");
const captainName = findAttendeeFieldByPrefix(
attendee,
"tiimi_kapteni_nimi",
);
const steamId = steamUrl ? extractSteamId(steamUrl) : null;
// Check if user is captain - captain name must exist and match user's name
const isCaptain = captainName !== null && steamId === captainName;
// Create or update user
const user = await upsertUser({
email,
firstName,
lastName,
steamId,
ticketId: ticket.code,
ticketType,
});
// Handle team association if there is a team name
if (teamName) {
// Find the team by name first
let team = await db.query.teams.findFirst({
where: eq(teams.name, teamName),
});
// Create the team if it doesn't exist
if (!team) {
const [newTeam] = await db
.insert(teams)
.values({ name: teamName })
.returning();
team = newTeam;
}
// Determine appropriate role
const role = determineUserRole(ticketType, isCaptain, true);
// Create or update membership with appropriate role
await upsertMember(user.id, team.id, role);
} else {
// For users without a team, handle membership without team association
const role = determineUserRole(ticketType, false, false);
await upsertMember(user.id, null, role);
}
}
}
/**
* Main function to fetch and sync tickets from Fienta to the database
*/
export async function syncFientaEvent(
eventId: string,
apiKey: string,
): Promise<void> {
try {
const response = await fetchFientaTickets(eventId, apiKey);
await syncFientaTickets(response.tickets);
} catch (error) {
console.error("Error syncing Fienta tickets:", error);
throw error;
}
}
/**
* Gets teams with their members from the database
*/
export async function getTeamsWithMembers() {
return await db.query.teams.findMany({
with: {
members: {
with: {
user: true,
},
},
},
});
}
/**
* Gets a specific team with its members
*/
export async function getTeamWithMembers(teamId: string) {
return await db.query.teams.findFirst({
where: eq(teams.id, teamId),
with: {
members: {
with: {
user: true,
},
},
},
});
}
/**
* Gets tournament teams with related data
*/
export async function getTournamentTeams(tournamentId: string) {
return await db.query.tournamentTeams.findMany({
where: eq(tournamentTeams.tournamentId, tournamentId),
with: {
team: {
with: {
members: {
with: {
user: true,
},
},
},
},
},
});
}

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}