chore: initialized repository

This commit is contained in:
Lukas Wölfer
2026-04-16 22:00:50 +02:00
parent 0a74a9a7ef
commit 74c94b05f9
12 changed files with 354 additions and 53 deletions

View File

@@ -0,0 +1,133 @@
import { useState, useEffect } from "react";
import type { castrations } from "~/database/schema";
interface CastrationTrackerProps {
castrations: (typeof castrations.$inferSelect)[];
totalCount: number;
}
export function CastrationTracker({
castrations: initialCastrations,
totalCount: initialCount,
}: CastrationTrackerProps) {
const [castrations, setCastrations] = useState(initialCastrations);
const [totalCount, setTotalCount] = useState(initialCount);
const [loading, setLoading] = useState(false);
const handleCastration = async (gender: "male" | "female") => {
setLoading(true);
try {
const response = await fetch("/api/castration", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ gender }),
});
if (response.ok) {
const result = await response.json();
// Add new castration to the list
const newCastration = {
id: castrations.length + 1,
gender,
timestamp: new Date(),
};
setCastrations([newCastration, ...castrations]);
setTotalCount(totalCount + 1);
}
} catch (error) {
console.error("Failed to record castration:", error);
} finally {
setLoading(false);
}
};
const formatTime = (date: Date | string) => {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleString();
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-8">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-800 mb-2">
Castration Tracker
</h1>
<div className="bg-white rounded-lg shadow-md p-6">
<p className="text-3xl font-bold text-indigo-600">{totalCount}</p>
<p className="text-gray-600 text-lg">Total Castrations</p>
</div>
</div>
{/* Buttons */}
<div className="grid grid-cols-2 gap-6 mb-12">
<button
onClick={() => handleCastration("male")}
disabled={loading}
className="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white font-bold py-12 px-6 rounded-lg shadow-lg transition duration-200 transform hover:scale-105 active:scale-95"
>
<div className="text-6xl mb-4"></div>
<div className="text-xl">Male</div>
</button>
<button
onClick={() => handleCastration("female")}
disabled={loading}
className="bg-pink-500 hover:bg-pink-600 disabled:bg-pink-300 text-white font-bold py-12 px-6 rounded-lg shadow-lg transition duration-200 transform hover:scale-105 active:scale-95"
>
<div className="text-6xl mb-4"></div>
<div className="text-xl">Female</div>
</button>
</div>
{/* Castration List */}
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="bg-indigo-600 text-white p-4">
<h2 className="text-2xl font-bold">Recent Castrations</h2>
</div>
{castrations.length > 0 ? (
<div className="divide-y">
{castrations.map((castration) => (
<div
key={castration.id}
className="p-4 hover:bg-gray-50 transition flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div
className={`text-4xl ${
castration.gender === "male"
? "text-blue-500"
: "text-pink-500"
}`}
>
{castration.gender === "male" ? "♂" : "♀"}
</div>
<div>
<p className="font-semibold text-gray-800 capitalize">
{castration.gender}
</p>
<p className="text-gray-600 text-sm">
{formatTime(castration.timestamp)}
</p>
</div>
</div>
<div className="text-gray-400 text-sm">#{castration.id}</div>
</div>
))}
</div>
) : (
<div className="p-8 text-center text-gray-500">
<p>No castrations recorded yet. Start by clicking a button above!</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -2,59 +2,36 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import type { Route } from "./+types/home"; import type { Route } from "./+types/home";
import { Welcome } from "../welcome/welcome"; import { CastrationTracker } from "../components/castration-tracker";
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [ return [
{ title: "New React Router App" }, { title: "Castration Tracker" },
{ name: "description", content: "Welcome to React Router!" }, { name: "description", content: "Track animal castrations" },
]; ];
} }
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
let name = formData.get("name");
let email = formData.get("email");
if (typeof name !== "string" || typeof email !== "string") {
return { guestBookError: "Name and email are required" };
}
name = name.trim();
email = email.trim();
if (!name || !email) {
return { guestBookError: "Name and email are required" };
}
const db = database();
try {
await db.insert(schema.guestBook).values({ name, email });
} catch (error) {
return { guestBookError: "Error adding to guest book" };
}
}
export async function loader({ context }: Route.LoaderArgs) { export async function loader({ context }: Route.LoaderArgs) {
const db = database(); const db = database();
const guestBook = await db.query.guestBook.findMany({ const castrations = await db.query.castrations.findMany({
columns: { orderBy: (castrations, { desc }) => [desc(castrations.timestamp)],
id: true,
name: true,
},
}); });
const totalCount = castrations.length;
return { return {
guestBook, castrations,
totalCount,
message: context.VALUE_FROM_EXPRESS, message: context.VALUE_FROM_EXPRESS,
}; };
} }
export default function Home({ actionData, loaderData }: Route.ComponentProps) { export default function Home({ loaderData }: Route.ComponentProps) {
return ( return (
<Welcome <CastrationTracker
guestBook={loaderData.guestBook} castrations={loaderData.castrations}
guestBookError={actionData?.guestBookError} totalCount={loaderData.totalCount}
message={loaderData.message}
/> />
); );
} }

View File

@@ -1,7 +1,21 @@
import { integer, pgTable, varchar } from "drizzle-orm/pg-core"; import {
integer,
pgTable,
timestamp,
pgEnum,
varchar,
} from "drizzle-orm/pg-core";
export const genderEnum = pgEnum("gender", ["male", "female"]);
export const guestBook = pgTable("guestBook", { export const guestBook = pgTable("guestBook", {
id: integer().primaryKey().generatedAlwaysAsIdentity(), id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(), name: varchar({ length: 255 }).notNull(),
email: varchar({ length: 255 }).notNull().unique(), email: varchar({ length: 255 }).notNull().unique(),
}); });
export const castrations = pgTable("castrations", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
gender: genderEnum("gender").notNull(),
timestamp: timestamp().notNull().defaultNow(),
});

View File

@@ -0,0 +1,6 @@
CREATE TYPE "public"."gender" AS ENUM('male', 'female');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "castrations" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "castrations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"gender" "gender" NOT NULL,
"timestamp" timestamp DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,122 @@
{
"id": "a9835a0d-a647-43bb-b34a-f049e710ecf1",
"prevId": "6bf145c1-851c-4a50-a085-9306f05abb25",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.castrations": {
"name": "castrations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "castrations_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"gender": {
"name": "gender",
"type": "gender",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"timestamp": {
"name": "timestamp",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.guestBook": {
"name": "guestBook",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "guestBook_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"guestBook_email_unique": {
"name": "guestBook_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.gender": {
"name": "gender",
"schema": "public",
"values": [
"male",
"female"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1732076135211, "when": 1732076135211,
"tag": "0000_short_donald_blake", "tag": "0000_short_donald_blake",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1776369457264,
"tag": "0001_heavy_havok",
"breakpoints": true
} }
] ]
} }

2
package-lock.json generated
View File

@@ -24,7 +24,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/express-serve-static-core": "^5.1.1", "@types/express-serve-static-core": "^5.1.1",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/node": "^20", "@types/node": "^20.19.39",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View File

@@ -30,7 +30,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/express-serve-static-core": "^5.1.1", "@types/express-serve-static-core": "^5.1.1",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/node": "^20", "@types/node": "^20.19.39",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -41,4 +41,4 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^8.0.3" "vite": "^8.0.3"
} }
} }

View File

@@ -21,6 +21,24 @@ const client = postgres(process.env.DATABASE_URL);
const db = drizzle(client, { schema }); const db = drizzle(client, { schema });
app.use((_, __, next) => DatabaseContext.run(db, next)); app.use((_, __, next) => DatabaseContext.run(db, next));
// API endpoint to record castration
app.post("/api/castration", express.json(), async (req, res) => {
try {
const { gender } = req.body;
if (!gender || !["male", "female"].includes(gender)) {
return res.status(400).json({ error: "Invalid gender" });
}
const result = await db.insert(schema.castrations).values({ gender });
return res.json({ success: true, result });
} catch (error) {
console.error("Error recording castration:", error);
return res.status(500).json({ error: "Failed to record castration" });
}
});
app.use( app.use(
createRequestHandler({ createRequestHandler({
build: () => import("virtual:react-router/server-build"), build: () => import("virtual:react-router/server-build"),

View File

@@ -1,8 +1,12 @@
{ {
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.node.json" }, {
{ "path": "./tsconfig.vite.json" } "path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.vite.json"
}
], ],
"compilerOptions": { "compilerOptions": {
"checkJs": true, "checkJs": true,
@@ -11,4 +15,4 @@
"strict": true, "strict": true,
"noEmit": true "noEmit": true
} }
} }

View File

@@ -1,13 +1,20 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"include": ["server.js", "vite.config.ts"], "include": [
"server.js",
"vite.config.ts"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"strict": true, "strict": true,
"types": ["node"], "types": [
"lib": ["ES2022"], "node"
],
"lib": [
"ES2022"
],
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }
} }

View File

@@ -11,18 +11,31 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"strict": true, "strict": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": [
"types": ["vite/client"], "DOM",
"DOM.Iterable",
"ES2022"
],
"types": [
"vite/client"
],
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "react-jsx", "jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"], "rootDirs": [
".",
"./.react-router/types"
],
"paths": { "paths": {
"~/database/*": ["./database/*"], "~/database/*": [
"~/*": ["./app/*"] "./database/*"
],
"~/*": [
"./app/*"
]
}, },
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true "resolveJsonModule": true
} }
} }