Compare commits

..

No commits in common. "f61fd266623f0d295eb659217787a4d56657e073" and "940e492359a2c739022d9a5a2ef9bb2595fd9f67" have entirely different histories.

25 changed files with 0 additions and 4722 deletions

View File

@ -1,130 +1,3 @@
# EmberClone — Generation Log
Schritt-für-Schritt-Historie aller Gemma-Code-Generierungen.
## EmberClone Codegen-Run gestartet (2026-05-23 04:24:46)
- `04:24:46` **INFO** Specs: 18 Files zu generieren
- `04:24:46` **INFO** vLLM: http://127.0.0.1:8000/v1/chat/completions, Model: gemma-4-31b
- `04:24:46` **INFO** Pinging Gemma …
- `04:24:46` **INFO** Gemma pong ok: 'pong'
## Generiere packages/shared/src/schemas.ts (2026-05-23 04:24:46)
- `04:24:46` **INFO** Attempt 1/3 für packages/shared/src/schemas.ts
- `04:25:03` **INFO** wrote 1956 chars in 16.8s
- `04:25:03` **INFO** syntax check ok
## Generiere packages/shared/src/index.ts (2026-05-23 04:25:03)
- `04:25:03` **INFO** Attempt 1/3 für packages/shared/src/index.ts
- `04:25:03` **INFO** wrote 25 chars in 0.3s
- `04:25:03` **WARN** syntax check failed: too short (25 chars)
- `04:25:03` **INFO** Attempt 2/3 für packages/shared/src/index.ts
- `04:25:03` **INFO** wrote 25 chars in 0.2s
- `04:25:03` **WARN** syntax check failed: too short (25 chars)
- `04:25:03` **INFO** Attempt 3/3 für packages/shared/src/index.ts
- `04:25:04` **INFO** wrote 25 chars in 0.2s
- `04:25:04` **WARN** syntax check failed: too short (25 chars)
- `04:25:04` **ERROR** GAVE UP after 3 attempts: too short (25 chars)
## Generiere apps/api/src/db/schema.ts (2026-05-23 04:25:04)
- `04:25:04` **INFO** Attempt 1/3 für apps/api/src/db/schema.ts
- `04:25:16` **INFO** wrote 1440 chars in 12.2s
- `04:25:16` **INFO** syntax check ok
## Generiere apps/api/src/db/index.ts (2026-05-23 04:25:16)
- `04:25:16` **INFO** Attempt 1/3 für apps/api/src/db/index.ts
- `04:25:19` **INFO** wrote 328 chars in 2.8s
- `04:25:19` **INFO** syntax check ok
## Generiere apps/api/src/db/migrate.ts (2026-05-23 04:25:19)
- `04:25:19` **INFO** Attempt 1/3 für apps/api/src/db/migrate.ts
- `04:25:28` **INFO** wrote 1105 chars in 9.4s
- `04:25:28` **INFO** syntax check ok
## Generiere apps/api/src/routes/auth.ts (2026-05-23 04:25:28)
- `04:25:28` **INFO** Attempt 1/3 für apps/api/src/routes/auth.ts
- `04:25:45` **INFO** wrote 1852 chars in 17.2s
- `04:25:45` **INFO** syntax check ok
## Generiere apps/api/src/routes/time-entries.ts (2026-05-23 04:25:45)
- `04:25:45` **INFO** Attempt 1/3 für apps/api/src/routes/time-entries.ts
- `04:26:21` **INFO** wrote 3875 chars in 35.8s
- `04:26:21` **INFO** syntax check ok
## Generiere apps/api/src/routes/index.ts (2026-05-23 04:26:21)
- `04:26:21` **INFO** Attempt 1/3 für apps/api/src/routes/index.ts
- `04:26:24` **INFO** wrote 318 chars in 3.0s
- `04:26:24` **INFO** syntax check ok
## Generiere apps/api/src/index.ts (2026-05-23 04:26:24)
- `04:26:24` **INFO** Attempt 1/3 für apps/api/src/index.ts
- `04:26:32` **INFO** wrote 806 chars in 8.0s
- `04:26:32` **INFO** syntax check ok
## Generiere apps/web/src/main.tsx (2026-05-23 04:26:32)
- `04:26:32` **INFO** Attempt 1/3 für apps/web/src/main.tsx
- `04:26:39` **INFO** wrote 855 chars in 7.1s
- `04:26:39` **INFO** syntax check ok
## Generiere apps/web/src/lib/api.ts (2026-05-23 04:26:39)
- `04:26:39` **INFO** Attempt 1/3 für apps/web/src/lib/api.ts
- `04:26:54` **INFO** wrote 1625 chars in 14.2s
- `04:26:54` **INFO** syntax check ok
## Generiere apps/web/src/pages/Login.tsx (2026-05-23 04:26:54)
- `04:26:54` **INFO** Attempt 1/3 für apps/web/src/pages/Login.tsx
- `04:27:17` **INFO** wrote 2773 chars in 23.3s
- `04:27:17` **INFO** syntax check ok
## Generiere apps/web/src/pages/Dashboard.tsx (2026-05-23 04:27:17)
- `04:27:17` **INFO** Attempt 1/3 für apps/web/src/pages/Dashboard.tsx
- `04:27:37` **INFO** wrote 2229 chars in 20.1s
- `04:27:37` **INFO** syntax check ok
## Generiere apps/web/src/pages/TimeEntries.tsx (2026-05-23 04:27:37)
- `04:27:37` **INFO** Attempt 1/3 für apps/web/src/pages/TimeEntries.tsx
- `04:28:26` **INFO** wrote 6015 chars in 48.7s
- `04:28:26` **INFO** syntax check ok
## Generiere apps/web/src/App.tsx (2026-05-23 04:28:26)
- `04:28:26` **INFO** Attempt 1/3 für apps/web/src/App.tsx
- `04:28:39` **INFO** wrote 1466 chars in 13.6s
- `04:28:39` **INFO** syntax check ok
## Generiere apps/web/src/index.css (2026-05-23 04:28:39)
- `04:28:39` **INFO** Attempt 1/3 für apps/web/src/index.css
- `04:28:41` **INFO** wrote 149 chars in 1.6s
- `04:28:41` **INFO** syntax check ok
## Generiere apps/web/postcss.config.cjs (2026-05-23 04:28:41)
- `04:28:41` **INFO** Attempt 1/3 für apps/web/postcss.config.cjs
- `04:28:42` **INFO** wrote 81 chars in 0.8s
- `04:28:42` **INFO** syntax check ok
## Generiere apps/web/tailwind.config.ts (2026-05-23 04:28:42)
- `04:28:42` **INFO** Attempt 1/3 für apps/web/tailwind.config.ts
- `04:28:45` **INFO** wrote 294 chars in 3.2s
- `04:28:45` **INFO** syntax check ok
## Codegen-Run beendet (2026-05-23 04:28:45)
- `04:28:45` **INFO** ok: 17/18, fail: 1/18
- `04:28:45` **WARN** 1 Files mit final-Fehler. Manuelle Inspektion nötig.

View File

@ -1,52 +0,0 @@
CREATE TABLE IF NOT EXISTS "customers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"customer_id" uuid NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "time_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"project_id" uuid,
"description" text NOT NULL,
"start_time" timestamp NOT NULL,
"end_time" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"name" text NOT NULL,
"role" text NOT NULL,
"password_hash" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "projects" ADD CONSTRAINT "projects_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@ -1,262 +0,0 @@
{
"id": "cf31898f-9a96-4dfd-ac9e-23326daf55fb",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.customers": {
"name": "customers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"projects_customer_id_customers_id_fk": {
"name": "projects_customer_id_customers_id_fk",
"tableFrom": "projects",
"tableTo": "customers",
"columnsFrom": [
"customer_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.time_entries": {
"name": "time_entries",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"start_time": {
"name": "start_time",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_time": {
"name": "end_time",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"time_entries_user_id_users_id_fk": {
"name": "time_entries_user_id_users_id_fk",
"tableFrom": "time_entries",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"time_entries_project_id_projects_id_fk": {
"name": "time_entries_project_id_projects_id_fk",
"tableFrom": "time_entries",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779503479059,
"tag": "0000_empty_talon",
"breakpoints": true
}
]
}

View File

@ -12,7 +12,6 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@emberclone/shared": "workspace:*",
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.1",

View File

@ -1,11 +0,0 @@
import { drizzle } from "drizzle-orm/node-postgres"
import { Pool } from "pg"
import * as schema from "./schema"
const connectionString = process.env.DATABASE_URL || "postgresql://emberclone:emberclone@localhost:5433/emberclone"
export const pool = new Pool({
connectionString
})
export const db = drizzle(pool, { schema })

View File

@ -1,39 +0,0 @@
import { migrate } from "drizzle-orm/node-postgres/migrator"
import { db } from "./index"
import { users } from "./schema"
import * as argon2 from "argon2"
async function runMigration() {
try {
console.log("Running migrations...")
await migrate(db, { migrationsFolder: "./drizzle" })
console.log("Migrations completed successfully")
console.log("Checking for admin user...")
const existingAdmin = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, "admin@emberclone.local")
})
if (!existingAdmin) {
console.log("Creating default admin user...")
const passwordHash = await argon2.hash("emberclone2026")
await db.insert(users).values({
email: "admin@emberclone.local",
passwordHash,
name: "Admin",
role: "admin"
})
console.log("Default admin user created: admin@emberclone.local")
} else {
console.log("Admin user already exists")
}
process.exit(0)
} catch (error) {
console.error("Migration failed:", error)
process.exit(1)
}
}
runMigration()

View File

@ -1,35 +0,0 @@
import { pgTable, text, timestamp, boolean, uuid } from "drizzle-orm/pg-core"
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
role: text("role").notNull(),
passwordHash: text("password_hash").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow()
})
export const customers = pgTable("customers", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow()
})
export const projects = pgTable("projects", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
customerId: uuid("customer_id").notNull().references(() => customers.id, { onDelete: "cascade" }),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow()
})
export const timeEntries = pgTable("time_entries", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
description: text("description").notNull(),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time"),
createdAt: timestamp("created_at").notNull().defaultNow()
})

View File

@ -1,40 +0,0 @@
import Fastify from "fastify"
import cors from "@fastify/cors"
import cookie from "@fastify/cookie"
import jwt from "@fastify/jwt"
import { setupRoutes } from "./routes/index"
const server = Fastify({
logger: true
})
const PORT = Number(process.env.PORT) || 4001
const HOST = "0.0.0.0"
async function start() {
await server.register(cors, {
origin: "http://localhost:5174",
credentials: true
})
await server.register(cookie)
await server.register(jwt, {
secret: process.env.JWT_SECRET || "dev-secret-change-me"
})
server.get("/health", async () => {
return { status: "ok" }
})
await setupRoutes(server)
try {
await server.listen({ port: PORT, host: HOST })
} catch (err) {
server.log.error(err)
process.exit(1)
}
}
start()

View File

@ -1,77 +0,0 @@
import { FastifyPluginAsync } from "fastify"
import argon2 from "argon2"
import { db } from "../db"
import { users } from "../db/schema"
import { eq } from "drizzle-orm"
import { LoginRequestSchema } from "@emberclone/shared"
export default async function authRoutes(fastify: FastifyPluginAsync) {
fastify.post("/login", async (request, reply) => {
const body = LoginRequestSchema.parse(request.body)
const [user] = await db
.select()
.from(users)
.where(eq(users.email, body.email))
.limit(1)
if (!user || !(await argon2.verify(user.passwordHash, body.password))) {
return reply.code(401).send({ message: "Invalid credentials" })
}
const token = fastify.jwt.sign({
sub: user.id,
role: user.role
})
reply
.setCookie("token", token, {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax"
})
.send({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
})
})
fastify.get("/me", async (request, reply) => {
try {
await request.jwtVerify()
const { sub } = request.user as { sub: string }
const [user] = await db
.select()
.from(users)
.where(eq(users.id, sub))
.limit(1)
if (!user) {
return reply.code(404).send({ message: "User not found" })
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
} catch (err) {
return reply.code(401).send({ message: "Unauthorized" })
}
})
fastify.post("/logout", async (request, reply) => {
reply
.clearCookie("token", {
path: "/"
})
.send({ message: "Logged out" })
})
}

View File

@ -1,13 +0,0 @@
import { FastifyInstance } from "fastify"
import authRoutes from "./auth"
import timeEntryRoutes from "./time-entries"
export async function setupRoutes(server: FastifyInstance) {
server.register(authRoutes, {
prefix: "/api/auth"
})
server.register(timeEntryRoutes, {
prefix: "/api/time-entries"
})
}

View File

@ -1,147 +0,0 @@
import { FastifyPluginAsync } from "fastify"
import { db } from "../db"
import { timeEntries } from "../db/schema"
import { eq, and, gte, lte, or } from "drizzle-orm"
import { z } from "zod"
const TimeEntrySchema = z.object({
projectId: z.string().uuid().optional(),
description: z.string().min(1),
startTime: z.string().datetime(),
endTime: z.string().datetime().optional()
})
const TimeEntryUpdateSchema = TimeEntrySchema.partial()
export default async function timeEntryRoutes(fastify: FastifyPluginAsync) {
fastify.addHook("preHandler", async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
return reply.code(401).send({ message: "Unauthorized" })
}
})
fastify.get("/", async (request, reply) => {
const { from, to } = request.query as { from?: string; to?: string }
const user = request.user as { sub: string; role: string }
const filters = []
if (user.role !== "admin") {
filters.push(eq(timeEntries.userId, user.sub))
}
if (from) {
filters.push(gte(timeEntries.startTime, new Date(from)))
}
if (to) {
filters.push(lte(timeEntries.startTime, new Date(to)))
}
const entries = await db
.select()
.from(timeEntries)
.where(and(...filters))
.orderBy(timeEntries.startTime)
return entries
})
fastify.get("/:id", async (request, reply) => {
const { id } = request.params as { id: string }
const user = request.user as { sub: string; role: string }
const [entry] = await db
.select()
.from(timeEntries)
.where(
and(
eq(timeEntries.id, id),
user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub)
)
)
.limit(1)
if (!entry) {
return reply.code(404).send({ message: "Time entry not found" })
}
return entry
})
fastify.post("/", async (request, reply) => {
const user = request.user as { sub: string }
const body = TimeEntrySchema.parse(request.body)
const [entry] = await db
.insert(timeEntries)
.values({
...body,
userId: user.sub,
startTime: new Date(body.startTime),
endTime: body.endTime ? new Date(body.endTime) : null
})
.returning()
return reply.code(201).send(entry)
})
fastify.patch("/:id", async (request, reply) => {
const { id } = request.params as { id: string }
const user = request.user as { sub: string; role: string }
const body = TimeEntryUpdateSchema.parse(request.body)
const [entry] = await db
.select()
.from(timeEntries)
.where(
and(
eq(timeEntries.id, id),
user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub)
)
)
.limit(1)
if (!entry) {
return reply.code(404).send({ message: "Time entry not found" })
}
const updateData: any = { ...body }
if (body.startTime) updateData.startTime = new Date(body.startTime)
if (body.endTime) updateData.endTime = body.endTime ? new Date(body.endTime) : null
const [updated] = await db
.update(timeEntries)
.set(updateData)
.where(eq(timeEntries.id, id))
.returning()
return updated
})
fastify.delete("/:id", async (request, reply) => {
const { id } = request.params as { id: string }
const user = request.user as { sub: string; role: string }
const [entry] = await db
.select()
.from(timeEntries)
.where(
and(
eq(timeEntries.id, id),
user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub)
)
)
.limit(1)
if (!entry) {
return reply.code(404).send({ message: "Time entry not found" })
}
await db.delete(timeEntries).where(eq(timeEntries.id, id))
return reply.code(204).send()
})
}

View File

@ -9,7 +9,6 @@
"preview": "vite preview --port 5174"
},
"dependencies": {
"@emberclone/shared": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.62.7",
"react": "^18.3.1",

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,70 +0,0 @@
import { createRootRoute, createRoute, createRouter, RouterProvider, Outlet, redirect } from "@tanstack/react-router"
import Dashboard from "./pages/Dashboard"
import Login from "./pages/Login"
import TimeEntries from "./pages/TimeEntries"
import { api } from "./lib/api"
const rootRoute = createRootRoute({
component: () => (
<div className="min-h-screen bg-slate-50">
<Outlet />
</div>
)
})
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/login",
component: Login
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: async () => {
try {
await api.getMe()
} catch (error: any) {
if (error.status === 401) {
throw redirect({ to: "/login" })
}
}
},
component: Dashboard
})
const timeEntriesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/time-entries",
beforeLoad: async () => {
try {
await api.getMe()
} catch (error: any) {
if (error.status === 401) {
throw redirect({ to: "/login" })
}
}
},
component: TimeEntries
})
const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
timeEntriesRoute
])
const router = createRouter({
routeTree,
defaultPreload: "intent"
})
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
export default function App() {
return <RouterProvider router={router} />
}

View File

@ -1,9 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-50 text-slate-900 font-sans antialiased;
}
}

View File

@ -1,69 +0,0 @@
import type { TimeEntryInsert } from "@emberclone/shared"
const API_BASE = "/api"
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem("auth_token")
const headers = new Headers(options.headers)
if (token) {
headers.set("Authorization", `Bearer ${token}`)
}
headers.set("Content-Type", "application/json")
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
credentials: "include"
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const err: any = new Error(errorData.message || `API Error: ${response.status}`)
err.status = response.status
throw err
}
return response.json()
}
export const api = {
async login(email: string, password: string) {
const data = await request<{ token: string }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password })
})
if (data?.token) {
localStorage.setItem("auth_token", data.token)
}
return data
},
logout() {
localStorage.removeItem("auth_token")
return request("/auth/logout", { method: "POST" }).catch(() => {})
},
async getMe() {
return request<{ id: string; email: string; name: string; role: "admin" | "user" }>("/auth/me")
},
async listTimeEntries(opts?: Record<string, string>) {
const query = opts ? `?${new URLSearchParams(opts).toString()}` : ""
return request<any[]>(`/time-entries${query}`)
},
async createTimeEntry(data: Partial<TimeEntryInsert>) {
return request("/time-entries", {
method: "POST",
body: JSON.stringify(data)
})
},
async deleteTimeEntry(id: string) {
return request(`/time-entries/${id}`, {
method: "DELETE"
})
}
}

View File

@ -1,22 +0,0 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App"
import "./index.css"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
)

View File

@ -1,64 +0,0 @@
import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { api } from "../lib/api"
export default function Dashboard() {
const navigate = useNavigate()
const { data: user, isLoading: userLoading } = useQuery({
queryKey: ["me"],
queryFn: () => api.getMe()
})
const { data: entries, isLoading: entriesLoading } = useQuery({
queryKey: ["timeEntries", "today"],
queryFn: () => api.listTimeEntries({ date: new Date().toISOString().split("T")[0] })
})
const handleLogout = () => {
api.logout()
navigate({ to: "/login" })
}
if (userLoading || entriesLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<header className="flex justify-between items-center mb-8 max-w-7xl mx-auto">
<h1 className="text-2xl font-bold text-gray-800">Dashboard</h1>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 transition-colors"
>
Logout
</button>
</header>
<main className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="col-span-1 md:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<h2 className="text-xl font-semibold text-gray-700 mb-2">
Hallo, {user?.name || "Benutzer"}!
</h2>
<p className="text-gray-500">
Willkommen zurück in deinem EmberClone. Hier ist die Übersicht deiner heutigen Aktivitäten.
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 flex flex-col justify-center items-center text-center">
<span className="text-sm font-medium text-gray-500 uppercase tracking-wider">
Heutige Einträge
</span>
<span className="text-5xl font-bold text-indigo-600 mt-2">
{entries?.length || 0}
</span>
</div>
</main>
</div>
)
}

View File

@ -1,79 +0,0 @@
import { useState } from "react"
import { useNavigate } from "@tanstack/react-router"
import { api } from "../lib/api"
export default function Login() {
const navigate = useNavigate()
const [email, setEmail] = useState("admin@emberclone.local")
const [password, setPassword] = useState("emberclone2026")
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
await api.login(email, password)
navigate({ to: "/" })
} catch (err: any) {
setError(err.message || "Login failed")
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<div className="w-full max-w-md bg-white rounded-xl shadow-sm border border-slate-200 p-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-slate-900">EmberClone</h1>
<p className="text-slate-500 mt-2">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Email Address
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
required
/>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Signing in..." : "Sign In"}
</button>
</form>
</div>
</div>
)
}

View File

@ -1,146 +0,0 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { api } from "../lib/api"
import type { TimeEntryInsert } from "@emberclone/shared"
export default function TimeEntries() {
const queryClient = useQueryClient()
const [formData, setFormData] = useState({
description: "",
startTime: "",
endTime: "",
projectId: ""
})
const { data: entries, isLoading, isError } = useQuery({
queryKey: ["time-entries"],
queryFn: () => api.listTimeEntries()
})
const createMutation = useMutation({
mutationFn: (data: Partial<TimeEntryInsert>) => api.createTimeEntry(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["time-entries"] })
setFormData({ description: "", startTime: "", endTime: "", projectId: "" })
}
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.deleteTimeEntry(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["time-entries"] })
}
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createMutation.mutate({
description: formData.description,
startTime: new Date(formData.startTime) as any,
endTime: new Date(formData.endTime) as any,
projectId: formData.projectId || undefined
})
}
if (isLoading) return <div className="p-6 text-gray-500">Loading entries...</div>
if (isError) return <div className="p-6 text-red-500">Error loading time entries.</div>
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<header>
<h1 className="text-2xl font-bold text-gray-900">Time Tracking</h1>
<p className="text-gray-500">Manage your work logs and project hours</p>
</header>
<section className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Log New Entry</h2>
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="What did you work on?"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start</label>
<input
type="datetime-local"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
value={formData.startTime}
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End</label>
<input
type="datetime-local"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
value={formData.endTime}
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={createMutation.isPending}
className="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 font-medium"
>
{createMutation.isPending ? "Saving..." : "Add Entry"}
</button>
</div>
</form>
</section>
<section className="overflow-x-auto bg-white rounded-lg border border-gray-200 shadow-sm">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="px-6 py-3 text-sm font-semibold text-gray-600">Description</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-600">Start</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-600">End</th>
<th className="px-6 py-3 text-sm font-semibold text-gray-600 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{entries?.length === 0 && (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-400">No entries found.</td>
</tr>
)}
{entries?.map((entry: any) => (
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-800">{entry.description}</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(entry.startTime).toLocaleString()}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(entry.endTime).toLocaleString()}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => {
if (confirm("Delete this entry?")) {
deleteMutation.mutate(entry.id)
}
}}
disabled={deleteMutation.isPending}
className="text-red-500 hover:text-red-700 text-sm font-medium disabled:opacity-50"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
)
}

View File

@ -1,19 +0,0 @@
import type { Config } from "tailwindcss"
export default {
content: [
"./index.html",
"./src/**/*.{ts,tsx}"
],
theme: {
extend: {
colors: {
ember: {
500: "#f97316",
600: "#ea580c"
}
}
}
},
plugins: []
} satisfies Config

View File

@ -1 +0,0 @@
export * from "./schemas"

View File

@ -1,69 +0,0 @@
import { z } from "zod"
export const UserRoleSchema = z.enum(["admin", "user"])
export const UserInsertSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
role: UserRoleSchema,
passwordHash: z.string().min(1)
})
export const UserSelectSchema = UserInsertSchema.extend({
id: z.string().uuid(),
createdAt: z.date()
}).omit({ passwordHash: true })
export const CustomerInsertSchema = z.object({
name: z.string().min(1),
active: z.boolean().default(true)
})
export const CustomerSelectSchema = CustomerInsertSchema.extend({
id: z.string().uuid(),
createdAt: z.date()
})
export const ProjectInsertSchema = z.object({
name: z.string().min(1),
customerId: z.string().uuid(),
active: z.boolean().default(true)
})
export const ProjectSelectSchema = ProjectInsertSchema.extend({
id: z.string().uuid(),
createdAt: z.date()
})
export const TimeEntryInsertSchema = z.object({
userId: z.string().uuid(),
projectId: z.string().uuid().optional(),
description: z.string().min(1),
startTime: z.date(),
endTime: z.date().optional()
})
export const TimeEntrySelectSchema = TimeEntryInsertSchema.extend({
id: z.string().uuid(),
createdAt: z.date()
})
export const LoginRequestSchema = z.object({
email: z.string().email(),
password: z.string().min(1)
})
export type UserInsert = z.infer<typeof UserInsertSchema>
export type UserSelect = z.infer<typeof UserSelectSchema>
export type UserRole = z.infer<typeof UserRoleSchema>
export type CustomerInsert = z.infer<typeof CustomerInsertSchema>
export type CustomerSelect = z.infer<typeof CustomerSelectSchema>
export type ProjectInsert = z.infer<typeof ProjectInsertSchema>
export type ProjectSelect = z.infer<typeof ProjectSelectSchema>
export type TimeEntryInsert = z.infer<typeof TimeEntryInsertSchema>
export type TimeEntrySelect = z.infer<typeof TimeEntrySelectSchema>
export type LoginRequest = z.infer<typeof LoginRequestSchema>

3350
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff