chore: initialized repository
This commit is contained in:
133
app/components/castration-tracker.tsx
Normal file
133
app/components/castration-tracker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,59 +2,36 @@ import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
import type { Route } from "./+types/home";
|
||||
import { Welcome } from "../welcome/welcome";
|
||||
import { CastrationTracker } from "../components/castration-tracker";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "New React Router App" },
|
||||
{ name: "description", content: "Welcome to React Router!" },
|
||||
{ title: "Castration Tracker" },
|
||||
{ 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) {
|
||||
const db = database();
|
||||
|
||||
const guestBook = await db.query.guestBook.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
const castrations = await db.query.castrations.findMany({
|
||||
orderBy: (castrations, { desc }) => [desc(castrations.timestamp)],
|
||||
});
|
||||
|
||||
const totalCount = castrations.length;
|
||||
|
||||
return {
|
||||
guestBook,
|
||||
castrations,
|
||||
totalCount,
|
||||
message: context.VALUE_FROM_EXPRESS,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Home({ actionData, loaderData }: Route.ComponentProps) {
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<Welcome
|
||||
guestBook={loaderData.guestBook}
|
||||
guestBookError={actionData?.guestBookError}
|
||||
message={loaderData.message}
|
||||
<CastrationTracker
|
||||
castrations={loaderData.castrations}
|
||||
totalCount={loaderData.totalCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
email: varchar({ length: 255 }).notNull().unique(),
|
||||
});
|
||||
|
||||
export const castrations = pgTable("castrations", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
gender: genderEnum("gender").notNull(),
|
||||
timestamp: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
6
drizzle/0001_heavy_havok.sql
Normal file
6
drizzle/0001_heavy_havok.sql
Normal 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
|
||||
);
|
||||
122
drizzle/meta/0001_snapshot.json
Normal file
122
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1732076135211,
|
||||
"tag": "0000_short_donald_blake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1776369457264,
|
||||
"tag": "0001_heavy_havok",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -24,7 +24,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-serve-static-core": "^5.1.1",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^20.19.39",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-serve-static-core": "^5.1.1",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20",
|
||||
"@types/node": "^20.19.39",
|
||||
"@types/pg": "^8.18.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -21,6 +21,24 @@ const client = postgres(process.env.DATABASE_URL);
|
||||
const db = drizzle(client, { schema });
|
||||
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(
|
||||
createRequestHandler({
|
||||
build: () => import("virtual:react-router/server-build"),
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.vite.json" }
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vite.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["server.js", "vite.config.ts"],
|
||||
"include": [
|
||||
"server.js",
|
||||
"vite.config.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"lib": ["ES2022"],
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler"
|
||||
|
||||
@@ -11,16 +11,29 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"strict": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["vite/client"],
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"rootDirs": [
|
||||
".",
|
||||
"./.react-router/types"
|
||||
],
|
||||
"paths": {
|
||||
"~/database/*": ["./database/*"],
|
||||
"~/*": ["./app/*"]
|
||||
"~/database/*": [
|
||||
"./database/*"
|
||||
],
|
||||
"~/*": [
|
||||
"./app/*"
|
||||
]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
|
||||
Reference in New Issue
Block a user