commit 6207a7549f172eaec190befcc1c4d373acbe4e02 Author: khannurien Date: Sun Mar 15 17:15:46 2026 +0000 initial commit, boilerplate stuff diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..a97252b --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM denoland/deno:bin-2.7.5 AS deno +FROM mcr.microsoft.com/devcontainers/typescript-node:20 +COPY --from=deno /deno /usr/local/bin/deno diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..deb951f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Deno", + "build": { + "dockerfile": "Dockerfile" + }, + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + "runArgs": ["--network=host"], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "yarn install", + + // Configure tool-specific properties. + "customizations": { + "vscode": { + "extensions": [ + "justjavac.vscode-deno-extensionpack" + ] + } + }, + "features": { + "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {} + } + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..88341d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +VITE_API_PROTOCOL=http +VITE_WS_PROTOCOL=ws +VITE_SERVER_HOST=localhost +VITE_SERVER_PORT=8000 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f17a1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,169 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ + +# Database +*.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8ac1bc0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Vincent Lannurien + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..3d6597a --- /dev/null +++ b/api/.env.example @@ -0,0 +1,3 @@ +PROTOCOL=http +HOSTNAME=localhost +PORT=8000 \ No newline at end of file diff --git a/api/config.ts b/api/config.ts new file mode 100644 index 0000000..af864e4 --- /dev/null +++ b/api/config.ts @@ -0,0 +1,4 @@ +export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http"; +export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost"; +export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000; +export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`; diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts new file mode 100644 index 0000000..05fc10c --- /dev/null +++ b/api/lib/jwt.ts @@ -0,0 +1,56 @@ +import { randomBytes, scrypt } from "node:crypto"; +import { jwtVerify, SignJWT } from "@panva/jose"; + +import { type AuthPayload, isAuthPayload } from "../model/interfaces.ts"; + +const JWT_SECRET = "tp-M1-SOR-2026"; +const JWT_KEY = new TextEncoder().encode(JWT_SECRET); + +export async function createJWT( + payload: Omit, +): Promise { + return await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("24h") + .sign(JWT_KEY); +} + +export async function verifyJWT(token: string): Promise { + try { + const { payload } = await jwtVerify(token, JWT_KEY); + + if (!isAuthPayload(payload)) { + return null; + } + + return payload; + } catch (err) { + console.error("JWT verification failed:", err); + return null; + } +} + +export function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString("hex"); + + return new Promise((resolve, reject) => { + scrypt(password, salt, 64, (err, derivedKey) => { + if (err) reject(err); + else resolve(`${derivedKey.toString("hex")}.${salt}`); + }); + }); +} + +export function verifyPassword( + password: string, + storedHash: string, +): Promise { + const [hash, salt] = storedHash.split("."); + + return new Promise((resolve, reject) => { + scrypt(password, salt, 64, (err, derivedKey) => { + if (err) reject(err); + else resolve(hash === derivedKey.toString("hex")); + }); + }); +} diff --git a/api/lib/static.ts b/api/lib/static.ts new file mode 100644 index 0000000..999ccad --- /dev/null +++ b/api/lib/static.ts @@ -0,0 +1,16 @@ +import { Context, Next } from "@oak/oak"; + +export default function routeStaticFilesFrom(staticPaths: string[]) { + return async (context: Context>, next: Next) => { + for (const path of staticPaths) { + try { + await context.send({ root: path, index: "index.html" }); + return; + } catch { + continue; + } + } + + await next(); + }; +} diff --git a/api/main.ts b/api/main.ts new file mode 100644 index 0000000..ac59f7a --- /dev/null +++ b/api/main.ts @@ -0,0 +1,42 @@ +import { Application } from "@oak/oak"; +import { oakCors } from "@tajpouria/cors"; + +import dumpsRouter from "./routes/dumps.ts"; +import usersRouter from "./routes/users.ts"; + +import { BASE_URL, HOSTNAME, PORT } from "./config.ts"; +import { errorMiddleware } from "./middleware/error.ts"; +import routeStaticFilesFrom from "./lib/static.ts"; + +const app = new Application(); + +app.use(errorMiddleware); +app.use(oakCors()); +app.use( + dumpsRouter.routes(), + dumpsRouter.allowedMethods(), +); +app.use( + usersRouter.routes(), + usersRouter.allowedMethods(), +); +app.use(routeStaticFilesFrom([ + `${Deno.cwd()}/dist`, + `${Deno.cwd()}/public`, +])); + +app.addEventListener( + "listen", + () => console.log(`Server listening on ${BASE_URL}`), +); + +app.addEventListener( + "error", + (e) => console.log(`Uncaught error: ${e.message}`), +); + +if (import.meta.main) { + await app.listen({ hostname: HOSTNAME, port: PORT }); +} + +export { app }; diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts new file mode 100644 index 0000000..3d00912 --- /dev/null +++ b/api/middleware/auth.ts @@ -0,0 +1,50 @@ +import { Context, Next, State } from "@oak/oak"; +import { verifyJWT } from "../lib/jwt.ts"; +import { + APIErrorCode, + APIException, + type AuthPayload, +} from "../model/interfaces.ts"; + +export interface AuthContext extends Context { + state: AuthState; +} + +export interface AuthState extends State { + user: AuthPayload; +} + +export async function authMiddleware(ctx: AuthContext, next: Next) { + const authHeader = ctx.request.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Missing or invalid token", + ); + } + + const token = authHeader.substring(7); + const payload = await verifyJWT(token); + + if (!payload) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); + } + + ctx.state.user = payload; + + await next(); +} + +export async function adminOnlyMiddleware(ctx: AuthContext, next: Next) { + if (!ctx.state.user?.isAdmin) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 403, + "Admin access required", + ); + } + + await next(); +} diff --git a/api/middleware/error.ts b/api/middleware/error.ts new file mode 100644 index 0000000..0afabff --- /dev/null +++ b/api/middleware/error.ts @@ -0,0 +1,37 @@ +import { Context, Next } from "@oak/oak"; + +import { APIErrorCode, APIException, APIFailure } from "../model/interfaces.ts"; + +export async function errorMiddleware(ctx: Context, next: Next) { + try { + await next(); + } catch (err) { + if (err instanceof APIException) { + const responseBody: APIFailure = { + success: false, + error: { + code: err.code, + message: err.message, + }, + }; + + ctx.response.status = err.status; + ctx.response.body = responseBody; + + console.log(responseBody); + } else { + console.error(err); + + const responseBody: APIFailure = { + success: false, + error: { + code: APIErrorCode.SERVER_ERROR, + message: "Unexpected server error", + }, + }; + + ctx.response.status = 500; + ctx.response.body = responseBody; + } + } +} diff --git a/api/model/db.ts b/api/model/db.ts new file mode 100644 index 0000000..04af48f --- /dev/null +++ b/api/model/db.ts @@ -0,0 +1,97 @@ +import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; +import { Dump, type User } from "./interfaces.ts"; + +export const db = new DatabaseSync("api/sql/gerbeur.db"); + +/** + * Database Row Types + */ + +export interface DumpRow { + id: string; + title: string; + description: string | null; + user_id: string; + created_at: string; + [key: string]: SQLOutputValue; // Index signature +} + +export interface UserRow { + id: string; + username: string; + password_hash: string; + is_admin: number; + created_at: string; + [key: string]: SQLOutputValue; // Index signature +} + +/** + * Type Guards + */ + +export function isDumpRow(obj: Record): obj is DumpRow { + return !!obj && + typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "title" in obj && typeof obj.title === "string" && + "description" in obj && + (typeof obj.description === "string" || obj.description === null) && + "user_id" in obj && typeof obj.user_id === "string" && + "created_at" in obj && typeof obj.created_at === "string"; +} + +export function isUserRow(obj: Record): obj is UserRow { + return !!obj && + typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "username" in obj && typeof obj.username === "string" && + "password_hash" in obj && typeof obj.password_hash === "string" && + "is_admin" in obj && typeof obj.is_admin === "number" && + "created_at" in obj && typeof obj.created_at === "string"; +} + +/** + * Conversion Helpers + */ + +export function dumpRowToApi( + row: DumpRow, +): Dump { + return { + id: row.id, + title: row.title, + description: row.description ?? undefined, + userId: row.user_id, + createdAt: new Date(row.created_at), + }; +} + +export function dumpApiToRow(dump: Dump): DumpRow { + return { + id: dump.id, + title: dump.title, + description: dump.description ?? null, + user_id: dump.userId, + created_at: dump.createdAt.toISOString(), + }; +} + +export function userRowToApi(row: UserRow): User { + return { + id: row.id, + username: row.username, + passwordHash: row.password_hash, + isAdmin: Boolean(row.is_admin), + createdAt: new Date(row.created_at), + }; +} + +export function userApiToRow(user: User): UserRow { + return { + id: user.id, + username: user.username, + password_hash: user.passwordHash, + is_admin: user.isAdmin ? 1 : 0, + created_at: user.createdAt.toISOString(), + }; +} diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts new file mode 100644 index 0000000..3f61248 --- /dev/null +++ b/api/model/interfaces.ts @@ -0,0 +1,154 @@ +/** + * Backend + */ + +export interface Dump { + id: string; + title: string; + description?: string; + userId: string; + createdAt: Date; +} + +/** + * Authentication + */ + +export interface User { + id: string; + username: string; + passwordHash: string; + isAdmin: boolean; + createdAt: Date; +} + +export interface LoginUserRequest { + username: string; + password: string; +} + +export interface RegisterUserRequest { + username: string; + password: string; +} + +export interface UpdateUserRequest { + username?: string; + password?: string; + isAdmin?: boolean; +} + +export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { + return !!obj && typeof obj === "object" && + "username" in obj && typeof obj.username === "string" && + "password" in obj && typeof obj.password === "string"; +} + +export function isRegisterUserRequest( + obj: unknown, +): obj is RegisterUserRequest { + return !!obj && typeof obj === "object" && + "username" in obj && typeof obj.username === "string" && + "password" in obj && typeof obj.password === "string"; +} + +export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { + return !!obj && typeof obj === "object" && + (!("username" in obj) || typeof obj.username === "string") && + (!("password" in obj) || typeof obj.password === "string") && + (!("isAdmin" in obj) || typeof obj.isAdmin === "boolean"); +} + +export interface AuthResponse { + token: string; + user: User; +} + +export interface AuthPayload { + userId: string; + username: string; + isAdmin: boolean; + exp: number; +} + +export function isAuthPayload(obj: unknown): obj is AuthPayload { + return !!obj && + typeof obj === "object" && + "userId" in obj && typeof obj.userId === "string" && + "username" in obj && typeof obj.username === "string" && + "isAdmin" in obj && typeof obj.isAdmin === "boolean" && + "exp" in obj && typeof obj.exp === "number"; +} + +/** + * API + */ + +export enum APIErrorCode { + BAD_REQUEST = "BAD_REQUEST", + NOT_FOUND = "NOT_FOUND", + SERVER_ERROR = "SERVER_ERROR", + TIMEOUT = "TIMEOUT", + UNAUTHORIZED = "UNAUTHORIZED", + VALIDATION_ERROR = "VALIDATION_ERROR", +} + +export interface APIError { + code: APIErrorCode; + message: string; +} + +export interface APISuccess { + success: true; + data: T; + error?: never; +} + +export interface APIFailure { + success: false; + data?: never; + error: APIError; +} + +export type APIResponse = APISuccess | APIFailure; + +export class APIException extends Error { + readonly code: APIErrorCode; + readonly status: number; + + constructor(code: APIErrorCode, status: number, message: string) { + super(message); + this.code = code; + this.status = status; + } +} + +/** + * Request DTOs + */ + +export interface CreateDumpRequest { + title: string; + description?: string; +} + +export function isCreateDumpRequest(obj: unknown): obj is CreateDumpRequest { + return !!obj && + typeof obj === "object" && + "title" in obj && typeof obj.title === "string" && + (!("description" in obj) || + (typeof obj.description === "string" || obj.description === null)); +} + +export interface UpdateDumpRequest { + title?: string; + description?: string; +} + +export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { + return !!obj && + typeof obj === "object" && + (!("title" in obj) || typeof obj.title === "string") && + (!("description" in obj) || + (typeof obj.description === "string" || obj.description === null)); +} diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts new file mode 100644 index 0000000..6f5e048 --- /dev/null +++ b/api/routes/dumps.ts @@ -0,0 +1,130 @@ +import { Router } from "@oak/oak"; + +import { + APIErrorCode, + APIException, + type APIResponse, + type Dump, + isCreateDumpRequest, + isUpdateDumpRequest, +} from "../model/interfaces.ts"; + +import { authMiddleware } from "../middleware/auth.ts"; +import { + createDump, + deleteDump, + getDump, + listDumps, + updateDump, +} from "../services/dump-service.ts"; + +const router = new Router({ prefix: "/api/dumps" }); + +router.post( + "/", + authMiddleware, + async (ctx) => { + const createDumpRequest = await ctx.request.body.json(); + + if (!isCreateDumpRequest(createDumpRequest)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid dump data", + ); + } + + const userId = ctx.state.user.userId; + const dump = createDump(createDumpRequest, userId); + + const responseBody: APIResponse = { + success: true, + data: dump, + }; + + ctx.response.status = 201; + ctx.response.body = responseBody; + }, +); + +router.get("/:dumpId", (ctx) => { + const dumpId = ctx.params.dumpId; + const dump = getDump(dumpId); + + const responseBody: APIResponse = { + success: true, + data: dump, + }; + + ctx.response.body = responseBody; +}); + +router.get("/", (ctx) => { + const dumps = listDumps(); + + const responseBody: APIResponse = { + success: true, + data: dumps, + }; + + ctx.response.body = responseBody; +}); + +router.put("/:dumpId", authMiddleware, async (ctx) => { + const dumpId = ctx.params.dumpId; + const userId = ctx.state.user?.userId; + const updateDumpRequest = await ctx.request.body.json(); + + if (!isUpdateDumpRequest(updateDumpRequest)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 422, + "Erroneous user input", + ); + } + + const dump = getDump(dumpId); + + if (userId !== dump.userId) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to update dump", + ); + } + + const updatedDump = updateDump(dumpId, updateDumpRequest); + + const responseBody: APIResponse = { + success: true, + data: updatedDump, + }; + + ctx.response.body = responseBody; +}); + +router.delete("/:dumpId", authMiddleware, (ctx) => { + const dumpId = ctx.params.dumpId; + const userId = ctx.state.user?.userId; + + const dump = getDump(dumpId); + + if (userId !== dump.userId) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to update dump", + ); + } + + deleteDump(dumpId); + + const responseBody: APIResponse = { + success: true, + data: null, + }; + + ctx.response.body = responseBody; +}); + +export default router; diff --git a/api/routes/users.ts b/api/routes/users.ts new file mode 100644 index 0000000..15c5988 --- /dev/null +++ b/api/routes/users.ts @@ -0,0 +1,132 @@ +import { Router } from "@oak/oak"; + +import { + APIErrorCode, + APIException, + isLoginUserRequest, + isRegisterUserRequest, +} from "../model/interfaces.ts"; + +import { createJWT, verifyPassword } from "../lib/jwt.ts"; +import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; +import { + createUser, + getUserById, + getUserByUsername, +} from "../services/user-service.ts"; + +// Users router +const router = new Router({ prefix: "/api/users" }); + +// Register a new user +router.post("/register", async (ctx) => { + try { + const body = await ctx.request.body.json(); + + if (!isRegisterUserRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid request", + ); + } + + const user = await createUser(body); + const token = await createJWT({ + userId: user.id, + username: user.username, + isAdmin: user.isAdmin, + }); + + ctx.response.status = 201; + ctx.response.body = { + success: true, + data: { + token, + user, + }, + }; + } catch (err) { + console.error(err); + + throw new APIException( + APIErrorCode.SERVER_ERROR, + 500, + "Failed to register user", + ); + } +}); + +// Login +router.post("/login", async (ctx) => { + try { + const body = await ctx.request.body.json(); + + if (!isLoginUserRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid request", + ); + } + + const user = getUserByUsername(body.username); + const valid = await verifyPassword(body.password, user.passwordHash); + + if (!valid) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 401, + "Invalid username or password", + ); + } + + const token = await createJWT({ + userId: user.id, + username: user.username, + isAdmin: user.isAdmin, + }); + + ctx.response.body = { + success: true, + data: { + token, + user, + }, + }; + } catch (err) { + console.error(err); + + throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login"); + } +}); + +// Get current user profile +router.get("/me", authMiddleware, (ctx: AuthContext) => { + try { + if (!ctx.state.user) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authenticated", + ); + } + + const user = getUserById(ctx.state.user.userId); + + ctx.response.body = { + success: true, + data: user, + }; + } catch (err) { + console.error(err); + + throw new APIException( + APIErrorCode.SERVER_ERROR, + 500, + "Failed to fetch user profile", + ); + } +}); + +export default router; diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts new file mode 100644 index 0000000..6931140 --- /dev/null +++ b/api/services/dump-service.ts @@ -0,0 +1,99 @@ +import { + APIErrorCode, + APIException, + type CreateDumpRequest, + type Dump, + type UpdateDumpRequest, +} from "../model/interfaces.ts"; +import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts"; + +export function createDump( + request: CreateDumpRequest, + userId: string, +): Dump { + const dumpId = crypto.randomUUID(); + const createdAt = new Date(); + + db.prepare( + `INSERT INTO dumps (id, title, description, user_id, created_at) + VALUES (?, ?, ?, ?, ?);`, + ).run( + dumpId, + request.title, + request.description ?? null, + userId, + createdAt.toISOString(), + ); + + return { + id: dumpId, + title: request.title, + description: request.description ?? undefined, + userId: userId, + createdAt, + }; +} + +export function getDump(dumpId: string): Dump { + const dumpRow = db.prepare( + `SELECT id, title, description, user_id, created_at + FROM dumps WHERE id = ?;`, + ).get(dumpId); + + if (!dumpRow || !isDumpRow(dumpRow)) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + } + + return dumpRowToApi(dumpRow); +} + +export function listDumps(): Dump[] { + const dumpRows = db.prepare( + `SELECT id, title, description, user_id, created_at FROM dumps;`, + ).all(); + + if (!dumpRows || !dumpRows.every(isDumpRow)) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "No dump found"); + } + + const dumps: Dump[] = dumpRows.map(dumpRowToApi); + + return dumps; +} + +export function updateDump( + dumpId: string, + request: UpdateDumpRequest, +): Dump { + const dump = getDump(dumpId); + + const updatedDump = { + ...dump, + ...request, + }; + const updatedDumpRow = dumpApiToRow(updatedDump); + + const dumpResult = db.prepare( + `UPDATE dumps SET title = ?, description = ? WHERE id = ?;`, + ).run( + updatedDumpRow.title, + updatedDumpRow.description, + updatedDumpRow.id, + ); + + if (dumpResult.changes === 0) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + } + + return updatedDump; +} + +export function deleteDump(dumpId: string): void { + const result = db.prepare( + `DELETE FROM dumps WHERE id = ?;`, + ).run(dumpId); + + if (result.changes === 0) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + } +} diff --git a/api/services/user-service.ts b/api/services/user-service.ts new file mode 100644 index 0000000..30dab3f --- /dev/null +++ b/api/services/user-service.ts @@ -0,0 +1,130 @@ +import { + APIErrorCode, + APIException, + type RegisterUserRequest, + type UpdateUserRequest, + type User, +} from "../model/interfaces.ts"; +import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts"; + +import { hashPassword } from "../lib/jwt.ts"; + +export async function createUser( + request: RegisterUserRequest, +): Promise { + const userId = crypto.randomUUID(); + const createdAt = new Date(); + + const existingUser = db.prepare( + "SELECT id FROM users WHERE username = ?;", + ).get(request.username); + + if (existingUser) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Username already exists", + ); + } + + const passwordHash = await hashPassword(request.password); + + db.prepare( + `INSERT INTO users (id, username, password_hash, is_admin, created_at) + VALUES (?, ?, ?, ?, ?);`, + ).run( + userId, + request.username, + passwordHash, + 0, + createdAt.toISOString(), + ); + + return { + id: userId, + username: request.username, + passwordHash, + isAdmin: false, + createdAt, + }; +} + +export function getUserById(userId: string): User { + const userRow = db.prepare( + `SELECT id, username, password_hash, is_admin, created_at + FROM users WHERE id = ?`, + ).get(userId); + + if (!userRow || !isUserRow(userRow)) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + } + + return userRowToApi(userRow); +} + +export function getUserByUsername(username: string): User { + const userRow = db.prepare( + `SELECT id, username, password_hash, is_admin, created_at + FROM users WHERE username = ?`, + ).get(username); + + if (!userRow || !isUserRow(userRow)) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + } + + return userRowToApi(userRow); +} + +export function listUsers(): User[] { + const userRows = db.prepare( + `SELECT id, username, password_hash, is_admin, created_at FROM users`, + ).all(); + + if (!userRows || !userRows.every(isUserRow)) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "No user found"); + } + + return userRows.map(userRowToApi); +} + +export async function updateUser( + userId: string, + request: UpdateUserRequest, +): Promise { + const user = getUserById(userId); + + const { password, ...requestFields } = request; + + const updatedUser: User = { + ...user, + passwordHash: password ? await hashPassword(password) : user.passwordHash, + ...requestFields, + }; + + const updatedUserRow = userApiToRow(updatedUser); + + const userResult = db.prepare( + `UPDATE users SET username = ?, password_hash = ?, is_admin = ? WHERE id = ?`, + ).run( + updatedUserRow.username, + updatedUserRow.password_hash, + updatedUserRow.is_admin, + updatedUserRow.id, + ); + + if (userResult.changes === 0) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); + } + + return updatedUser; +} + +export function deleteUser(userId: string): void { + const result = db.prepare( + `DELETE FROM users WHERE id = ?;`, + ).run(userId); + + if (result.changes === 0) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + } +} diff --git a/api/sql/schema.sql b/api/sql/schema.sql new file mode 100644 index 0000000..b6fb59c --- /dev/null +++ b/api/sql/schema.sql @@ -0,0 +1,18 @@ +CREATE TABLE dumps ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + url TEXT, + rich_content TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..7e8a9b1 --- /dev/null +++ b/deno.json @@ -0,0 +1,29 @@ +{ + "tasks": { + "dev": "deno run -A npm:vite & deno run -A server:start", + "build": "deno run -A npm:vite build", + "server:start": "deno run -A --watch ./api/main.ts", + "serve": "deno run -A build && deno run -A server:start" + }, + "nodeModulesDir": "auto", + "compilerOptions": { + "types": [ + "react", + "react-dom", + "@types/react" + ], + "lib": [ + "dom", + "dom.iterable", + "deno.ns" + ], + "jsx": "react-jsx", + "jsxImportSource": "react" + }, + "imports": { + "@db/sqlite": "jsr:@db/sqlite@^0.13.0", + "@oak/oak": "jsr:@oak/oak@^17.2.0", + "@panva/jose": "jsr:@panva/jose@^6.2.1", + "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..286193e --- /dev/null +++ b/deno.lock @@ -0,0 +1,1360 @@ +{ + "version": "5", + "specifiers": { + "jsr:@db/sqlite@0.13": "0.13.0", + "jsr:@denosaurs/plug@1": "1.1.0", + "jsr:@oak/commons@1": "1.0.1", + "jsr:@oak/oak@^17.2.0": "17.2.0", + "jsr:@panva/jose@^6.2.1": "6.2.1", + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/bytes@1": "1.0.6", + "jsr:@std/crypto@1": "1.0.5", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@1": "1.0.9", + "jsr:@std/fs@1": "1.0.23", + "jsr:@std/http@1": "1.0.25", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/media-types@1": "1.1.0", + "jsr:@std/path@1": "1.1.4", + "jsr:@std/path@1.0": "1.0.9", + "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@tajpouria/cors@^1.2.1": "1.2.1", + "npm:@deno/vite-plugin@^1.0.6": "1.0.6_vite@8.0.0__@types+node@24.12.0", + "npm:@eslint/js@^9.39.4": "9.39.4", + "npm:@types/node@^24.12.0": "24.12.0", + "npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.14", + "npm:@types/react@^19.2.14": "19.2.14", + "npm:@vitejs/plugin-react@^6.0.1": "6.0.1_vite@8.0.0__@types+node@24.12.0", + "npm:eslint-plugin-react-hooks@^7.0.1": "7.0.1_eslint@9.39.4", + "npm:eslint-plugin-react-refresh@~0.5.2": "0.5.2_eslint@9.39.4", + "npm:eslint@^9.39.4": "9.39.4", + "npm:globals@^17.4.0": "17.4.0", + "npm:path-to-regexp@^6.3.0": "6.3.0", + "npm:react-dom@^19.2.4": "19.2.4_react@19.2.4", + "npm:react-router@^7.13.1": "7.13.1_react@19.2.4_react-dom@19.2.4__react@19.2.4", + "npm:react@^19.2.4": "19.2.4", + "npm:typescript-eslint@^8.56.1": "8.57.0_eslint@9.39.4_typescript@5.9.3", + "npm:typescript@~5.9.3": "5.9.3", + "npm:vite@*": "8.0.0_@types+node@24.12.0", + "npm:vite@8": "8.0.0_@types+node@24.12.0" + }, + "jsr": { + "@db/sqlite@0.13.0": { + "integrity": "4545c635e0b3d4ddfdc0f2240f932f24b8ad0178e9c2e3a0f9403e7b18ae2fb5", + "dependencies": [ + "jsr:@denosaurs/plug", + "jsr:@std/path@1.0" + ] + }, + "@denosaurs/plug@1.1.0": { + "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", + "dependencies": [ + "jsr:@std/encoding@1", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/path@1" + ] + }, + "@oak/commons@1.0.1": { + "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/bytes", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/http", + "jsr:@std/media-types" + ] + }, + "@oak/oak@17.2.0": { + "integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1", + "dependencies": [ + "jsr:@oak/commons", + "jsr:@std/assert", + "jsr:@std/bytes", + "jsr:@std/http", + "jsr:@std/media-types", + "jsr:@std/path@1", + "npm:path-to-regexp" + ] + }, + "@panva/jose@6.2.1": { + "integrity": "6725a90a47be84c57a0f889c73bf09c6a209019c816f1f029f9084929fadfcbf" + }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e" + }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/crypto@1.0.5": { + "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, + "@std/fs@1.0.23": { + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", + "dependencies": [ + "jsr:@std/internal", + "jsr:@std/path@^1.1.4" + ] + }, + "@std/http@1.0.25": { + "integrity": "577b4252290af1097132812b339fffdd55fb0f4aeb98ff11bdbf67998aa17193", + "dependencies": [ + "jsr:@std/encoding@^1.0.10" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.0.9": { + "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@tajpouria/cors@1.2.1": { + "integrity": "eca42e4fb7cb3906ef0ee3d1e565dd6bb4632ccd8e70a95cf4279759743328f0" + } + }, + "npm": { + "@babel/code-frame@7.29.0": { + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dependencies": [ + "@babel/helper-validator-identifier", + "js-tokens", + "picocolors" + ] + }, + "@babel/compat-data@7.29.0": { + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==" + }, + "@babel/core@7.29.0": { + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-compilation-targets", + "@babel/helper-module-transforms", + "@babel/helpers", + "@babel/parser", + "@babel/template", + "@babel/traverse", + "@babel/types", + "@jridgewell/remapping", + "convert-source-map", + "debug", + "gensync", + "json5", + "semver@6.3.1" + ] + }, + "@babel/generator@7.29.1": { + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping", + "jsesc" + ] + }, + "@babel/helper-compilation-targets@7.28.6": { + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dependencies": [ + "@babel/compat-data", + "@babel/helper-validator-option", + "browserslist", + "lru-cache", + "semver@6.3.1" + ] + }, + "@babel/helper-globals@7.28.0": { + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==" + }, + "@babel/helper-module-imports@7.28.6": { + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dependencies": [ + "@babel/traverse", + "@babel/types" + ] + }, + "@babel/helper-module-transforms@7.28.6_@babel+core@7.29.0": { + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dependencies": [ + "@babel/core", + "@babel/helper-module-imports", + "@babel/helper-validator-identifier", + "@babel/traverse" + ] + }, + "@babel/helper-string-parser@7.27.1": { + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" + }, + "@babel/helper-validator-identifier@7.28.5": { + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" + }, + "@babel/helper-validator-option@7.27.1": { + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==" + }, + "@babel/helpers@7.28.6": { + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dependencies": [ + "@babel/template", + "@babel/types" + ] + }, + "@babel/parser@7.29.0": { + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dependencies": [ + "@babel/types" + ], + "bin": true + }, + "@babel/template@7.28.6": { + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dependencies": [ + "@babel/code-frame", + "@babel/parser", + "@babel/types" + ] + }, + "@babel/traverse@7.29.0": { + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dependencies": [ + "@babel/code-frame", + "@babel/generator", + "@babel/helper-globals", + "@babel/parser", + "@babel/template", + "@babel/types", + "debug" + ] + }, + "@babel/types@7.29.0": { + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": [ + "@babel/helper-string-parser", + "@babel/helper-validator-identifier" + ] + }, + "@deno/vite-plugin@1.0.6_vite@8.0.0__@types+node@24.12.0": { + "integrity": "sha512-Sh5XqvFuKAwjARTesi0n6xRpEXm1V0UeqKh+SxIrexCofxOaieNDMqXZD02RiZCg0mrJ43V8eCMuVrDfq6mLmg==", + "dependencies": [ + "vite" + ] + }, + "@emnapi/core@1.9.0": { + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dependencies": [ + "@emnapi/wasi-threads", + "tslib" + ] + }, + "@emnapi/runtime@1.9.0": { + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dependencies": [ + "tslib" + ] + }, + "@emnapi/wasi-threads@1.2.0": { + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dependencies": [ + "tslib" + ] + }, + "@eslint-community/eslint-utils@4.9.1_eslint@9.39.4": { + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dependencies": [ + "eslint", + "eslint-visitor-keys@3.4.3" + ] + }, + "@eslint-community/regexpp@4.12.2": { + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==" + }, + "@eslint/config-array@0.21.2": { + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dependencies": [ + "@eslint/object-schema", + "debug", + "minimatch@3.1.5" + ] + }, + "@eslint/config-helpers@0.4.2": { + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dependencies": [ + "@eslint/core" + ] + }, + "@eslint/core@0.17.0": { + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dependencies": [ + "@types/json-schema" + ] + }, + "@eslint/eslintrc@3.3.5": { + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dependencies": [ + "ajv", + "debug", + "espree", + "globals@14.0.0", + "ignore@5.3.2", + "import-fresh", + "js-yaml", + "minimatch@3.1.5", + "strip-json-comments" + ] + }, + "@eslint/js@9.39.4": { + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==" + }, + "@eslint/object-schema@2.1.7": { + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==" + }, + "@eslint/plugin-kit@0.4.1": { + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dependencies": [ + "@eslint/core", + "levn" + ] + }, + "@humanfs/core@0.19.1": { + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==" + }, + "@humanfs/node@0.16.7": { + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dependencies": [ + "@humanfs/core", + "@humanwhocodes/retry" + ] + }, + "@humanwhocodes/module-importer@1.0.1": { + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==" + }, + "@humanwhocodes/retry@0.4.3": { + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==" + }, + "@jridgewell/gen-mapping@0.3.13": { + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.31": { + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@napi-rs/wasm-runtime@1.1.1": { + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dependencies": [ + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util" + ] + }, + "@oxc-project/runtime@0.115.0": { + "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==" + }, + "@oxc-project/types@0.115.0": { + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==" + }, + "@rolldown/binding-android-arm64@1.0.0-rc.9": { + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rolldown/binding-darwin-arm64@1.0.0-rc.9": { + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rolldown/binding-darwin-x64@1.0.0-rc.9": { + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rolldown/binding-freebsd-x64@1.0.0-rc.9": { + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9": { + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9": { + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rolldown/binding-linux-arm64-musl@1.0.0-rc.9": { + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9": { + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9": { + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rolldown/binding-linux-x64-gnu@1.0.0-rc.9": { + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rolldown/binding-linux-x64-musl@1.0.0-rc.9": { + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rolldown/binding-openharmony-arm64@1.0.0-rc.9": { + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rolldown/binding-wasm32-wasi@1.0.0-rc.9": { + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "dependencies": [ + "@napi-rs/wasm-runtime" + ], + "cpu": ["wasm32"] + }, + "@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9": { + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rolldown/binding-win32-x64-msvc@1.0.0-rc.9": { + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rolldown/pluginutils@1.0.0-rc.7": { + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==" + }, + "@rolldown/pluginutils@1.0.0-rc.9": { + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==" + }, + "@tybys/wasm-util@0.10.1": { + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dependencies": [ + "tslib" + ] + }, + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "@types/json-schema@7.0.15": { + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, + "@types/node@24.12.0": { + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dependencies": [ + "undici-types" + ] + }, + "@types/react-dom@19.2.3_@types+react@19.2.14": { + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dependencies": [ + "@types/react" + ] + }, + "@types/react@19.2.14": { + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dependencies": [ + "csstype" + ] + }, + "@typescript-eslint/eslint-plugin@8.57.0_@typescript-eslint+parser@8.57.0__eslint@9.39.4__typescript@5.9.3_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "dependencies": [ + "@eslint-community/regexpp", + "@typescript-eslint/parser", + "@typescript-eslint/scope-manager", + "@typescript-eslint/type-utils", + "@typescript-eslint/utils", + "@typescript-eslint/visitor-keys", + "eslint", + "ignore@7.0.5", + "natural-compare", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/parser@8.57.0_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "dependencies": [ + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/visitor-keys", + "debug", + "eslint", + "typescript" + ] + }, + "@typescript-eslint/project-service@8.57.0_typescript@5.9.3": { + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dependencies": [ + "@typescript-eslint/tsconfig-utils", + "@typescript-eslint/types", + "debug", + "typescript" + ] + }, + "@typescript-eslint/scope-manager@8.57.0": { + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys" + ] + }, + "@typescript-eslint/tsconfig-utils@8.57.0_typescript@5.9.3": { + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dependencies": [ + "typescript" + ] + }, + "@typescript-eslint/type-utils@8.57.0_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "dependencies": [ + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/utils", + "debug", + "eslint", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/types@8.57.0": { + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==" + }, + "@typescript-eslint/typescript-estree@8.57.0_typescript@5.9.3": { + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dependencies": [ + "@typescript-eslint/project-service", + "@typescript-eslint/tsconfig-utils", + "@typescript-eslint/types", + "@typescript-eslint/visitor-keys", + "debug", + "minimatch@10.2.4", + "semver@7.7.4", + "tinyglobby", + "ts-api-utils", + "typescript" + ] + }, + "@typescript-eslint/utils@8.57.0_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "dependencies": [ + "@eslint-community/eslint-utils", + "@typescript-eslint/scope-manager", + "@typescript-eslint/types", + "@typescript-eslint/typescript-estree", + "eslint", + "typescript" + ] + }, + "@typescript-eslint/visitor-keys@8.57.0": { + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "dependencies": [ + "@typescript-eslint/types", + "eslint-visitor-keys@5.0.1" + ] + }, + "@vitejs/plugin-react@6.0.1_vite@8.0.0__@types+node@24.12.0": { + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dependencies": [ + "@rolldown/pluginutils@1.0.0-rc.7", + "vite" + ] + }, + "acorn-jsx@5.3.2_acorn@8.16.0": { + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dependencies": [ + "acorn" + ] + }, + "acorn@8.16.0": { + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "bin": true + }, + "ajv@6.14.0": { + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dependencies": [ + "fast-deep-equal", + "fast-json-stable-stringify", + "json-schema-traverse", + "uri-js" + ] + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "balanced-match@1.0.2": { + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "balanced-match@4.0.4": { + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "baseline-browser-mapping@2.10.8": { + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "bin": true + }, + "brace-expansion@1.1.12": { + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dependencies": [ + "balanced-match@1.0.2", + "concat-map" + ] + }, + "brace-expansion@5.0.4": { + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dependencies": [ + "balanced-match@4.0.4" + ] + }, + "browserslist@4.28.1": { + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dependencies": [ + "baseline-browser-mapping", + "caniuse-lite", + "electron-to-chromium", + "node-releases", + "update-browserslist-db" + ], + "bin": true + }, + "callsites@3.1.0": { + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" + }, + "caniuse-lite@1.0.30001779": { + "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==" + }, + "chalk@4.1.2": { + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": [ + "ansi-styles", + "supports-color" + ] + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "concat-map@0.0.1": { + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "convert-source-map@2.0.0": { + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "cookie@1.1.1": { + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, + "cross-spawn@7.0.6": { + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": [ + "path-key", + "shebang-command", + "which" + ] + }, + "csstype@3.2.3": { + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "deep-is@0.1.4": { + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + }, + "detect-libc@2.1.2": { + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, + "electron-to-chromium@1.5.313": { + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==" + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "escape-string-regexp@4.0.0": { + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "eslint-plugin-react-hooks@7.0.1_eslint@9.39.4": { + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dependencies": [ + "@babel/core", + "@babel/parser", + "eslint", + "hermes-parser", + "zod", + "zod-validation-error" + ] + }, + "eslint-plugin-react-refresh@0.5.2_eslint@9.39.4": { + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dependencies": [ + "eslint" + ] + }, + "eslint-scope@8.4.0": { + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dependencies": [ + "esrecurse", + "estraverse" + ] + }, + "eslint-visitor-keys@3.4.3": { + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==" + }, + "eslint-visitor-keys@4.2.1": { + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==" + }, + "eslint-visitor-keys@5.0.1": { + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==" + }, + "eslint@9.39.4": { + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dependencies": [ + "@eslint-community/eslint-utils", + "@eslint-community/regexpp", + "@eslint/config-array", + "@eslint/config-helpers", + "@eslint/core", + "@eslint/eslintrc", + "@eslint/js", + "@eslint/plugin-kit", + "@humanfs/node", + "@humanwhocodes/module-importer", + "@humanwhocodes/retry", + "@types/estree", + "ajv", + "chalk", + "cross-spawn", + "debug", + "escape-string-regexp", + "eslint-scope", + "eslint-visitor-keys@4.2.1", + "espree", + "esquery", + "esutils", + "fast-deep-equal", + "file-entry-cache", + "find-up", + "glob-parent", + "ignore@5.3.2", + "imurmurhash", + "is-glob", + "json-stable-stringify-without-jsonify", + "lodash.merge", + "minimatch@3.1.5", + "natural-compare", + "optionator" + ], + "bin": true + }, + "espree@10.4.0": { + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dependencies": [ + "acorn", + "acorn-jsx", + "eslint-visitor-keys@4.2.1" + ] + }, + "esquery@1.7.0": { + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dependencies": [ + "estraverse" + ] + }, + "esrecurse@4.3.0": { + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dependencies": [ + "estraverse" + ] + }, + "estraverse@5.3.0": { + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==" + }, + "esutils@2.0.3": { + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify@2.1.0": { + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "fast-levenshtein@2.0.6": { + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + }, + "fdir@6.5.0_picomatch@4.0.3": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dependencies": [ + "picomatch" + ], + "optionalPeers": [ + "picomatch" + ] + }, + "file-entry-cache@8.0.0": { + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dependencies": [ + "flat-cache" + ] + }, + "find-up@5.0.0": { + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": [ + "locate-path", + "path-exists" + ] + }, + "flat-cache@4.0.1": { + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dependencies": [ + "flatted", + "keyv" + ] + }, + "flatted@3.4.1": { + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==" + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "gensync@1.0.0-beta.2": { + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" + }, + "glob-parent@6.0.2": { + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": [ + "is-glob" + ] + }, + "globals@14.0.0": { + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==" + }, + "globals@17.4.0": { + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==" + }, + "has-flag@4.0.0": { + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "hermes-estree@0.25.1": { + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==" + }, + "hermes-parser@0.25.1": { + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dependencies": [ + "hermes-estree" + ] + }, + "ignore@5.3.2": { + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" + }, + "ignore@7.0.5": { + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==" + }, + "import-fresh@3.3.1": { + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dependencies": [ + "parent-module", + "resolve-from" + ] + }, + "imurmurhash@0.1.4": { + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" + }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml@4.1.1": { + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dependencies": [ + "argparse" + ], + "bin": true + }, + "jsesc@3.1.0": { + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": true + }, + "json-buffer@3.0.1": { + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "json-schema-traverse@0.4.1": { + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stable-stringify-without-jsonify@1.0.1": { + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + }, + "json5@2.2.3": { + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": true + }, + "keyv@4.5.4": { + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dependencies": [ + "json-buffer" + ] + }, + "levn@0.4.1": { + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dependencies": [ + "prelude-ls", + "type-check" + ] + }, + "lightningcss-android-arm64@1.32.0": { + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-arm64@1.32.0": { + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-x64@1.32.0": { + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "lightningcss-freebsd-x64@1.32.0": { + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "lightningcss-linux-arm-gnueabihf@1.32.0": { + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "lightningcss-linux-arm64-gnu@1.32.0": { + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-arm64-musl@1.32.0": { + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-x64-gnu@1.32.0": { + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-linux-x64-musl@1.32.0": { + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-win32-arm64-msvc@1.32.0": { + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "lightningcss-win32-x64-msvc@1.32.0": { + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "os": ["win32"], + "cpu": ["x64"] + }, + "lightningcss@1.32.0": { + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dependencies": [ + "detect-libc" + ], + "optionalDependencies": [ + "lightningcss-android-arm64", + "lightningcss-darwin-arm64", + "lightningcss-darwin-x64", + "lightningcss-freebsd-x64", + "lightningcss-linux-arm-gnueabihf", + "lightningcss-linux-arm64-gnu", + "lightningcss-linux-arm64-musl", + "lightningcss-linux-x64-gnu", + "lightningcss-linux-x64-musl", + "lightningcss-win32-arm64-msvc", + "lightningcss-win32-x64-msvc" + ] + }, + "locate-path@6.0.0": { + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": [ + "p-locate" + ] + }, + "lodash.merge@4.6.2": { + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "lru-cache@5.1.1": { + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": [ + "yallist" + ] + }, + "minimatch@10.2.4": { + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dependencies": [ + "brace-expansion@5.0.4" + ] + }, + "minimatch@3.1.5": { + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dependencies": [ + "brace-expansion@1.1.12" + ] + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@3.3.11": { + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": true + }, + "natural-compare@1.4.0": { + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + }, + "node-releases@2.0.36": { + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" + }, + "optionator@0.9.4": { + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dependencies": [ + "deep-is", + "fast-levenshtein", + "levn", + "prelude-ls", + "type-check", + "word-wrap" + ] + }, + "p-limit@3.1.0": { + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dependencies": [ + "yocto-queue" + ] + }, + "p-locate@5.0.0": { + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": [ + "p-limit" + ] + }, + "parent-module@1.0.1": { + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": [ + "callsites" + ] + }, + "path-exists@4.0.0": { + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "path-key@3.1.1": { + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "path-to-regexp@6.3.0": { + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@4.0.3": { + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" + }, + "postcss@8.5.8": { + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "prelude-ls@1.2.1": { + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "react-dom@19.2.4_react@19.2.4": { + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dependencies": [ + "react", + "scheduler" + ] + }, + "react-router@7.13.1_react@19.2.4_react-dom@19.2.4__react@19.2.4": { + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "dependencies": [ + "cookie", + "react", + "react-dom", + "set-cookie-parser" + ], + "optionalPeers": [ + "react-dom" + ] + }, + "react@19.2.4": { + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" + }, + "resolve-from@4.0.0": { + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" + }, + "rolldown@1.0.0-rc.9": { + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "dependencies": [ + "@oxc-project/types", + "@rolldown/pluginutils@1.0.0-rc.9" + ], + "optionalDependencies": [ + "@rolldown/binding-android-arm64", + "@rolldown/binding-darwin-arm64", + "@rolldown/binding-darwin-x64", + "@rolldown/binding-freebsd-x64", + "@rolldown/binding-linux-arm-gnueabihf", + "@rolldown/binding-linux-arm64-gnu", + "@rolldown/binding-linux-arm64-musl", + "@rolldown/binding-linux-ppc64-gnu", + "@rolldown/binding-linux-s390x-gnu", + "@rolldown/binding-linux-x64-gnu", + "@rolldown/binding-linux-x64-musl", + "@rolldown/binding-openharmony-arm64", + "@rolldown/binding-wasm32-wasi", + "@rolldown/binding-win32-arm64-msvc", + "@rolldown/binding-win32-x64-msvc" + ], + "bin": true + }, + "scheduler@0.27.0": { + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "semver@6.3.1": { + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": true + }, + "semver@7.7.4": { + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": true + }, + "set-cookie-parser@2.7.2": { + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "shebang-command@2.0.0": { + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@3.0.0": { + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "strip-json-comments@3.1.1": { + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" + }, + "supports-color@7.2.0": { + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": [ + "has-flag" + ] + }, + "tinyglobby@0.2.15": { + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": [ + "fdir", + "picomatch" + ] + }, + "ts-api-utils@2.4.0_typescript@5.9.3": { + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dependencies": [ + "typescript" + ] + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "type-check@0.4.0": { + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dependencies": [ + "prelude-ls" + ] + }, + "typescript-eslint@8.57.0_eslint@9.39.4_typescript@5.9.3": { + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "dependencies": [ + "@typescript-eslint/eslint-plugin", + "@typescript-eslint/parser", + "@typescript-eslint/typescript-estree", + "@typescript-eslint/utils", + "eslint", + "typescript" + ] + }, + "typescript@5.9.3": { + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "bin": true + }, + "undici-types@7.16.0": { + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + }, + "update-browserslist-db@1.2.3_browserslist@4.28.1": { + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dependencies": [ + "browserslist", + "escalade", + "picocolors" + ], + "bin": true + }, + "uri-js@4.4.1": { + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": [ + "punycode" + ] + }, + "vite@8.0.0_@types+node@24.12.0": { + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "dependencies": [ + "@oxc-project/runtime", + "@types/node", + "lightningcss", + "picomatch", + "postcss", + "rolldown", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node" + ], + "bin": true + }, + "which@2.0.2": { + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": [ + "isexe" + ], + "bin": true + }, + "word-wrap@1.2.5": { + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" + }, + "yallist@3.1.1": { + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "yocto-queue@0.1.0": { + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" + }, + "zod-validation-error@4.0.2_zod@4.3.6": { + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dependencies": [ + "zod" + ] + }, + "zod@4.3.6": { + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@db/sqlite@0.13", + "jsr:@oak/oak@^17.2.0", + "jsr:@panva/jose@^6.2.1", + "jsr:@tajpouria/cors@^1.2.1" + ], + "packageJson": { + "dependencies": [ + "npm:@deno/vite-plugin@^1.0.6", + "npm:@eslint/js@^9.39.4", + "npm:@types/node@^24.12.0", + "npm:@types/react-dom@^19.2.3", + "npm:@types/react@^19.2.14", + "npm:@vitejs/plugin-react@^6.0.1", + "npm:eslint-plugin-react-hooks@^7.0.1", + "npm:eslint-plugin-react-refresh@~0.5.2", + "npm:eslint@^9.39.4", + "npm:globals@^17.4.0", + "npm:react-dom@^19.2.4", + "npm:react-router@^7.13.1", + "npm:react@^19.2.4", + "npm:typescript-eslint@^8.56.1", + "npm:typescript@~5.9.3", + "npm:vite@8" + ] + } + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..c79533d --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Dumps + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..f4bdc20 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "gerbeur", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@deno/vite-plugin": "^1.0.6", + "@types/react": "^19.2.14", + "@vitejs/plugin-react": "^6.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router": "^7.13.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react-dom": "^19.2.3", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", + "vite": "^8.0.0" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..38aae28 --- /dev/null +++ b/src/App.css @@ -0,0 +1,101 @@ +.dump-container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; +} + +.dump-description { + font-size: 1rem; + color: var(--color-text); + opacity: 0.85; + margin-top: 0.5rem; +} + +.dump-inactive-notice { + background-color: var(--color-notice-bg); + color: #fff; + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: 8px; + margin-top: 1rem; +} + +.dump-meta { + text-align: center; + margin-bottom: 2rem; +} + +.dump-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; +} + +.dump-actions { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 2rem; +} + +.dump-actions a, +.dump-actions button { + background-color: var(--color-accent); + color: #fff; + font-weight: 500; + padding: 0.75rem 1.5rem; + border-radius: 8px; + text-decoration: none; + border: none; + cursor: pointer; + transition: background-color 0.2s; +} +.dump-actions a:hover, +.dump-actions button:hover { + background-color: var(--color-accent-hover); +} + +/* Forms */ +.dump-form, +.registration-form, +.auth-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.dump-form .form-group, +.registration-form .form-group, +.auth-form .form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.dump-form input, +.dump-form textarea, +.registration-form input, +.registration-form textarea, +.auth-form input, +.auth-form textarea { + padding: 0.5rem 1rem; + border-radius: 8px; + border: 2px solid var(--color-border); + background-color: var(--color-surface); + color: var(--color-text); + font-size: 1rem; + font-family: "Saira", sans-serif; + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + line-height: 1.5; +} + +.dump-form input:disabled, +.dump-form textarea:disabled, +.registration-form input:disabled, +.registration-form textarea:disabled, +.auth-form input:disabled, +.auth-form textarea:disabled { + opacity: 0.6; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..e161314 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,70 @@ +import { BrowserRouter, Route, Routes } from "react-router"; + +import { Index } from "./pages/Index.tsx"; +import { RestrictedGuest } from "./pages/RestrictedGuest.tsx"; +import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx"; +import { Dump } from "./pages/Dump.tsx"; +import { DumpCreate } from "./pages/DumpCreate.tsx"; +import { DumpEdit } from "./pages/DumpEdit.tsx"; +import { UserLogin } from "./pages/UserLogin.tsx"; +import { UserProfile } from "./pages/UserProfile.tsx"; +import { UserRegister } from "./pages/UserRegister.tsx"; + +import { AuthProvider } from "./contexts/AuthProvider.tsx"; + +import "./App.css"; + +function App() { + return ( + + + + } /> + + + + } + /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + ); +} + +export default App; diff --git a/src/assets/hero.png b/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/src/assets/hero.png differ diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..886452f --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1,16 @@ + diff --git a/src/assets/vite.svg b/src/assets/vite.svg new file mode 100644 index 0000000..93caf61 --- /dev/null +++ b/src/assets/vite.svg @@ -0,0 +1,366 @@ + + Vite + diff --git a/src/config/api.ts b/src/config/api.ts new file mode 100644 index 0000000..96d83ba --- /dev/null +++ b/src/config/api.ts @@ -0,0 +1,9 @@ +// TypeScript triple-slash directive +// include type declarations from the package vite/client +/// + +const apiProtocol = import.meta.env.VITE_API_PROTOCOL || "http"; +const serverHost = import.meta.env.VITE_SERVER_HOST || "localhost"; +const serverPort = import.meta.env.VITE_SERVER_PORT || "8000"; + +export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`; diff --git a/src/contexts/AuthContext.ts b/src/contexts/AuthContext.ts new file mode 100644 index 0000000..2492c6d --- /dev/null +++ b/src/contexts/AuthContext.ts @@ -0,0 +1,13 @@ +import { createContext } from "react"; + +import { type AuthResponse } from "../model.ts"; + +export interface AuthContextValue { + authResponse: AuthResponse | null; + setAuthResponse: (authResponse: AuthResponse | null) => void; +} + +export const AuthContext = createContext({ + authResponse: null, + setAuthResponse: () => {}, +}); diff --git a/src/contexts/AuthProvider.tsx b/src/contexts/AuthProvider.tsx new file mode 100644 index 0000000..4b6974f --- /dev/null +++ b/src/contexts/AuthProvider.tsx @@ -0,0 +1,21 @@ +import { ReactNode, useState } from "react"; + +import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; + +import { type AuthResponse } from "../model.ts"; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [authResponse, setAuthResponse] = useState(() => { + const stored = localStorage.getItem("authResponse"); + + return stored ? JSON.parse(stored) : null; + }); + + const value: AuthContextValue = { authResponse, setAuthResponse }; + + return ( + + {children} + + ); +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..6213b8e --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,57 @@ +import { useContext } from "react"; + +import { AuthContext } from "../contexts/AuthContext.ts"; + +import { type AuthResponse } from "../model.ts"; + +export const useAuth = () => { + const { authResponse, setAuthResponse } = useContext(AuthContext); + + const login = (response: AuthResponse) => { + setAuthResponse(response); + localStorage.setItem("authResponse", JSON.stringify(response)); + }; + + const logout = () => { + setAuthResponse(null); + localStorage.removeItem("authResponse"); + }; + + const authFetch = async (input: RequestInfo, init: RequestInit = {}) => { + const token = authResponse?.token; + + const res = await fetch(input, { + ...init, + headers: { + ...(init.headers ?? {}), + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (res.status === 401) { + logout(); + } + + return res; + }; + + return { + user: authResponse?.user ?? null, + token: authResponse?.token ?? null, + login, + logout, + authFetch, + }; +}; + +export const useRequiredAuth = () => { + const { user, token, login, logout, authFetch } = useAuth(); + + if (!user) { + throw new Error( + "Invariant: useRequiredAuth called outside a protected route", + ); + } + return { user, token, login, logout, authFetch }; +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..97bd452 --- /dev/null +++ b/src/index.css @@ -0,0 +1,81 @@ +:root { + font-family: "Saira", sans-serif; + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + line-height: 1.5; + + color-scheme: light dark; + + --color-text: #f9fafb; + --color-bg: #111827; + --color-link: #646cff; + --color-link-hover: #535bf2; + --color-surface: #1f2937; + --color-border: transparent; + --color-accent: #7c83ff; + --color-accent-hover: #4a50e0; + --color-option-bg: #37366e; + --color-option-border: #111827; + --color-notice-bg: #a02b2b; +} + +@media (prefers-color-scheme: light) { + :root { + --color-text: #213547; + --color-bg: #ffffff; + --color-link-hover: #747bff; + --color-surface: #f9f9f9; + --color-option-bg: #f5f5f5; + --color-option-border: #cccccc; + --color-border: #646cff; + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + display: grid; + place-items: center; + min-height: 100vh; + background-color: var(--color-bg); + color: var(--color-text); +} + +a { + font-weight: 500; + color: var(--color-link); + text-decoration: inherit; +} +a:hover { + color: var(--color-link-hover); +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid var(--color-border); + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: var(--color-surface); + cursor: pointer; + transition: border-color 0.25s, background-color 0.2s; +} +button:hover { + border-color: var(--color-accent); +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..eff7ccc --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..fd1a711 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,120 @@ +/** + * Backend + */ + +export interface Dump { + id: string; + title: string; + description?: string; + userId: string; + createdAt: Date; +} + +/** + * Authentication + */ + +export interface User { + id: string; + username: string; + isAdmin: boolean; + createdAt: Date; +} + +export interface LoginUserRequest { + username: string; + password: string; +} + +export interface RegisterUserRequest { + username: string; + password: string; +} + +export interface UpdateUserRequest { + username?: string; + password?: string; + isAdmin?: boolean; +} + +export interface AuthResponse { + token: string; + user: User; +} + +/** + * API + */ + +export enum APIErrorCode { + BAD_REQUEST = "BAD_REQUEST", + NOT_FOUND = "NOT_FOUND", + SERVER_ERROR = "SERVER_ERROR", + TIMEOUT = "TIMEOUT", + UNAUTHORIZED = "UNAUTHORIZED", + VALIDATION_ERROR = "VALIDATION_ERROR", +} + +export interface APIError { + code: APIErrorCode; + message: string; +} + +export interface APISuccess { + success: true; + data: T; + error?: never; +} + +export interface APIFailure { + success: false; + data?: never; + error: APIError; +} + +export type APIResponse = APISuccess | APIFailure; + +/** + * Request DTOs + */ + +export interface CreateDumpRequest { + title: string; + description?: string; +} + +export interface UpdateDumpRequest { + title?: string; + description?: string; +} + +export interface LoginUserRequest { + username: string; + password: string; +} + +export interface RegisterUserRequest { + username: string; + password: string; +} + +export interface UpdateUserRequest { + username?: string; + password?: string; + isAdmin?: boolean; +} + +/** + * Frontend + */ + +export interface ActionResultSuccess { + success: true; +} + +export interface ActionResultFailure { + success: false; + error: string; +} + +export type ActionResult = ActionResultSuccess | ActionResultFailure; diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx new file mode 100644 index 0000000..de103d7 --- /dev/null +++ b/src/pages/Dump.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router"; + +import { API_URL } from "../config/api.ts"; + +import type { Dump } from "../model.ts"; + +import { useAuth } from "../hooks/useAuth.ts"; + +type DumpState = + | { status: "loading" } + | { status: "error"; error: string } + | { status: "loaded"; dump: Dump }; + +export function Dump() { + const { selectedDump } = useParams(); + + const [dumpState, setDumpState] = useState({ status: "loading" }); + + const { user } = useAuth(); + + // Fetch dump data + useEffect(() => { + if (!selectedDump) return; + + setDumpState({ status: "loading" }); + + (async () => { + try { + const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const apiResponse = await res.json(); + setDumpState({ status: "loaded", dump: apiResponse.data }); + } catch (err) { + setDumpState({ + status: "error", + error: err instanceof Error ? err.message : "Failed to load dump", + }); + } + })(); + }, [selectedDump]); + + if (dumpState.status === "loading") { + return
Loading dump...
; + } + + if (dumpState.status === "error") { + return ( +
+

Error

+

{dumpState.error}

+ +

+ ← Back to all dumps +

+
+ ); + } + + const { dump } = dumpState; + const canEdit = !!user && + (dump.userId === user.id || user.isAdmin === true); + + return ( +
+
+

{dump.title}

+ {dump.description && ( +

{dump.description}

+ )} +
+ +
+ ~ +
+ +
+ {canEdit && ( + + Edit dump + + )} + ← Back to all dumps +
+
+ ); +} diff --git a/src/pages/DumpCreate.tsx b/src/pages/DumpCreate.tsx new file mode 100644 index 0000000..412816b --- /dev/null +++ b/src/pages/DumpCreate.tsx @@ -0,0 +1,120 @@ +import { SubmitEvent, useState } from "react"; +import { Link, useNavigate } from "react-router"; + +import { API_URL } from "../config/api.ts"; + +import type { CreateDumpRequest } from "../model.ts"; + +import { useRequiredAuth } from "../hooks/useAuth.ts"; + +type DumpCreateState = + | { status: "idle" } + | { status: "submitting" } + | { status: "error"; error: string }; + +export function DumpCreate() { + const navigate = useNavigate(); + const { authFetch } = useRequiredAuth(); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [state, setState] = useState({ status: "idle" }); + + const handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + setState({ status: "error", error: "Title is required." }); + return; + } + + const body: CreateDumpRequest = { + title, + description: description || undefined, + }; + + setState({ status: "submitting" }); + + try { + const res = await authFetch(`${API_URL}/api/dumps`, { + method: "POST", + body: JSON.stringify(body), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const apiResponse = await res.json(); + + if (apiResponse.success) { + const createdDump = apiResponse.data; + navigate(`/dumps/${createdDump.id}`); + } else { + setState({ + status: "error", + error: apiResponse.error.message, + }); + } + } catch (err) { + setState({ + status: "error", + error: err instanceof Error ? err.message : "Failed to create dump.", + }); + } + }; + + return ( +
+
+

Create Dump

+
+ + {state.status === "error" && ( +
{state.error}
+ )} + +
+
+ + setTitle(e.target.value)} + disabled={state.status === "submitting"} + required + /> +
+ +
+ +