initial commit, boilerplate stuff

This commit is contained in:
khannurien
2026-03-15 17:15:46 +00:00
commit 6207a7549f
52 changed files with 4400 additions and 0 deletions

3
.devcontainer/Dockerfile Normal file
View File

@@ -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

View File

@@ -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"
}

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
VITE_API_PROTOCOL=http
VITE_WS_PROTOCOL=ws
VITE_SERVER_HOST=localhost
VITE_SERVER_PORT=8000

169
.gitignore vendored Normal file
View File

@@ -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?

21
LICENSE Normal file
View File

@@ -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.

73
README.md Normal file
View File

@@ -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...
},
},
])
```

3
api/.env.example Normal file
View File

@@ -0,0 +1,3 @@
PROTOCOL=http
HOSTNAME=localhost
PORT=8000

4
api/config.ts Normal file
View File

@@ -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}`;

56
api/lib/jwt.ts Normal file
View File

@@ -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<AuthPayload, "exp">,
): Promise<string> {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.sign(JWT_KEY);
}
export async function verifyJWT(token: string): Promise<AuthPayload | null> {
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<string> {
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<boolean> {
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"));
});
});
}

16
api/lib/static.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Context, Next } from "@oak/oak";
export default function routeStaticFilesFrom(staticPaths: string[]) {
return async (context: Context<Record<string, object>>, next: Next) => {
for (const path of staticPaths) {
try {
await context.send({ root: path, index: "index.html" });
return;
} catch {
continue;
}
}
await next();
};
}

42
api/main.ts Normal file
View File

@@ -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 };

50
api/middleware/auth.ts Normal file
View File

@@ -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();
}

37
api/middleware/error.ts Normal file
View File

@@ -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;
}
}
}

97
api/model/db.ts Normal file
View File

@@ -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<string, SQLOutputValue>): 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<string, SQLOutputValue>): 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(),
};
}

154
api/model/interfaces.ts Normal file
View File

@@ -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<T> {
success: true;
data: T;
error?: never;
}
export interface APIFailure {
success: false;
data?: never;
error: APIError;
}
export type APIResponse<T> = APISuccess<T> | 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));
}

130
api/routes/dumps.ts Normal file
View File

@@ -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<Dump> = {
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<Dump> = {
success: true,
data: dump,
};
ctx.response.body = responseBody;
});
router.get("/", (ctx) => {
const dumps = listDumps();
const responseBody: APIResponse<Dump[]> = {
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<Dump> = {
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<null> = {
success: true,
data: null,
};
ctx.response.body = responseBody;
});
export default router;

132
api/routes/users.ts Normal file
View File

@@ -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;

View File

@@ -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");
}
}

View File

@@ -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<User> {
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<User> {
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");
}
}

18
api/sql/schema.sql Normal file
View File

@@ -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
);

29
deno.json Normal file
View File

@@ -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"
}
}

1360
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
eslint.config.js Normal file
View File

@@ -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,
},
},
])

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dumps</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
package.json Normal file
View File

@@ -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"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

101
src/App.css Normal file
View File

@@ -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;
}

70
src/App.tsx Normal file
View File

@@ -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 (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route
path="/dumps/new"
element={
<RestrictedLoggedIn>
<DumpCreate />
</RestrictedLoggedIn>
}
/>
<Route path="/dumps/:selectedDump" element={<Dump />} />
<Route
path="/dumps/:selectedDump/edit"
element={
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>
}
/>
<Route
path="/register"
element={
<RestrictedGuest>
<UserRegister />
</RestrictedGuest>
}
/>
<Route
path="/login"
element={
<RestrictedGuest>
<UserLogin />
</RestrictedGuest>
}
/>
<Route
path="/profile"
element={
<RestrictedLoggedIn>
<UserProfile />
</RestrictedLoggedIn>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
export default App;

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

16
src/assets/react.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--logos"
width="35.93"
height="32"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 256 228"
>
<path
fill="#00D8FF"
d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

366
src/assets/vite.svg Normal file
View File

@@ -0,0 +1,366 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="77"
height="47"
fill="none"
aria-labelledby="vite-logo-title"
viewBox="0 0 77 47"
>
<title id="vite-logo-title">Vite</title><style>
.parenthesis {
fill: #000;
}
@media (prefers-color-scheme: dark) {
.parenthesis {
fill: #fff;
}
}
</style><path
fill="#9135ff"
d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"
/><mask
id="a"
width="48"
height="47"
x="14"
y="0"
maskUnits="userSpaceOnUse"
style="mask-type: alpha"
><path
fill="#000"
d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"
/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse
cx="5.508"
cy="14.704"
fill="#eee6ff"
rx="5.508"
ry="14.704"
transform="rotate(269.814 20.96 11.29)scale(-1 1)"
/></g><g filter="url(#c)"><ellipse
cx="10.399"
cy="29.851"
fill="#eee6ff"
rx="10.399"
ry="29.851"
transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"
/></g><g filter="url(#d)"><ellipse
cx="5.508"
cy="30.487"
fill="#8900ff"
rx="5.508"
ry="30.487"
transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"
/></g><g filter="url(#e)"><ellipse
cx="5.508"
cy="30.599"
fill="#8900ff"
rx="5.508"
ry="30.599"
transform="rotate(89.814 -25.928 4.177)scale(1 -1)"
/></g><g filter="url(#f)"><ellipse
cx="5.508"
cy="30.599"
fill="#8900ff"
rx="5.508"
ry="30.599"
transform="rotate(89.814 -25.738 5.52)scale(1 -1)"
/></g><g filter="url(#g)"><ellipse
cx="14.072"
cy="22.078"
fill="#eee6ff"
rx="14.072"
ry="22.078"
transform="rotate(93.35 31.245 55.578)scale(-1 1)"
/></g><g filter="url(#h)"><ellipse
cx="3.47"
cy="21.501"
fill="#8900ff"
rx="3.47"
ry="21.501"
transform="rotate(89.009 35.419 55.202)scale(-1 1)"
/></g><g filter="url(#i)"><ellipse
cx="3.47"
cy="21.501"
fill="#8900ff"
rx="3.47"
ry="21.501"
transform="rotate(89.009 35.419 55.202)scale(-1 1)"
/></g><g filter="url(#j)"><ellipse
cx="14.592"
cy="9.743"
fill="#8900ff"
rx="4.407"
ry="29.108"
transform="rotate(39.51 14.592 9.743)"
/></g><g filter="url(#k)"><ellipse
cx="61.728"
cy="-5.321"
fill="#8900ff"
rx="4.407"
ry="29.108"
transform="rotate(37.892 61.728 -5.32)"
/></g><g filter="url(#l)"><ellipse
cx="55.618"
cy="7.104"
fill="#00c2ff"
rx="5.971"
ry="9.665"
transform="rotate(37.892 55.618 7.104)"
/></g><g filter="url(#m)"><ellipse
cx="12.326"
cy="39.103"
fill="#8900ff"
rx="4.407"
ry="29.108"
transform="rotate(37.892 12.326 39.103)"
/></g><g filter="url(#n)"><ellipse
cx="12.326"
cy="39.103"
fill="#8900ff"
rx="4.407"
ry="29.108"
transform="rotate(37.892 12.326 39.103)"
/></g><g filter="url(#o)"><ellipse
cx="49.857"
cy="30.678"
fill="#8900ff"
rx="4.407"
ry="29.108"
transform="rotate(37.892 49.857 30.678)"
/></g><g filter="url(#p)"><ellipse
cx="52.623"
cy="33.171"
fill="#00c2ff"
rx="5.971"
ry="15.297"
transform="rotate(37.892 52.623 33.17)"
/></g></g><path
d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789"
class="parenthesis"
/><defs><filter
id="b"
width="60.045"
height="41.654"
x="-5.564"
y="16.92"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="7.659"
/></filter><filter
id="c"
width="90.34"
height="51.437"
x="-40.407"
y="-6.762"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="7.659"
/></filter><filter
id="d"
width="79.355"
height="29.4"
x="-35.435"
y="2.801"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="e"
width="79.579"
height="29.4"
x="-30.84"
y="20.8"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="f"
width="79.579"
height="29.4"
x="-29.307"
y="21.949"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="g"
width="74.749"
height="58.852"
x="29.961"
y="-17.13"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="7.659"
/></filter><filter
id="h"
width="61.377"
height="25.362"
x="37.754"
y="3.055"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="i"
width="61.377"
height="25.362"
x="37.754"
y="3.055"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="j"
width="56.045"
height="63.649"
x="-13.43"
y="-22.082"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="k"
width="54.814"
height="64.646"
x="34.321"
y="-37.644"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="l"
width="33.541"
height="35.313"
x="38.847"
y="-10.552"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="m"
width="54.814"
height="64.646"
x="-15.081"
y="6.78"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="n"
width="54.814"
height="64.646"
x="-15.081"
y="6.78"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="o"
width="54.814"
height="64.646"
x="22.45"
y="-1.645"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter><filter
id="p"
width="39.409"
height="43.623"
x="32.919"
y="11.36"
color-interpolation-filters="sRGB"
filterUnits="userSpaceOnUse"
><feFlood flood-opacity="0" result="BackgroundImageFix" /><feBlend
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
/><feGaussianBlur
result="effect1_foregroundBlur_2002_17286"
stdDeviation="4.596"
/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

9
src/config/api.ts Normal file
View File

@@ -0,0 +1,9 @@
// TypeScript triple-slash directive
// include type declarations from the package vite/client
/// <reference types="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}`;

View File

@@ -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<AuthContextValue>({
authResponse: null,
setAuthResponse: () => {},
});

View File

@@ -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<AuthResponse | null>(() => {
const stored = localStorage.getItem("authResponse");
return stored ? JSON.parse(stored) : null;
});
const value: AuthContextValue = { authResponse, setAuthResponse };
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}

57
src/hooks/useAuth.ts Normal file
View File

@@ -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 };
};

81
src/index.css Normal file
View File

@@ -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;
}

10
src/main.tsx Normal file
View File

@@ -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(
<StrictMode>
<App />
</StrictMode>,
);

120
src/model.ts Normal file
View File

@@ -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<T> {
success: true;
data: T;
error?: never;
}
export interface APIFailure {
success: false;
data?: never;
error: APIError;
}
export type APIResponse<T> = APISuccess<T> | 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;

92
src/pages/Dump.tsx Normal file
View File

@@ -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<DumpState>({ 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 <div className="loading">Loading dump...</div>;
}
if (dumpState.status === "error") {
return (
<div className="error-container">
<h2>Error</h2>
<p>{dumpState.error}</p>
<button type="button" onClick={() => globalThis.location.reload()}>
Retry
</button>
<p>
<Link to="/"> Back to all dumps</Link>
</p>
</div>
);
}
const { dump } = dumpState;
const canEdit = !!user &&
(dump.userId === user.id || user.isAdmin === true);
return (
<div className="dump-container">
<div className="dump-meta">
<h1>{dump.title}</h1>
{dump.description && (
<p className="dump-description">{dump.description}</p>
)}
</div>
<div className="dump-grid">
~
</div>
<div className="dump-actions">
{canEdit && (
<Link to={`/dumps/${dump.id}/edit`}>
Edit dump
</Link>
)}
<Link to="/"> Back to all dumps</Link>
</div>
</div>
);
}

120
src/pages/DumpCreate.tsx Normal file
View File

@@ -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<DumpCreateState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
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 (
<div className="dump-container">
<div className="dump-meta">
<h1>Create Dump</h1>
</div>
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
)}
<form onSubmit={handleSubmit} className="dump-form">
<div className="form-group">
<label htmlFor="title">
<strong>Title</strong>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={state.status === "submitting"}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">
<strong>Description (optional)</strong>
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={state.status === "submitting"}
rows={3}
/>
</div>
<div className="dump-actions">
<button
type="submit"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Creating..." : "Create dump"}
</button>
<Link to="/">Cancel</Link>
</div>
</form>
</div>
);
}

169
src/pages/DumpEdit.tsx Normal file
View File

@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, UpdateDumpRequest } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
type DumpEditState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dump: Dump };
export function DumpEdit() {
const { selectedDump } = useParams();
const navigate = useNavigate();
const { authFetch } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
if (!selectedDump) return;
setState({ 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();
if (apiResponse.success) {
const dump: Dump = apiResponse.data;
setTitle(dump.title);
setDescription(dump.description ?? "");
setState({ status: "loaded", dump });
} else {
setState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Load failed",
});
}
})();
}, [selectedDump]);
const handleSave = async () => {
if (state.status !== "loaded") return;
const body: UpdateDumpRequest = {
title,
description: description || undefined,
};
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
});
if (!res.ok) {
setState({
status: "error",
error: `Update failed (${res.status})`,
});
return;
}
navigate(`/dumps/${state.dump.id}`);
};
const handleDelete = async () => {
if (state.status !== "loaded") return;
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "DELETE",
});
if (!res.ok) {
setState({
status: "error",
error: `Delete failed (${res.status})`,
});
return;
}
navigate("/");
};
if (state.status === "loading") {
return <div className="loading">Loading dump...</div>;
}
if (state.status === "error") {
return (
<div className="error-container">
<h2>Error</h2>
<p>{state.error}</p>
<button type="button" onClick={() => globalThis.location.reload()}>
Retry
</button>
<p>
<Link to="/"> Back to all dumps</Link>
</p>
</div>
);
}
return (
<div className="dump-container">
<div className="dump-meta">
<h1>Edit Dump</h1>
</div>
<form
className="dump-form"
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div className="form-group">
<label htmlFor="title">
<strong>Title</strong>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">
<strong>Description (optional)</strong>
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
rows={3}
/>
</div>
<div className="dump-actions">
<button type="submit">Save</button>
<button
type="button"
onClick={handleDelete}
style={{ backgroundColor: "#a02b2b" }}
>
Delete
</button>
<Link to={`/dumps/${state.dump.id}`}>Cancel</Link>
</div>
</form>
</div>
);
}

122
src/pages/Index.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { type Dump } from "../model.ts";
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dumps: Dump[] };
export function Index() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleCreateDump = () => {
navigate("/dumps/new");
};
const handleRegister = () => {
navigate("/register");
};
const handleLogin = () => {
navigate("/login");
};
const handleLogout = () => {
logout();
navigate("/", { replace: true });
};
const [dumpsState, setDumpsState] = useState<DumpsState>({
status: "loading",
});
useEffect(() => {
(async () => {
try {
const response = await fetch(`${API_URL}/api/dumps/`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const apiResponse = await response.json();
setDumpsState({ status: "loaded", dumps: apiResponse.data });
} catch (err) {
setDumpsState({
status: "error",
error: err instanceof Error ? err.message : "Failed to load dumps",
});
}
})();
}, []);
if (dumpsState.status === "loading") {
return (
<main id="content">
<div className="loading">Loading dumps...</div>
</main>
);
}
if (dumpsState.status === "error") {
return (
<main id="content">
<div className="error-container">
<h2>Error</h2>
<p>{dumpsState.error}</p>
<button type="button" onClick={() => globalThis.location.reload()}>
Retry
</button>
</div>
</main>
);
}
const { dumps } = dumpsState;
return (
<main id="content">
<h1>🚚 Dumps</h1>
<p>Welcome, {user?.username ?? "guest"}!</p>
{user &&
<button type="button" onClick={handleCreateDump}>New dump</button>}
<p>Click on a dump below to participate.</p>
{dumps.length === 0
? <p className="empty-state">No dumps available yet.</p>
: (
<ul>
{dumps.map((dump) => (
<li key={dump.id}>
<Link to={`/dumps/${dump.id}`} className="dump">
{dump.title}
</Link>
</li>
))}
</ul>
)}
{user
? (
<form>
<button type="button" onClick={handleLogout}>Logout</button>
</form>
)
: (
<form>
<button type="button" onClick={handleRegister}>Register</button>
<button type="button" onClick={handleLogin}>Log in</button>
</form>
)}
</main>
);
}

View File

@@ -0,0 +1,9 @@
import { Navigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
export function RestrictedGuest({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
return user ? <Navigate to="/" /> : <>{children}</>;
}

View File

@@ -0,0 +1,11 @@
import { Navigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
export function RestrictedLoggedIn(
{ children }: { children: React.ReactNode },
) {
const { user } = useAuth();
return user ? <>{children}</> : <Navigate to="/login" />;
}

88
src/pages/UserLogin.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { SubmitEvent, useState } from "react";
import { useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
type UserLoginState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
export function UserLogin() {
const navigate = useNavigate();
const { login } = useAuth();
const [state, setState] = useState<UserLoginState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
try {
const res = await fetch(`${API_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const apiResponse = await res.json();
if (apiResponse.success) {
login(apiResponse.data);
navigate("/");
} else {
setState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Login failed.",
});
}
};
return (
<div className="auth-container">
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
)}
<form onSubmit={handleSubmit} className="auth-form">
<input
name="username"
type="text"
placeholder="Username"
required
disabled={state.status === "submitting"}
/>
<input
name="password"
type="password"
placeholder="Password"
required
disabled={state.status === "submitting"}
/>
<button
type="submit"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Logging in..." : "Login"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { useRequiredAuth } from "../hooks/useAuth.ts";
export function UserProfile() {
const { user } = useRequiredAuth();
return (
`Hello, ${user.username}!`
);
}

View File

@@ -0,0 +1,88 @@
import { SubmitEvent, useState } from "react";
import { useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
type UserRegisterState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
export function UserRegister() {
const navigate = useNavigate();
const { login } = useAuth();
const [state, setState] = useState<UserRegisterState>({ status: "idle" });
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
try {
const res = await fetch(`${API_URL}/api/users/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const apiResponse = await res.json();
if (apiResponse.success) {
login(apiResponse.data);
navigate("/");
} else {
setState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Registration failed.",
});
}
};
return (
<div className="registration-container">
{state.status === "error" && (
<div className="error-banner">{state.error}</div>
)}
<form onSubmit={handleSubmit} className="registration-form">
<input
name="username"
type="text"
placeholder="Username"
required
disabled={state.status === "submitting"}
/>
<input
name="password"
type="password"
placeholder="Password"
required
disabled={state.status === "submitting"}
/>
<button
type="submit"
disabled={state.status === "submitting"}
>
{state.status === "submitting" ? "Registering..." : "Register"}
</button>
</form>
</div>
);
}

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

12
vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
server: {
port: 3000,
},
plugins: [react()],
optimizeDeps: {
include: ["react/jsx-runtime"],
},
});