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 * 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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
|||||||
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,
|
"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
2
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
{
|
{
|
||||||
"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"
|
||||||
|
|||||||
@@ -11,16 +11,29 @@
|
|||||||
"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
|
||||||
|
|||||||
Reference in New Issue
Block a user