mirror of
https://github.com/Lapikud/tipilan.git
synced 2026-03-23 13:24:21 +00:00
bun --bun run dev + haldus leht init
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -172,4 +172,6 @@ dist
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/nextjs,node
|
||||
# SQLite database files
|
||||
/data/*.db
|
||||
/data/*.db-*
|
||||
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: "data/tipilan.db",
|
||||
},
|
||||
});
|
||||
43
migrations/0000_awesome_wendigo.sql
Normal file
43
migrations/0000_awesome_wendigo.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE `member` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`team_id` text,
|
||||
`role` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_team_unique` ON `member` (`user_id`,`team_id`);--> statement-breakpoint
|
||||
CREATE TABLE `team` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tournament_team` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`tournament_id` text NOT NULL,
|
||||
`team_id` text NOT NULL,
|
||||
`registration_date` integer NOT NULL,
|
||||
FOREIGN KEY (`tournament_id`) REFERENCES `tournament`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`team_id`) REFERENCES `team`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tournament_team_unique` ON `tournament_team` (`tournament_id`,`team_id`);--> statement-breakpoint
|
||||
CREATE TABLE `tournament` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`steam_id` text,
|
||||
`first_name` text NOT NULL,
|
||||
`last_name` text NOT NULL,
|
||||
`ticket_id` text,
|
||||
`ticket_type` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_steam_id_unique` ON `user` (`steam_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_ticket_id_unique` ON `user` (`ticket_id`);
|
||||
1
migrations/0001_cool_ma_gnuci.sql
Normal file
1
migrations/0001_cool_ma_gnuci.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP INDEX `user_email_unique`;
|
||||
295
migrations/meta/0000_snapshot.json
Normal file
295
migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "efe4e865-a061-4ab0-8023-5211e6d86e14",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"member": {
|
||||
"name": "member",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_team_unique": {
|
||||
"name": "user_team_unique",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"team_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"member_user_id_user_id_fk": {
|
||||
"name": "member_user_id_user_id_fk",
|
||||
"tableFrom": "member",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"member_team_id_team_id_fk": {
|
||||
"name": "member_team_id_team_id_fk",
|
||||
"tableFrom": "member",
|
||||
"tableTo": "team",
|
||||
"columnsFrom": [
|
||||
"team_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"team": {
|
||||
"name": "team",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tournament_team": {
|
||||
"name": "tournament_team",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tournament_id": {
|
||||
"name": "tournament_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"registration_date": {
|
||||
"name": "registration_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tournament_team_unique": {
|
||||
"name": "tournament_team_unique",
|
||||
"columns": [
|
||||
"tournament_id",
|
||||
"team_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tournament_team_tournament_id_tournament_id_fk": {
|
||||
"name": "tournament_team_tournament_id_tournament_id_fk",
|
||||
"tableFrom": "tournament_team",
|
||||
"tableTo": "tournament",
|
||||
"columnsFrom": [
|
||||
"tournament_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tournament_team_team_id_team_id_fk": {
|
||||
"name": "tournament_team_team_id_team_id_fk",
|
||||
"tableFrom": "tournament_team",
|
||||
"tableTo": "team",
|
||||
"columnsFrom": [
|
||||
"team_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tournament": {
|
||||
"name": "tournament",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ticket_id": {
|
||||
"name": "ticket_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ticket_type": {
|
||||
"name": "ticket_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_steam_id_unique": {
|
||||
"name": "user_steam_id_unique",
|
||||
"columns": [
|
||||
"steam_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_ticket_id_unique": {
|
||||
"name": "user_ticket_id_unique",
|
||||
"columns": [
|
||||
"ticket_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
288
migrations/meta/0001_snapshot.json
Normal file
288
migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,288 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "eae7a237-8469-4a6a-acac-1910adfc98ff",
|
||||
"prevId": "efe4e865-a061-4ab0-8023-5211e6d86e14",
|
||||
"tables": {
|
||||
"member": {
|
||||
"name": "member",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_team_unique": {
|
||||
"name": "user_team_unique",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"team_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"member_user_id_user_id_fk": {
|
||||
"name": "member_user_id_user_id_fk",
|
||||
"tableFrom": "member",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"member_team_id_team_id_fk": {
|
||||
"name": "member_team_id_team_id_fk",
|
||||
"tableFrom": "member",
|
||||
"tableTo": "team",
|
||||
"columnsFrom": [
|
||||
"team_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"team": {
|
||||
"name": "team",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tournament_team": {
|
||||
"name": "tournament_team",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tournament_id": {
|
||||
"name": "tournament_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"registration_date": {
|
||||
"name": "registration_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tournament_team_unique": {
|
||||
"name": "tournament_team_unique",
|
||||
"columns": [
|
||||
"tournament_id",
|
||||
"team_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tournament_team_tournament_id_tournament_id_fk": {
|
||||
"name": "tournament_team_tournament_id_tournament_id_fk",
|
||||
"tableFrom": "tournament_team",
|
||||
"tableTo": "tournament",
|
||||
"columnsFrom": [
|
||||
"tournament_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tournament_team_team_id_team_id_fk": {
|
||||
"name": "tournament_team_team_id_team_id_fk",
|
||||
"tableFrom": "tournament_team",
|
||||
"tableTo": "team",
|
||||
"columnsFrom": [
|
||||
"team_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tournament": {
|
||||
"name": "tournament",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ticket_id": {
|
||||
"name": "ticket_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ticket_type": {
|
||||
"name": "ticket_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_steam_id_unique": {
|
||||
"name": "user_steam_id_unique",
|
||||
"columns": [
|
||||
"steam_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"user_ticket_id_unique": {
|
||||
"name": "user_ticket_id_unique",
|
||||
"columns": [
|
||||
"ticket_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
20
migrations/meta/_journal.json
Normal file
20
migrations/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1751642215588,
|
||||
"tag": "0000_awesome_wendigo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1752632968857,
|
||||
"tag": "0001_cool_ma_gnuci",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
50
package.json
50
package.json
@@ -9,34 +9,48 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"drizzle:generate": "drizzle-kit generate:sqlite",
|
||||
"drizzle:push": "drizzle-kit push:sqlite",
|
||||
"drizzle:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@libsql/client": "^0.15.9",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.488.0",
|
||||
"material-symbols": "^0.31.2",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.522.0",
|
||||
"material-symbols": "^0.31.8",
|
||||
"next": "15.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tw-animate-css": "^1.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/bun": "^1.2.18",
|
||||
"@types/node": "^20.19.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"dotenv": "^16.3.1",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5"
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
2987
pnpm-lock.yaml
generated
2987
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
102
src/app/haldus/meeskonnad/page.tsx
Normal file
102
src/app/haldus/meeskonnad/page.tsx
Normal 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
168
src/app/haldus/page.tsx
Normal 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 "Tühista".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
148
src/components/haldus/columns.tsx
Normal file
148
src/components/haldus/columns.tsx
Normal 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>;
|
||||
},
|
||||
},
|
||||
];
|
||||
220
src/components/haldus/data-table.tsx
Normal file
220
src/components/haldus/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 };
|
||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal 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
116
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal 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
6
src/db/drizzle.ts
Normal 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
139
src/db/schema.ts
Normal 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
350
src/lib/fienta.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user