This commit is contained in:
Nathan 2026-01-30 17:46:07 -05:00
parent c504f31eff
commit 257b6fa02f
46 changed files with 31553 additions and 446 deletions

View file

@ -1,7 +1,7 @@
# AGENTS.md
## Architecture
- **frontend/**: SvelteKit 2 + Svelte 5 app with Tailwind CSS v4, static adapter, better-auth
- **frontend/**: SvelteKit 2 + Svelte 5 app with Tailwind CSS v4, static adapter
- **backend/**: Bun + Elysia API server with Drizzle ORM + PostgreSQL
## Commands
@ -19,3 +19,49 @@
- Frontend: Svelte 5 runes, TypeScript strict, Lucide icons
- Backend: Drizzle schemas in `schemas/`, snake_case for DB columns, camelCase in TS
- No comments unless complex; no `// @ts-expect-error` or `as any`
## UI Style Guide
### Buttons
All buttons should follow these patterns:
**Primary Button (filled)**
```html
class="px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50"
```
**Secondary Button (outlined)**
```html
class="px-4 py-2 border-2 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50"
```
**Toggle/Filter Button (selected state)**
```html
class="px-4 py-2 border-2 border-black rounded-full font-bold transition-all duration-200 {isSelected
? 'bg-black text-white'
: 'hover:border-dashed'}"
```
### Cards & Containers
```html
class="border-4 border-black rounded-2xl p-6 hover:border-dashed transition-all"
```
### Inputs
```html
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
```
### Modals
```html
class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto"
```
### Key Patterns
- **Border style**: `border-2` for buttons/inputs, `border-4` for cards/containers
- **Rounding**: `rounded-full` for buttons, `rounded-2xl` for cards, `rounded-lg` for inputs
- **Hover state**: `hover:border-dashed` for outlined elements
- **Focus state**: `focus:border-dashed` for inputs
- **Selected state**: `bg-black text-white` (filled)
- **Animation**: Always include `transition-all duration-200`
- **Colors**: Black borders, white backgrounds, no colors except for errors (red)

View file

@ -4,6 +4,8 @@
"": {
"name": "app",
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"better-auth": "^1.4.18",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"elysia": "latest",
@ -18,10 +20,20 @@
},
},
"packages": {
"@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
@ -78,8 +90,14 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@ -88,6 +106,10 @@
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="],
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
@ -96,6 +118,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
@ -120,10 +144,16 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"kysely": ["kysely@0.28.10", "", {}, "sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
@ -152,6 +182,10 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
@ -170,6 +204,8 @@
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],

28771
backend/dist/index.js vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
CREATE TABLE "projects" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "projects_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"name" varchar NOT NULL,
"description" varchar NOT NULL,
"image" text,
"github_url" varchar,
"hackatime_project" varchar,
"hours" real DEFAULT 0,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"token" varchar PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shop_hearts" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "shop_hearts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"shop_item_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "shop_hearts_user_id_shop_item_id_unique" UNIQUE("user_id","shop_item_id")
);
--> statement-breakpoint
CREATE TABLE "shop_items" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "shop_items_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"name" varchar NOT NULL,
"image" varchar NOT NULL,
"description" varchar NOT NULL,
"price" integer NOT NULL,
"category" varchar NOT NULL,
"count" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "users_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"sub" varchar NOT NULL,
"slack_id" varchar,
"username" varchar,
"email" varchar NOT NULL,
"avatar" varchar,
"access_token" text,
"refresh_token" text,
"id_token" text,
"scraps" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_sub_unique" UNIQUE("sub")
);
--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_hearts" ADD CONSTRAINT "shop_hearts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shop_hearts" ADD CONSTRAINT "shop_hearts_shop_item_id_shop_items_id_fk" FOREIGN KEY ("shop_item_id") REFERENCES "public"."shop_items"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,445 @@
{
"id": "fd2567f0-9a0d-4759-98fc-db9ef6cc8968",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "projects_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"github_url": {
"name": "github_url",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"hackatime_project": {
"name": "hackatime_project",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"hours": {
"name": "hours",
"type": "real",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"projects_user_id_users_id_fk": {
"name": "projects_user_id_users_id_fk",
"tableFrom": "projects",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"token": {
"name": "token",
"type": "varchar",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.shop_hearts": {
"name": "shop_hearts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "shop_hearts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"shop_item_id": {
"name": "shop_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"shop_hearts_user_id_users_id_fk": {
"name": "shop_hearts_user_id_users_id_fk",
"tableFrom": "shop_hearts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"shop_hearts_shop_item_id_shop_items_id_fk": {
"name": "shop_hearts_shop_item_id_shop_items_id_fk",
"tableFrom": "shop_hearts",
"tableTo": "shop_items",
"columnsFrom": [
"shop_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"shop_hearts_user_id_shop_item_id_unique": {
"name": "shop_hearts_user_id_shop_item_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id",
"shop_item_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.shop_items": {
"name": "shop_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "shop_items_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"price": {
"name": "price",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"sub": {
"name": "sub",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"slack_id": {
"name": "slack_id",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scraps": {
"name": "scraps",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_sub_unique": {
"name": "users_sub_unique",
"nullsNotDistinct": false,
"columns": [
"sub"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

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

View file

@ -6,6 +6,8 @@
"dev": "bun run --watch src/index.ts"
},
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"better-auth": "^1.4.18",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"elysia": "latest",

View file

@ -0,0 +1,8 @@
import 'dotenv/config'
import { drizzle } from 'drizzle-orm/node-postgres'
import { sql } from 'drizzle-orm'
const db = drizzle(process.env.DATABASE_URL!)
await db.execute(sql`DROP TABLE IF EXISTS projects CASCADE`)
console.log('Dropped projects table')

2
backend/src/db/schema.ts Normal file
View file

@ -0,0 +1,2 @@
export * from '../schemas/users'
export * from '../schemas/shop'

View file

@ -1,13 +1,33 @@
import { Elysia } from "elysia"
import projects from "./routes/projects"
import news from "./routes/news"
import items from "./routes/items"
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import projects from './routes/projects'
import news from './routes/news'
import items from './routes/items'
import authRoutes from './routes/auth'
import user from './routes/user'
import shop from './routes/shop'
import leaderboard from './routes/leaderboard'
import hackatime from './routes/hackatime'
import upload from './routes/upload'
const app = new Elysia()
const api = new Elysia({ prefix: '/api' })
.use(authRoutes)
.use(projects)
.use(news)
.use(items)
.get("/", () => "Hello Elysia")
.use(user)
.use(shop)
.use(leaderboard)
.use(hackatime)
.use(upload)
.get("/", () => "if you dm @notaroomba abt finding this you may get cool stickers")
const app = new Elysia()
.use(cors({
origin: ["http://localhost:5173", "http://localhost:3000"],
credentials: true
}))
.use(api)
.listen(3000)
console.log(

209
backend/src/lib/auth.ts Normal file
View file

@ -0,0 +1,209 @@
import { eq, and, gt } from "drizzle-orm"
import { db } from "../db"
import { usersTable } from "../schemas/users"
import { sessionsTable } from "../schemas/sessions"
import { getSlackProfile, getAvatarUrl } from "./slack"
const HACKCLUB_AUTH_URL = "https://auth.hackclub.com"
const CLIENT_ID = process.env.HCAUTH_CLIENT_ID!
const CLIENT_SECRET = process.env.HCAUTH_CLIENT_SECRET!
const REDIRECT_URI = process.env.HCAUTH_REDIRECT_URI || "http://localhost:3000/api/auth/callback"
interface OIDCTokenResponse {
access_token: string
token_type: string
id_token: string
refresh_token?: string
}
interface HackClubIdentity {
id: string
ysws_eligible?: boolean
verification_status?: string
primary_email?: string
slack_id?: string
}
interface HackClubMeResponse {
identity: HackClubIdentity
scopes: string[]
}
export function getAuthorizationUrl(): string {
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "openid profile email slack_id verification_status"
})
return `${HACKCLUB_AUTH_URL}/oauth/authorize?${params.toString()}`
}
export async function exchangeCodeForTokens(code: string): Promise<OIDCTokenResponse | null> {
try {
const body = new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
code,
grant_type: "authorization_code"
})
const response = await fetch(`${HACKCLUB_AUTH_URL}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString()
})
if (!response.ok) {
console.error("Token exchange failed:", await response.text())
return null
}
return await response.json()
} catch (error) {
console.error("Token exchange error:", error)
return null
}
}
export async function fetchUserIdentity(accessToken: string): Promise<HackClubMeResponse | null> {
try {
const response = await fetch(`${HACKCLUB_AUTH_URL}/api/v1/me`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
})
if (!response.ok) {
console.error("[AUTH] Failed to fetch user identity:", await response.text())
return null
}
return await response.json()
} catch (error) {
console.error("[AUTH] Error fetching user identity:", error)
return null
}
}
export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OIDCTokenResponse) {
if (!identity.ysws_eligible) {
throw new Error("not-eligible")
}
let username: string | null = null
let avatarUrl: string | null = null
if (identity.slack_id && process.env.SLACK_BOT_TOKEN) {
const slackProfile = await getSlackProfile(identity.slack_id, process.env.SLACK_BOT_TOKEN)
if (slackProfile) {
username = slackProfile.display_name || slackProfile.real_name || null
avatarUrl = getAvatarUrl(slackProfile)
console.log("[AUTH] Slack profile fetched:", { username, avatarUrl })
}
}
const existingUser = await db
.select()
.from(usersTable)
.where(eq(usersTable.sub, identity.id))
.limit(1)
if (existingUser.length > 0) {
const updated = await db
.update(usersTable)
.set({
username,
email: identity.primary_email || existingUser[0].email,
slackId: identity.slack_id,
avatar: avatarUrl || existingUser[0].avatar,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
updatedAt: new Date()
})
.where(eq(usersTable.sub, identity.id))
.returning()
return updated[0]
} else {
console.log("[AUTH] New user signup:", {
id: identity.id,
username,
email: identity.primary_email,
slackId: identity.slack_id
})
const newUser = await db
.insert(usersTable)
.values({
sub: identity.id,
slackId: identity.slack_id,
username,
email: identity.primary_email || "",
avatar: avatarUrl,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token
})
.returning()
return newUser[0]
}
}
export async function createSession(userId: number): Promise<string> {
const token = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
await db.insert(sessionsTable).values({
token,
userId,
expiresAt
})
return token
}
export async function getSessionUserId(token: string): Promise<number | null> {
const session = await db
.select()
.from(sessionsTable)
.where(and(
eq(sessionsTable.token, token),
gt(sessionsTable.expiresAt, new Date())
))
.limit(1)
if (!session[0]) return null
return session[0].userId
}
export async function deleteSession(token: string): Promise<void> {
await db.delete(sessionsTable).where(eq(sessionsTable.token, token))
}
export async function getUserFromSession(headers: Record<string, string | undefined>) {
const cookie = headers.cookie || ""
const match = cookie.match(/session=([^;]+)/)
if (!match) return null
const userId = await getSessionUserId(match[1])
if (!userId) return null
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId))
.limit(1)
return user[0] || null
}
export async function checkUserEligibility(accessToken: string): Promise<{ yswsEligible: boolean; verificationStatus: string } | null> {
const identity = await fetchUserIdentity(accessToken)
if (!identity) return null
return {
yswsEligible: identity.identity.ysws_eligible ?? false,
verificationStatus: identity.identity.verification_status ?? 'unknown'
}
}

48
backend/src/lib/slack.ts Normal file
View file

@ -0,0 +1,48 @@
interface SlackProfile {
avatar_hash: string
display_name: string
display_name_normalized: string
email: string
first_name: string
last_name: string
real_name: string
image_24: string
image_32: string
image_48: string
image_72: string
image_192: string
image_512: string
}
interface SlackProfileResponse {
ok: boolean
profile?: SlackProfile
error?: string
}
export async function getSlackProfile(slackId: string, token: string): Promise<SlackProfile | null> {
try {
const response = await fetch(`https://slack.com/api/users.profile.get?user=${slackId}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
})
const data: SlackProfileResponse = await response.json()
if (data.ok && data.profile) {
return data.profile
}
console.error('Slack API error:', data.error)
return null
} catch (error) {
console.error('Failed to fetch Slack profile:', error)
return null
}
}
export function getAvatarUrl(profile: SlackProfile): string {
return profile.image_192 || profile.image_512 || profile.image_72 || profile.image_48
}

106
backend/src/routes/auth.ts Normal file
View file

@ -0,0 +1,106 @@
import { Elysia } from "elysia"
import {
getAuthorizationUrl,
exchangeCodeForTokens,
fetchUserIdentity,
createOrUpdateUser,
createSession,
deleteSession,
getUserFromSession
} from "../lib/auth"
const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"
const authRoutes = new Elysia({ prefix: "/auth" })
// GET /auth/login - Redirect to Hack Club Auth
authRoutes.get("/login", ({ redirect }) => {
console.log("[AUTH] Login initiated")
return redirect(getAuthorizationUrl())
})
// GET /auth/callback - Handle OIDC callback
authRoutes.get("/callback", async ({ query, redirect, cookie }) => {
console.log("[AUTH] Callback received")
const code = query.code as string | undefined
if (!code) {
console.log("[AUTH] Callback error: no code provided")
return redirect(`${FRONTEND_URL}/auth/error?reason=auth-failed`)
}
try {
const tokens = await exchangeCodeForTokens(code)
if (!tokens) {
console.log("[AUTH] Callback error: token exchange failed")
return redirect(`${FRONTEND_URL}/auth/error?reason=auth-failed`)
}
const meResponse = await fetchUserIdentity(tokens.access_token)
if (!meResponse) {
console.log("[AUTH] Callback error: failed to fetch user identity")
return redirect(`${FRONTEND_URL}/auth/error?reason=auth-failed`)
}
const { identity } = meResponse
console.log("[AUTH] Identity received:", {
id: identity.id,
email: identity.primary_email,
slackId: identity.slack_id,
yswsEligible: identity.ysws_eligible,
verificationStatus: identity.verification_status
})
const user = await createOrUpdateUser(identity, tokens)
const sessionToken = await createSession(user.id)
console.log("[AUTH] User authenticated successfully:", { userId: user.id, username: user.username })
cookie.session.set({
value: sessionToken,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60,
path: "/"
})
return redirect(`${FRONTEND_URL}/dashboard`)
} catch (error) {
const msg = error instanceof Error ? error.message : "unknown"
console.log("[AUTH] Callback error:", msg, error)
if (msg === "not-eligible") {
return redirect(`${FRONTEND_URL}/auth/error?reason=not-eligible`)
}
return redirect(`${FRONTEND_URL}/auth/error?reason=auth-failed`)
}
})
// GET /auth/me - Get current user
authRoutes.get("/me", async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
console.log("[AUTH] /me check:", user ? { userId: user.id, username: user.username } : "no session")
if (!user) return { user: null }
return {
user: {
id: user.id,
username: user.username,
email: user.email,
avatar: user.avatar,
slackId: user.slackId,
scraps: user.scraps
}
}
})
// POST /auth/logout
authRoutes.post("/logout", async ({ cookie }) => {
console.log("[AUTH] Logout requested")
const token = cookie.session.value as string | undefined
if (token) {
await deleteSession(token)
cookie.session.remove()
}
return { success: true }
})
export default authRoutes

View file

@ -0,0 +1,64 @@
import { Elysia } from 'elysia'
import { getUserFromSession } from '../lib/auth'
const HACKATIME_API = 'https://hackatime.hackclub.com/api/v1'
interface HackatimeProject {
name: string
total_seconds: number
languages: string[]
repo_url: string | null
total_heartbeats: number
first_heartbeat: string
last_heartbeat: string
}
interface HackatimeResponse {
projects: HackatimeProject[]
}
const hackatime = new Elysia({ prefix: '/hackatime' })
hackatime.get('/projects', async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
if (!user.slackId) {
console.log('[HACKATIME] No slackId found for user:', user.id)
return { error: 'No Slack ID found for user', projects: [] }
}
const url = `${HACKATIME_API}/users/${encodeURIComponent(user.slackId)}/projects/details`
console.log('[HACKATIME] Fetching projects:', { userId: user.id, slackId: user.slackId, url })
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json'
}
})
if (!response.ok) {
const errorText = await response.text()
console.log('[HACKATIME] API error:', { status: response.status, body: errorText })
return { projects: [] }
}
const data: HackatimeResponse = await response.json()
console.log('[HACKATIME] Projects fetched:', data.projects?.length || 0)
return {
projects: data.projects.map((p) => ({
name: p.name,
hours: Math.round(p.total_seconds / 3600 * 10) / 10,
repoUrl: p.repo_url,
languages: p.languages
}))
}
} catch (error) {
console.error('[HACKATIME] Error fetching projects:', error)
return { projects: [] }
}
})
export default hackatime

View file

@ -0,0 +1,69 @@
import { Elysia, t } from 'elysia'
import { db } from '../db'
import { usersTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
import { sql, desc, eq } from 'drizzle-orm'
const leaderboard = new Elysia({ prefix: '/leaderboard' })
leaderboard.get('/', async ({ query }) => {
const sortBy = query.sortBy || 'scraps'
if (sortBy === 'hours') {
const results = await db
.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
scraps: usersTable.scraps,
hours: sql<number>`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql<number>`COUNT(${projectsTable.id})`.as('project_count')
})
.from(usersTable)
.leftJoin(projectsTable, eq(projectsTable.userId, usersTable.id))
.groupBy(usersTable.id)
.orderBy(desc(sql`total_hours`))
.limit(10)
return results.map((user, index) => ({
rank: index + 1,
id: user.id,
username: user.username,
avatar: user.avatar,
hours: Number(user.hours),
scraps: user.scraps,
projectCount: Number(user.projectCount)
}))
}
const results = await db
.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
scraps: usersTable.scraps,
hours: sql<number>`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql<number>`COUNT(${projectsTable.id})`.as('project_count')
})
.from(usersTable)
.leftJoin(projectsTable, eq(projectsTable.userId, usersTable.id))
.groupBy(usersTable.id)
.orderBy(desc(usersTable.scraps))
.limit(10)
return results.map((user, index) => ({
rank: index + 1,
id: user.id,
username: user.username,
avatar: user.avatar,
hours: Number(user.hours),
scraps: user.scraps,
projectCount: Number(user.projectCount)
}))
}, {
query: t.Object({
sortBy: t.Optional(t.Union([t.Literal('hours'), t.Literal('scraps')]))
})
})
export default leaderboard

View file

@ -14,7 +14,7 @@ news.get("/latest", async () => {
return {
id: 1,
date: "jan 21, 2026",
content: "remember to stay drafty!"
content: "remember to stay scrappy!"
}
})

View file

@ -1,57 +1,100 @@
import { Elysia } from "elysia"
import { Elysia } from 'elysia'
import { eq, and } from 'drizzle-orm'
import { db } from '../db'
import { projectsTable } from '../schemas/projects'
import { getUserFromSession } from '../lib/auth'
const projects = new Elysia({
prefix: "/projects"
const projects = new Elysia({ prefix: '/projects' })
projects.get('/', async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
return await db.select().from(projectsTable).where(eq(projectsTable.userId, user.id))
})
// GET /projects - Get all projects for the authenticated user
projects.get("/", async ({ headers }) => {
// TODO: Get user from auth header and fetch their projects from database
// const userId = await getUserFromToken(headers.authorization)
// const userProjects = await db.select().from(projectsTable).where(eq(projectsTable.userId, userId))
// return userProjects
projects.get('/:id', async ({ params, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
// Dummy data for now
return [
{
id: 1,
userId: 1,
name: "Blueprint",
description: "A hackathon project for AMD",
imageUrl: "/hero.png",
githubUrl: "https://github.com/hackclub/blueprint",
hours: 24,
hackatimeUrl: "https://hackatime.hackclub.com/projects/blueprint"
},
{
id: 2,
userId: 1,
name: "Flavortown",
description: "A food discovery app",
imageUrl: "/hero.png",
githubUrl: "https://github.com/hackclub/flavortown",
hours: 18,
hackatimeUrl: "https://hackatime.hackclub.com/projects/flavortown"
}
]
const project = await db
.select()
.from(projectsTable)
.where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id)))
.limit(1)
return project[0] || { error: 'Not found' }
})
// PUT /projects/:id - Update a project
projects.put("/:id", async ({ params, body }) => {
// TODO: Validate user owns this project and update in database
// const project = await db.update(projectsTable).set(body).where(eq(projectsTable.id, params.id)).returning()
// return project
projects.post('/', async ({ body, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
return { success: true, ...body }
const data = body as {
name: string
description: string
image?: string
githubUrl?: string
hackatimeProject?: string
hours?: number
}
const newProject = await db
.insert(projectsTable)
.values({
userId: user.id,
name: data.name,
description: data.description,
image: data.image || null,
githubUrl: data.githubUrl || null,
hackatimeProject: data.hackatimeProject || null,
hours: data.hours || 0
})
.returning()
return newProject[0]
})
// POST /projects - Create a new project
projects.post("/", async ({ body }) => {
// TODO: Get user from auth and create project in database
// const project = await db.insert(projectsTable).values({ ...body, userId }).returning()
// return project
projects.put('/:id', async ({ params, body, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
return { success: true, id: Date.now(), ...body }
const data = body as {
name?: string
description?: string
image?: string | null
githubUrl?: string | null
hackatimeProject?: string | null
hours?: number
}
const updated = await db
.update(projectsTable)
.set({
name: data.name,
description: data.description,
image: data.image,
githubUrl: data.githubUrl,
hackatimeProject: data.hackatimeProject,
hours: data.hours,
updatedAt: new Date()
})
.where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id)))
.returning()
return updated[0] || { error: 'Not found' }
})
projects.delete("/:id", async ({ params, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) return { error: "Unauthorized" }
const deleted = await db
.delete(projectsTable)
.where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id)))
.returning()
return deleted.length ? { success: true } : { error: "Not found" }
})
export default projects

145
backend/src/routes/shop.ts Normal file
View file

@ -0,0 +1,145 @@
import { Elysia } from 'elysia'
import { eq, sql, and } from 'drizzle-orm'
import { db } from '../db'
import { shopItemsTable, shopHeartsTable } from '../schemas/shop'
import { getUserFromSession } from '../lib/auth'
const shop = new Elysia({ prefix: '/shop' })
shop.get('/items', async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
const items = await db
.select({
id: shopItemsTable.id,
name: shopItemsTable.name,
image: shopItemsTable.image,
description: shopItemsTable.description,
price: shopItemsTable.price,
category: shopItemsTable.category,
count: shopItemsTable.count,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql<number>`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as('heart_count')
})
.from(shopItemsTable)
if (user) {
const userHearts = await db
.select({ shopItemId: shopHeartsTable.shopItemId })
.from(shopHeartsTable)
.where(eq(shopHeartsTable.userId, user.id))
const heartedIds = new Set(userHearts.map(h => h.shopItemId))
return items.map(item => ({
...item,
hearted: heartedIds.has(item.id)
}))
}
return items.map(item => ({
...item,
hearted: false
}))
})
shop.get('/items/:id', async ({ params, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
const itemId = parseInt(params.id)
const items = await db
.select({
id: shopItemsTable.id,
name: shopItemsTable.name,
image: shopItemsTable.image,
description: shopItemsTable.description,
price: shopItemsTable.price,
category: shopItemsTable.category,
count: shopItemsTable.count,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql<number>`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as('heart_count')
})
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (items.length === 0) {
return { error: 'Item not found' }
}
const item = items[0]
let hearted = false
if (user) {
const heart = await db
.select()
.from(shopHeartsTable)
.where(and(
eq(shopHeartsTable.userId, user.id),
eq(shopHeartsTable.shopItemId, itemId)
))
.limit(1)
hearted = heart.length > 0
}
return { ...item, hearted }
})
shop.post('/items/:id/heart', async ({ params, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const itemId = parseInt(params.id)
const item = await db
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (item.length === 0) {
return { error: 'Item not found' }
}
const existingHeart = await db
.select()
.from(shopHeartsTable)
.where(and(
eq(shopHeartsTable.userId, user.id),
eq(shopHeartsTable.shopItemId, itemId)
))
.limit(1)
if (existingHeart.length > 0) {
await db
.delete(shopHeartsTable)
.where(and(
eq(shopHeartsTable.userId, user.id),
eq(shopHeartsTable.shopItemId, itemId)
))
return { hearted: false }
} else {
await db
.insert(shopHeartsTable)
.values({
userId: user.id,
shopItemId: itemId
})
return { hearted: true }
}
})
shop.get('/categories', async () => {
const result = await db
.selectDistinct({ category: shopItemsTable.category })
.from(shopItemsTable)
return result.map(r => r.category)
})
export default shop

View file

@ -0,0 +1,74 @@
import { Elysia } from 'elysia'
import { getUserFromSession } from '../lib/auth'
const HCCDN_URL = 'https://cdn.hackclub.com/api/v4/upload'
const HCCDN_KEY = process.env.HCCDN_KEY
interface CDNUploadResponse {
id: string
filename: string
size: number
content_type: string
url: string
created_at: string
}
const upload = new Elysia({ prefix: '/upload' })
upload.post('/image', async ({ body, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
if (!HCCDN_KEY) {
console.error('[UPLOAD] HCCDN_KEY not configured')
return { error: 'Upload service not configured' }
}
const { file } = body as { file: File }
if (!file) {
return { error: 'No file provided' }
}
try {
const contentType = file.type
const extMap: Record<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp'
}
const ext = extMap[contentType] || 'png'
const filename = `scrap-${Date.now()}.${ext}`
const formData = new FormData()
formData.append('file', file, filename)
console.log('[UPLOAD] Uploading to CDN:', { userId: user.id, filename, size: file.size })
const response = await fetch(HCCDN_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${HCCDN_KEY}`
},
body: formData
})
if (!response.ok) {
const errorText = await response.text()
console.error('[UPLOAD] CDN error:', { status: response.status, body: errorText })
return { error: 'Failed to upload image' }
}
const data: CDNUploadResponse = await response.json()
console.log('[UPLOAD] Upload successful:', { url: data.url })
return { url: data.url }
} catch (error) {
console.error('[UPLOAD] Error:', error)
return { error: 'Failed to upload image' }
}
})
export default upload

View file

@ -0,0 +1,33 @@
import { Elysia } from 'elysia'
import { getUserFromSession, checkUserEligibility } from '../lib/auth'
const user = new Elysia({ prefix: '/user' })
user.get('/me', async ({ headers }) => {
const userData = await getUserFromSession(headers as Record<string, string>)
if (!userData) return { error: 'Unauthorized' }
let yswsEligible = false
let verificationStatus = 'unknown'
if (userData.accessToken) {
const eligibility = await checkUserEligibility(userData.accessToken)
if (eligibility) {
yswsEligible = eligibility.yswsEligible
verificationStatus = eligibility.verificationStatus
}
}
return {
id: userData.id,
username: userData.username,
email: userData.email,
avatar: userData.avatar,
slackId: userData.slackId,
scraps: userData.scraps,
yswsEligible,
verificationStatus
}
})
export default user

View file

@ -1,22 +1,22 @@
import { integer, pgTable, varchar } from "drizzle-orm/pg-core";
import { integer, pgTable, varchar, text, timestamp, real } from 'drizzle-orm/pg-core'
import { usersTable } from './users'
import { usersTable } from "./users";
export const projectsTable = pgTable("projects", {
export const projectsTable = pgTable('projects', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => usersTable.id),
name: varchar().notNull(),
description: varchar().notNull(),
imageUrl: varchar().notNull(),
githubUrl: varchar().notNull(),
// Optional fields (required for submission but not creation)
image: text(),
githubUrl: varchar('github_url'),
hackatimeProject: varchar('hackatime_project'),
hours: real().default(0),
hours: integer().notNull(),
hackatimeUrl: varchar().notNull()
});
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
// last ship date
// what we can improve

View file

@ -0,0 +1,9 @@
import { integer, pgTable, varchar, timestamp } from 'drizzle-orm/pg-core'
import { usersTable } from './users'
export const sessionsTable = pgTable('sessions', {
token: varchar().primaryKey(),
userId: integer('user_id').notNull().references(() => usersTable.id),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})

View file

@ -0,0 +1,23 @@
import { integer, pgTable, varchar, timestamp, unique } from 'drizzle-orm/pg-core'
import { usersTable } from './users'
export const shopItemsTable = pgTable('shop_items', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar().notNull(),
image: varchar().notNull(),
description: varchar().notNull(),
price: integer().notNull(),
category: varchar().notNull(),
count: integer().notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
export const shopHeartsTable = pgTable('shop_hearts', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => usersTable.id),
shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
createdAt: timestamp('created_at').defaultNow().notNull()
}, (table) => [
unique().on(table.userId, table.shopItemId)
])

View file

@ -1,12 +1,25 @@
import { integer, pgTable, varchar } from "drizzle-orm/pg-core";
import { integer, pgTable, varchar, text, timestamp } from 'drizzle-orm/pg-core'
export const usersTable = pgTable("users", {
export const usersTable = pgTable('users', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
avatar: varchar().notNull(),
username: varchar().notNull(),
email: varchar().notNull().unique(),
// OIDC identifiers
sub: varchar().notNull().unique(),
slackId: varchar('slack_id'),
// Profile info (from Slack)
username: varchar(),
email: varchar().notNull(),
avatar: varchar(),
accessToken: varchar().notNull(),
refreshToken: varchar().notNull()
});
// OAuth tokens
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
// scraps info
scraps: integer().notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})

View file

@ -0,0 +1,26 @@
import { API_URL } from '$lib/config'
export function login() {
window.location.href = `${API_URL}/auth/login`
}
export async function logout() {
await fetch(`${API_URL}/auth/logout`, {
method: "POST",
credentials: "include"
})
window.location.href = "/"
}
export async function getUser() {
try {
const response = await fetch(`${API_URL}/auth/me`, {
credentials: "include"
})
if (!response.ok) return null
const data = await response.json()
return data.user || null
} catch {
return null
}
}

View file

@ -0,0 +1,323 @@
<script lang="ts">
import { X, ChevronDown, Upload } from '@lucide/svelte'
import { API_URL } from '$lib/config'
interface Project {
id: number
userId: string
name: string
description: string
image: string | null
githubUrl: string | null
hackatimeProject: string | null
hours: number
}
interface HackatimeProject {
name: string
hours: number
repoUrl: string | null
languages: string[]
}
let {
open,
onClose,
onCreated
}: {
open: boolean
onClose: () => void
onCreated: (project: Project) => void
} = $props()
let name = $state('')
let description = $state('')
let imageUrl = $state<string | null>(null)
let imagePreview = $state<string | null>(null)
let uploadingImage = $state(false)
let selectedHackatimeProject = $state<HackatimeProject | null>(null)
let hackatimeProjects = $state<HackatimeProject[]>([])
let loadingProjects = $state(false)
let showDropdown = $state(false)
let loading = $state(false)
let error = $state<string | null>(null)
async function fetchHackatimeProjects() {
loadingProjects = true
try {
const response = await fetch(`${API_URL}/hackatime/projects`, {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
hackatimeProjects = data.projects || []
}
} catch (e) {
console.error('Failed to fetch hackatime projects:', e)
} finally {
loadingProjects = false
}
}
$effect(() => {
if (open) {
fetchHackatimeProjects()
}
})
async function handleImageUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (file.size > 5 * 1024 * 1024) {
error = 'Image must be less than 5MB'
return
}
imagePreview = URL.createObjectURL(file)
uploadingImage = true
error = null
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${API_URL}/upload/image`, {
method: 'POST',
credentials: 'include',
body: formData
})
const data = await response.json()
if (data.error) {
throw new Error(data.error)
}
imageUrl = data.url
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to upload image'
imagePreview = null
} finally {
uploadingImage = false
}
}
function removeImage() {
imageUrl = null
imagePreview = null
}
function selectProject(project: HackatimeProject) {
selectedHackatimeProject = project
showDropdown = false
}
function resetForm() {
name = ''
description = ''
imageUrl = null
imagePreview = null
selectedHackatimeProject = null
showDropdown = false
error = null
}
async function handleSubmit() {
if (!name.trim() || !description.trim()) {
error = 'Name and description are required'
return
}
loading = true
error = null
try {
const response = await fetch(`${API_URL}/projects`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
name,
description,
image: imageUrl || null,
githubUrl: selectedHackatimeProject?.repoUrl || null,
hackatimeProject: selectedHackatimeProject?.name || null,
hours: selectedHackatimeProject?.hours || 0
})
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.message || 'Failed to create project')
}
const newProject = await response.json()
resetForm()
onCreated(newProject)
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create project'
} finally {
loading = false
}
}
function handleClose() {
resetForm()
onClose()
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose()
}
}
</script>
{#if open}
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold">new project</h2>
<button onclick={handleClose} class="p-1 hover:bg-gray-100 rounded-lg transition-colors">
<X size={24} />
</button>
</div>
{#if error}
<div class="mb-4 p-3 bg-red-100 border-2 border-red-500 rounded-lg text-red-700 text-sm">
{error}
</div>
{/if}
<div class="space-y-4">
<!-- Image Upload -->
<div>
<label class="block text-sm font-bold mb-1">image <span class="font-normal text-gray-500">(optional)</span></label>
{#if imagePreview}
<div class="relative w-full h-40 border-2 border-black rounded-lg overflow-hidden">
<img src={imagePreview} alt="Preview" class="w-full h-full object-contain bg-gray-100" />
{#if uploadingImage}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center">
<span class="text-white font-bold">uploading...</span>
</div>
{:else}
<button
type="button"
onclick={removeImage}
class="absolute top-2 right-2 p-1 bg-white rounded-full border-2 border-black hover:bg-gray-100"
>
<X size={16} />
</button>
{/if}
</div>
{:else}
<label class="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-black rounded-lg cursor-pointer hover:bg-gray-50 transition-colors">
<Upload size={32} class="text-gray-400 mb-2" />
<span class="text-sm text-gray-500">click to upload image</span>
<input type="file" accept="image/*" onchange={handleImageUpload} class="hidden" />
</label>
{/if}
</div>
<!-- Required fields -->
<div>
<label for="name" class="block text-sm font-bold mb-1">name <span class="text-red-500">*</span></label>
<input
id="name"
type="text"
bind:value={name}
required
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="description" class="block text-sm font-bold mb-1">description <span class="text-red-500">*</span></label>
<textarea
id="description"
bind:value={description}
rows="3"
required
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed resize-none"
></textarea>
</div>
<!-- Optional Hackatime Project Dropdown -->
<div>
<label class="block text-sm font-bold mb-1">hackatime project <span class="font-normal text-gray-500">(optional)</span></label>
<div class="relative">
<button
type="button"
onclick={() => (showDropdown = !showDropdown)}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed text-left flex items-center justify-between"
>
{#if loadingProjects}
<span class="text-gray-500">loading projects...</span>
{:else if selectedHackatimeProject}
<span>{selectedHackatimeProject.name} <span class="text-gray-500">({selectedHackatimeProject.hours}h)</span></span>
{:else}
<span class="text-gray-500">select a project</span>
{/if}
<ChevronDown size={20} class={showDropdown ? 'rotate-180 transition-transform' : 'transition-transform'} />
</button>
{#if showDropdown && !loadingProjects}
<div class="absolute top-full left-0 right-0 mt-1 bg-white border-2 border-black rounded-lg max-h-48 overflow-y-auto z-10">
{#if hackatimeProjects.length === 0}
<div class="px-4 py-2 text-gray-500 text-sm">no projects found</div>
{:else}
<button
type="button"
onclick={() => { selectedHackatimeProject = null; showDropdown = false }}
class="w-full px-4 py-2 text-left hover:bg-gray-100 text-gray-500"
>
none
</button>
{#each hackatimeProjects as project}
<button
type="button"
onclick={() => selectProject(project)}
class="w-full px-4 py-2 text-left hover:bg-gray-100 flex justify-between items-center"
>
<span class="font-medium">{project.name}</span>
<span class="text-gray-500 text-sm">{project.hours}h</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
{#if selectedHackatimeProject?.repoUrl}
<p class="text-xs text-gray-500 mt-1">github: {selectedHackatimeProject.repoUrl}</p>
{/if}
</div>
</div>
<div class="flex gap-3 mt-6">
<button
onclick={handleClose}
disabled={loading}
class="flex-1 px-4 py-2 border-2 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50"
>
cancel
</button>
<button
onclick={handleSubmit}
disabled={loading}
class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50"
>
{loading ? 'creating...' : 'create'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -1,14 +1,43 @@
<script lang="ts">
import { page } from '$app/state'
import { LayoutDashboard, Trophy, Store, Flame, Send, Spool } from '@lucide/svelte'
import { LayoutDashboard, Trophy, Store, Flame, Send, Spool, LogOut } from '@lucide/svelte'
import { logout } from '$lib/auth-client'
let { screws = 0 }: { screws?: number } = $props()
interface User {
id: number
username: string
email: string
avatar: string | null
slackId: string | null
}
let { screws = 0, user = null }: { screws?: number; user?: User | null } = $props()
let currentPath = $derived(page.url.pathname)
let showProfileMenu = $state(false)
function handleLogout() {
logout()
}
function toggleProfileMenu() {
showProfileMenu = !showProfileMenu
}
function closeProfileMenu() {
showProfileMenu = false
}
</script>
<svelte:window onclick={(e) => {
const target = e.target as HTMLElement
if (!target.closest('.profile-menu-container')) {
closeProfileMenu()
}
}} />
<nav
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 bg-white/90 backdrop-blur-sm"
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 md:px-12 lg:px-24 bg-white/90 backdrop-blur-sm"
>
<a href="/">
<img src="/flag-standalone-bw.png" alt="Hack Club" class="h-8 md:h-10" />
@ -70,8 +99,43 @@
</a>
</div>
<div class="flex items-center gap-2 px-6 py-2 border-4 border-black rounded-full">
<Spool size={20} />
<span class="text-lg font-bold">{screws}</span>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 px-6 py-2 border-4 border-black rounded-full">
<Spool size={20} />
<span class="text-lg font-bold">{screws}</span>
</div>
{#if user}
<div class="relative profile-menu-container">
<button
onclick={toggleProfileMenu}
class="w-10 h-10 rounded-full border-4 border-black overflow-hidden hover:border-dashed transition-all duration-200"
>
{#if user.avatar}
<img src={user.avatar} alt={user.username || 'Profile'} class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-black text-white flex items-center justify-center font-bold text-lg">
{(user.username || user.email || '?').charAt(0).toUpperCase()}
</div>
{/if}
</button>
{#if showProfileMenu}
<div class="absolute right-0 top-14 bg-white border-4 border-black rounded-2xl overflow-hidden min-w-48 z-50">
<div class="px-4 py-3 border-b-2 border-black">
<p class="font-bold truncate">{user.username || 'user'}</p>
<p class="text-sm text-gray-500 truncate">{user.email}</p>
</div>
<button
onclick={handleLogout}
class="w-full px-4 py-3 flex items-center gap-2 hover:bg-gray-100 transition-colors text-left"
>
<LogOut size={18} />
<span class="font-bold">logout</span>
</button>
</div>
{/if}
</div>
{/if}
</div>
</nav>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { Heart } from '@lucide/svelte'
let {
count = 0,
hearted = false,
onclick
}: {
count: number
hearted: boolean
onclick: () => void
} = $props()
</script>
<button
{onclick}
class="flex items-center gap-1 p-2 rounded-full border-2 border-black transition-colors {hearted
? 'bg-red-100 border-red-500'
: 'hover:bg-gray-100'}"
title={hearted ? 'Remove from wishlist' : 'Add to wishlist'}
>
<Heart size={16} class={hearted ? 'fill-red-500 text-red-500' : ''} />
{#if count > 0}
<span class="text-xs font-bold">{count}</span>
{/if}
</button>

View file

@ -1,19 +1,92 @@
<script lang="ts">
import { LogIn } from '@lucide/svelte';
import { onMount } from 'svelte'
import { Home, Package, Info } from '@lucide/svelte'
let activeSection = $state('home')
let isScrolling = $state(false)
function scrollToSection(sectionId: string) {
isScrolling = true
activeSection = sectionId
if (sectionId === 'home') {
window.scrollTo({ top: 0, behavior: 'smooth' })
} else {
const element = document.getElementById(sectionId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
setTimeout(() => {
isScrolling = false
}, 1000)
}
onMount(() => {
const sections = ['home', 'scraps', 'about']
const observer = new IntersectionObserver(
(entries) => {
if (isScrolling) return
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio > 0.3) {
activeSection = entry.target.id
}
})
},
{
threshold: [0.3, 0.5, 0.7],
rootMargin: '-20% 0px -20% 0px'
}
)
sections.forEach((id) => {
const element = document.getElementById(id)
if (element) {
observer.observe(element)
}
})
return () => {
observer.disconnect()
}
})
</script>
<nav class="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 md:w-3/4 md:mx-auto md:left-1/2 md:-translate-x-1/2">
<a href="/">
<img src="/flag-standalone-bw.png" alt="Hack Club" class="h-8 md:h-10" />
</a>
<nav class="fixed top-0 left-0 right-0 z-50 flex items-center justify-center px-6 py-4 bg-white/90 backdrop-blur-sm">
<div class="w-full max-w-[75%] flex items-center justify-between">
<a href="/">
<img src="/flag-standalone-bw.png" alt="Hack Club" class="h-8 md:h-10" />
</a>
<a
href="https://forms.hackclub.com/t/58ZE2tdz5bus"
target="_blank"
rel="noopener noreferrer"
class="nav-button group flex items-center gap-2 px-6 py-2 border-4 border-black rounded-full transition-all duration-300 hover:border-dashed"
>
<LogIn size={18} />
<span class="text-lg font-bold">submit</span>
</a>
<div class="flex items-center gap-2">
<button
onclick={() => scrollToSection('home')}
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 {activeSection === 'home'
? 'bg-black text-white border-black'
: 'border-black hover:border-dashed'}"
>
<Home size={18} />
<span class="text-lg font-bold">home</span>
</button>
<button
onclick={() => scrollToSection('scraps')}
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 {activeSection === 'scraps'
? 'bg-black text-white border-black'
: 'border-black hover:border-dashed'}"
>
<Package size={18} />
<span class="text-lg font-bold">scraps</span>
</button>
<button
onclick={() => scrollToSection('about')}
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 {activeSection === 'about'
? 'bg-black text-white border-black'
: 'border-black hover:border-dashed'}"
>
<Info size={18} />
<span class="text-lg font-bold">about</span>
</button>
</div>
</div>
</nav>

View file

@ -1,15 +1,23 @@
<script lang="ts">
import { X } from '@lucide/svelte'
import { X, ChevronDown, Upload } from '@lucide/svelte'
import { API_URL } from '$lib/config'
interface Project {
id: number
userId: number
userId: string
name: string
description: string
imageUrl: string
githubUrl: string
image: string | null
githubUrl: string | null
hackatimeProject: string | null
hours: number
hackatimeUrl: string
}
interface HackatimeProject {
name: string
hours: number
repoUrl: string | null
languages: string[]
}
let {
@ -23,17 +31,132 @@
} = $props()
let editedProject = $state<Project | null>(null)
let imagePreview = $state<string | null>(null)
let uploadingImage = $state(false)
let hackatimeProjects = $state<HackatimeProject[]>([])
let loadingProjects = $state(false)
let showDropdown = $state(false)
let loading = $state(false)
let error = $state<string | null>(null)
async function fetchHackatimeProjects() {
loadingProjects = true
try {
const response = await fetch(`${API_URL}/hackatime/projects`, {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
hackatimeProjects = data.projects || []
}
} catch (e) {
console.error('Failed to fetch hackatime projects:', e)
} finally {
loadingProjects = false
}
}
$effect(() => {
if (project) {
editedProject = { ...project }
imagePreview = project.image
error = null
fetchHackatimeProjects()
}
})
function handleSave() {
async function handleImageUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !editedProject) return
if (file.size > 5 * 1024 * 1024) {
error = 'Image must be less than 5MB'
return
}
imagePreview = URL.createObjectURL(file)
uploadingImage = true
error = null
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch(`${API_URL}/upload/image`, {
method: 'POST',
credentials: 'include',
body: formData
})
const data = await response.json()
if (data.error) {
throw new Error(data.error)
}
if (editedProject) {
editedProject.image = data.url
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to upload image'
imagePreview = editedProject?.image || null
} finally {
uploadingImage = false
}
}
function removeImage() {
if (editedProject) {
// TODO: Call API to update project
onSave(editedProject)
editedProject.image = null
}
imagePreview = null
}
function selectHackatimeProject(hp: HackatimeProject) {
if (editedProject) {
editedProject.hackatimeProject = hp.name
editedProject.hours = hp.hours
if (hp.repoUrl && !editedProject.githubUrl) {
editedProject.githubUrl = hp.repoUrl
}
}
showDropdown = false
}
async function handleSave() {
if (!editedProject) return
loading = true
error = null
try {
const response = await fetch(`${API_URL}/projects/${editedProject.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
name: editedProject.name,
description: editedProject.description,
image: editedProject.image,
githubUrl: editedProject.githubUrl,
hackatimeProject: editedProject.hackatimeProject,
hours: editedProject.hours
})
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.message || 'Failed to save project')
}
const updatedProject = await response.json()
onSave(updatedProject)
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save project'
} finally {
loading = false
}
}
@ -52,15 +175,50 @@
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black">
<div class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold">edit draft</h2>
<h2 class="text-2xl font-bold">edit project</h2>
<button onclick={onClose} class="p-1 hover:bg-gray-100 rounded-lg transition-colors">
<X size={24} />
</button>
</div>
{#if error}
<div class="mb-4 p-3 bg-red-100 border-2 border-red-500 rounded-lg text-red-700 text-sm">
{error}
</div>
{/if}
<div class="space-y-4">
<!-- Image Upload -->
<div>
<label class="block text-sm font-bold mb-1">image</label>
{#if imagePreview}
<div class="relative w-full h-40 border-2 border-black rounded-lg overflow-hidden">
<img src={imagePreview} alt="Preview" class="w-full h-full object-contain bg-gray-100" />
{#if uploadingImage}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center">
<span class="text-white font-bold">uploading...</span>
</div>
{:else}
<button
type="button"
onclick={removeImage}
class="absolute top-2 right-2 p-1 bg-white rounded-full border-2 border-black hover:bg-gray-100"
>
<X size={16} />
</button>
{/if}
</div>
{:else}
<label class="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-black rounded-lg cursor-pointer hover:bg-gray-50 transition-colors">
<Upload size={32} class="text-gray-400 mb-2" />
<span class="text-sm text-gray-500">click to upload image</span>
<input type="file" accept="image/*" onchange={handleImageUpload} class="hidden" />
</label>
{/if}
</div>
<div>
<label for="name" class="block text-sm font-bold mb-1">name</label>
<input
@ -81,59 +239,80 @@
></textarea>
</div>
<div>
<label for="imageUrl" class="block text-sm font-bold mb-1">image url</label>
<input
id="imageUrl"
type="url"
bind:value={editedProject.imageUrl}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="githubUrl" class="block text-sm font-bold mb-1">github url</label>
<input
id="githubUrl"
type="url"
bind:value={editedProject.githubUrl}
placeholder="https://github.com/user/repo"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<!-- Hackatime Project Dropdown -->
<div>
<label for="hackatimeUrl" class="block text-sm font-bold mb-1">hackatime url</label>
<input
id="hackatimeUrl"
type="url"
bind:value={editedProject.hackatimeUrl}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<label class="block text-sm font-bold mb-1">hackatime project</label>
<div class="relative">
<button
type="button"
onclick={() => (showDropdown = !showDropdown)}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed text-left flex items-center justify-between"
>
{#if loadingProjects}
<span class="text-gray-500">loading projects...</span>
{:else if editedProject.hackatimeProject}
<span>{editedProject.hackatimeProject} <span class="text-gray-500">({editedProject.hours}h)</span></span>
{:else}
<span class="text-gray-500">select a project</span>
{/if}
<ChevronDown size={20} class={showDropdown ? 'rotate-180 transition-transform' : 'transition-transform'} />
</button>
{#if showDropdown && !loadingProjects}
<div class="absolute top-full left-0 right-0 mt-1 bg-white border-2 border-black rounded-lg max-h-48 overflow-y-auto z-10">
{#if hackatimeProjects.length === 0}
<div class="px-4 py-2 text-gray-500 text-sm">no projects found</div>
{:else}
<button
type="button"
onclick={() => { if (editedProject) { editedProject.hackatimeProject = null; editedProject.hours = 0; } showDropdown = false }}
class="w-full px-4 py-2 text-left hover:bg-gray-100 text-gray-500"
>
none
</button>
{#each hackatimeProjects as hp}
<button
type="button"
onclick={() => selectHackatimeProject(hp)}
class="w-full px-4 py-2 text-left hover:bg-gray-100 flex justify-between items-center"
>
<span class="font-medium">{hp.name}</span>
<span class="text-gray-500 text-sm">{hp.hours}h</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
<div>
<label for="hours" class="block text-sm font-bold mb-1">hours</label>
<input
id="hours"
type="number"
bind:value={editedProject.hours}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
onclick={onClose}
class="flex-1 px-4 py-2 border-2 border-black rounded-full font-bold hover:border-dashed transition-all"
disabled={loading}
class="flex-1 px-4 py-2 border-2 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50"
>
cancel
</button>
<button
onclick={handleSave}
class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all"
disabled={loading}
class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50"
>
save
{loading ? 'saving...' : 'save'}
</button>
</div>
</div>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { Spool } from '@lucide/svelte'
let { seed = 0 }: { seed?: number } = $props()
function seededRandom(s: number) {
return () => {
s = Math.sin(s) * 10000
return s - Math.floor(s)
}
}
let spools = $derived.by(() => {
const random = seededRandom(seed)
const count = Math.floor(random() * 4) + 5
const items = []
for (let i = 0; i < count; i++) {
items.push({
x: random() * 80 + 10,
y: random() * 80 + 10,
size: random() * 20 + 16,
rotation: random() * 360,
opacity: random() * 0.3 + 0.1
})
}
return items
})
</script>
<div class="w-full h-full relative bg-gray-50 overflow-hidden">
{#each spools as spool, i}
<div
class="absolute text-black"
style="left: {spool.x}%; top: {spool.y}%; transform: translate(-50%, -50%) rotate({spool.rotation}deg); opacity: {spool.opacity};"
>
<Spool size={spool.size} strokeWidth={1.5} />
</div>
{/each}
</div>

View file

@ -0,0 +1 @@
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'

View file

@ -1,6 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import Superscript from '$lib/components/Superscript.svelte';
import { onMount } from 'svelte'
import { LogIn } from '@lucide/svelte'
import Superscript from '$lib/components/Superscript.svelte'
import { login } from '$lib/auth-client'
let email = $state('')
function handleLogin() {
login()
}
interface ScrapItem {
id: string;
@ -94,7 +102,7 @@
</script>
<!-- Hero Section -->
<div class="h-dvh w-full overflow-hidden flex flex-col">
<div id="home" class="h-dvh w-full overflow-hidden flex flex-col">
<div class="w-full h-full md:h-full md:absolute md:inset-0 flex items-center justify-center">
<img
src="/hero.png"
@ -108,14 +116,31 @@
<p class="text-lg md:text-xl mb-1">
<strong>ys:</strong> any project<Superscript number={1} tooltip="silly, nonsensical, or fun" />
</p>
<p class="text-lg md:text-xl mb-4">
<p class="text-lg md:text-xl mb-6">
<strong>ws:</strong> a chance to win somthing amazing<Superscript number={2} tooltip="(including rare stickers)" />
</p>
<!-- Auth Section -->
<div class="flex flex-col sm:flex-row gap-3">
<input
type="email"
bind:value={email}
placeholder="your@email.com"
class="flex-1 px-4 py-3 border-4 border-black rounded-full focus:outline-none focus:border-dashed"
/>
<button
onclick={handleLogin}
class="flex items-center justify-center gap-2 px-6 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all"
>
<LogIn size={18} />
<span>sign up / login</span>
</button>
</div>
</div>
</div>
<!-- Scraps Section -->
<div class="min-h-dvh flex flex-col overflow-hidden">
<div id="scraps" class="min-h-dvh flex flex-col overflow-hidden">
<div class="px-6 md:px-12 pt-24 pb-8">
<div class="max-w-3xl mx-auto">
<h2 class="text-4xl md:text-6xl font-bold mb-2">scrapss</h2>
@ -181,7 +206,7 @@
</div>
<!-- About Section -->
<div class="min-h-dvh pt-24 px-6 md:px-12 max-w-3xl mx-auto pb-24">
<div id="about" class="min-h-dvh pt-24 px-6 md:px-12 max-w-3xl mx-auto pb-24">
<h2 class="text-4xl md:text-6xl font-bold mb-8">about scraps</h2>
<div class="prose prose-lg">

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { getUser } from '$lib/auth-client'
let loading = $state(true)
let error = $state<string | null>(null)
onMount(async () => {
try {
// Check URL for error from backend
const urlParams = new URLSearchParams(window.location.search)
const authError = urlParams.get('error')
if (authError === 'not-eligible') {
goto('/auth/error?reason=not-eligible')
return
}
// Check if we have a session now
const user = await getUser()
if (user) {
goto('/dashboard')
} else {
error = 'Authentication failed'
}
} catch (e) {
console.error('Auth callback error:', e)
error = 'An error occurred during authentication'
} finally {
loading = false
}
})
</script>
<svelte:head>
<title>authenticating... | scraps</title>
</svelte:head>
<div class="min-h-dvh flex items-center justify-center">
{#if loading}
<div class="text-center">
<h1 class="text-4xl font-bold mb-4">authenticating...</h1>
<p class="text-gray-600">please wait while we verify your account</p>
</div>
{:else if error}
<div class="text-center">
<h1 class="text-4xl font-bold mb-4 text-red-600">oops!</h1>
<p class="text-gray-600 mb-6">{error}</p>
<a
href="/"
class="px-6 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all"
>
go back home
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1 @@
export const prerender = false

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { page } from '$app/state'
import { AlertTriangle } from '@lucide/svelte'
let reason = $derived(page.url.searchParams.get('reason') || 'unknown')
const errorMessages: Record<string, { title: string; description: string }> = {
'not-eligible': {
title: 'not eligible for ysws',
description: 'your hack club account is not currently eligible for you ship we ship programs. this might be because your account needs to be verified first.'
},
'auth-failed': {
title: 'authentication failed',
description: 'we couldn\'t verify your identity. please try again.'
},
'unknown': {
title: 'something went wrong',
description: 'an unexpected error occurred. please try again later.'
}
}
let errorInfo = $derived(errorMessages[reason] || errorMessages['unknown'])
</script>
<svelte:head>
<title>error | scraps</title>
</svelte:head>
<div class="min-h-dvh flex items-center justify-center px-6">
<div class="max-w-md text-center">
<div class="flex justify-center mb-6">
<div class="w-24 h-24 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle size={48} class="text-red-600" />
</div>
</div>
<h1 class="text-4xl md:text-5xl font-bold mb-4">{errorInfo.title}</h1>
<p class="text-lg text-gray-600 mb-8">{errorInfo.description}</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a
href="/"
class="px-6 py-3 border-4 border-black rounded-full font-bold hover:border-dashed transition-all"
>
go back home
</a>
<a
href="https://hackclub.com/slack"
target="_blank"
rel="noopener noreferrer"
class="px-6 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all"
>
get help on slack
</a>
</div>
</div>
</div>

View file

@ -0,0 +1 @@
export const prerender = false

View file

@ -1,19 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import { FilePlus2, Pencil } from '@lucide/svelte'
import DashboardNavbar from '$lib/components/DashboardNavbar.svelte'
import ProjectModal from '$lib/components/ProjectModal.svelte'
import CreateProjectModal from '$lib/components/CreateProjectModal.svelte'
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte'
import { getUser } from '$lib/auth-client'
import { API_URL } from '$lib/config'
interface Project {
id: number
userId: number
userId: string
name: string
description: string
imageUrl: string
githubUrl: string
image: string | null
githubUrl: string | null
hackatimeProject: string | null
hours: number
hackatimeUrl: string
}
interface NewsItem {
@ -22,56 +26,56 @@
content: string
}
interface User {
id: number
username: string
email: string
avatar: string
slackId: string
scraps: number
}
let user = $state<User | null>(null)
let projects = $state<Project[]>([])
let latestNews = $state<NewsItem | null>(null)
let selectedProject = $state<Project | null>(null)
let screws = $state(42)
const dummyProjects: Project[] = [
{
id: 1,
userId: 1,
name: 'Blueprint',
description: 'A hackathon project for AMD',
imageUrl: '/hero.png',
githubUrl: 'https://github.com/hackclub/blueprint',
hours: 24,
hackatimeUrl: 'https://hackatime.hackclub.com/projects/blueprint'
},
{
id: 2,
userId: 1,
name: 'Flavortown',
description: 'A food discovery app',
imageUrl: '/hero.png',
githubUrl: 'https://github.com/hackclub/flavortown',
hours: 18,
hackatimeUrl: 'https://hackatime.hackclub.com/projects/flavortown'
}
]
const dummyLatestNews: NewsItem = {
id: 1,
date: 'jan 21, 2026',
content: 'remember to stay drafty!'
}
let showCreateModal = $state(false)
let screws = $derived(user?.scraps ?? 0)
onMount(async () => {
// TODO: Replace with actual API call to /api/projects
// const response = await fetch('/api/projects', {
// headers: { 'Authorization': `Bearer ${userToken}` }
// })
// projects = await response.json()
projects = dummyProjects
const userData = await getUser()
if (!userData) {
goto('/')
return
}
user = userData
// TODO: Replace with actual API call to /api/news/latest
// const newsResponse = await fetch('/api/news/latest')
// latestNews = await newsResponse.json()
latestNews = dummyLatestNews
// Fetch user's projects
try {
const projectsResponse = await fetch(`${API_URL}/projects`, {
credentials: 'include'
})
if (projectsResponse.ok) {
const data = await projectsResponse.json()
if (Array.isArray(data)) {
projects = data
}
}
} catch (e) {
console.error('Failed to fetch projects:', e)
}
// TODO: Fetch user's screw count
// const userResponse = await fetch('/api/user')
// screws = (await userResponse.json()).screws
// Fetch latest news
try {
const newsResponse = await fetch(`${API_URL}/news/latest`, {
credentials: 'include'
})
if (newsResponse.ok) {
latestNews = await newsResponse.json()
}
} catch (e) {
console.error('Failed to fetch news:', e)
}
})
function openEditModal(project: Project) {
@ -82,19 +86,18 @@
selectedProject = null
}
function handleSaveProject(updatedProject: Project) {
// TODO: Call API to update project
// await fetch(`/api/projects/${updatedProject.id}`, {
// method: 'PUT',
// body: JSON.stringify(updatedProject)
// })
async function handleSaveProject(updatedProject: Project) {
projects = projects.map((p) => (p.id === updatedProject.id ? updatedProject : p))
closeModal()
}
function createNewProject() {
// TODO: Navigate to project creation or open modal
console.log('Create new project')
showCreateModal = true
}
function handleProjectCreated(newProject: Project) {
projects = [...projects, newProject]
showCreateModal = false
}
</script>
@ -102,29 +105,42 @@
<title>dashboard | scraps</title>
</svelte:head>
<DashboardNavbar {screws} />
<DashboardNavbar {screws} {user} />
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
<!-- Greeting -->
{#if user}
<h1 class="text-4xl md:text-5xl font-bold mb-8">hello, {user.username || 'friend'}</h1>
{/if}
<!-- Projects Section -->
<div class="mb-12">
<div class="flex gap-6 overflow-x-auto pb-4 scrollbar-hide">
{#each projects as project (project.id)}
<button
onclick={() => openEditModal(project)}
class="shrink-0 w-80 h-64 rounded-2xl border-4 border-black overflow-hidden relative group bg-[#1a365d] cursor-pointer transition-all hover:border-dashed"
class="shrink-0 w-80 h-64 rounded-2xl border-4 border-black overflow-hidden relative group bg-white cursor-pointer transition-all hover:border-dashed flex flex-col"
>
<div class="absolute inset-0 flex items-center justify-center p-6">
<img
src={project.imageUrl}
alt={project.name}
class="max-w-full max-h-full object-contain"
/>
<div class="flex-1 flex items-center justify-center p-6 overflow-hidden">
{#if project.image}
<img
src={project.image}
alt={project.name}
class="max-w-full max-h-full object-contain"
/>
{:else}
<ProjectPlaceholder seed={project.id} />
{/if}
</div>
<div class="px-4 py-3 border-t-2 border-black bg-white flex items-center justify-between">
<span class="font-bold text-lg truncate">{project.name}</span>
<span class="text-gray-500 text-sm shrink-0">{project.hours}h</span>
</div>
<div
class="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 border-2 border-dashed border-black/50 rounded-full bg-white/90 opacity-0 group-hover:opacity-100 transition-opacity"
class="absolute bottom-12 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 border-2 border-dashed border-black/50 rounded-full bg-white/90 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Pencil size={16} />
<span class="font-bold">edit draft</span>
<span class="font-bold">edit project</span>
</div>
</button>
{/each}
@ -132,10 +148,10 @@
<!-- New Draft Card -->
<button
onclick={createNewProject}
class="shrink-0 w-80 h-64 rounded-2xl border-4 border-black flex flex-col items-center justify-center gap-4 cursor-pointer transition-all hover:border-dashed bg-white"
class="shrink-0 w-80 h-64 rounded-2xl border-4 border-black flex flex-col items-center justify-center gap-4 cursor-pointer transition-all border-dashed hover:border-solid bg-white"
>
<FilePlus2 size={64} strokeWidth={1.5} />
<span class="text-2xl font-bold">new draft</span>
<span class="text-2xl font-bold">new project</span>
</button>
</div>
</div>
@ -168,6 +184,7 @@
</div>
<ProjectModal project={selectedProject} onClose={closeModal} onSave={handleSaveProject} />
<CreateProjectModal open={showCreateModal} onClose={() => showCreateModal = false} onCreated={handleProjectCreated} />
<style>
.scrollbar-hide {

View file

@ -0,0 +1 @@
export const prerender = false

View file

@ -1,128 +1,58 @@
<script lang="ts">
import { onMount } from 'svelte'
import DashboardNavbar from '$lib/components/DashboardNavbar.svelte'
interface ScrapItem {
id: number
name: string
image: string
}
import { API_URL } from '$lib/config'
import { getUser } from '$lib/auth-client'
interface LeaderboardEntry {
rank: number
name: string
id: number
username: string
avatar: string
hours: number
projects: number
scraps: ScrapItem[]
scraps: number
projectCount: number
}
interface User {
id: number
username: string
email: string
avatar: string | null
slackId: string | null
scraps: number
}
let leaderboard = $state<LeaderboardEntry[]>([])
let screws = $state(42)
let loading = $state(true)
let sortBy = $state<'hours' | 'scraps'>('scraps')
let user = $state<User | null>(null)
let screws = $derived(user?.scraps ?? 0)
const dummyLeaderboard: LeaderboardEntry[] = [
{
rank: 1,
name: 'zrl',
avatar: 'https://avatars.githubusercontent.com/u/1234567?v=4',
hours: 156,
projects: 12,
scraps: [
{ id: 1, name: 'esp32', image: '/hero.png' },
{ id: 2, name: 'rare sticker', image: '/hero.png' },
{ id: 3, name: 'fudge', image: '/hero.png' }
]
},
{
rank: 2,
name: 'msw',
avatar: 'https://avatars.githubusercontent.com/u/2345678?v=4',
hours: 142,
projects: 8,
scraps: [
{ id: 4, name: 'arduino', image: '/hero.png' },
{ id: 5, name: 'postcard', image: '/hero.png' }
]
},
{
rank: 3,
name: 'belle',
avatar: 'https://avatars.githubusercontent.com/u/3456789?v=4',
hours: 128,
projects: 15,
scraps: [
{ id: 6, name: 'sensor kit', image: '/hero.png' },
{ id: 7, name: 'breadboard', image: '/hero.png' },
{ id: 8, name: 'sticker', image: '/hero.png' },
{ id: 9, name: 'fudge', image: '/hero.png' }
]
},
{
rank: 4,
name: 'sam',
avatar: 'https://avatars.githubusercontent.com/u/4567890?v=4',
hours: 98,
projects: 6,
scraps: [{ id: 10, name: 'resistors', image: '/hero.png' }]
},
{
rank: 5,
name: 'max',
avatar: 'https://avatars.githubusercontent.com/u/5678901?v=4',
hours: 87,
projects: 9,
scraps: []
},
{
rank: 6,
name: 'orpheus',
avatar: 'https://avatars.githubusercontent.com/u/6789012?v=4',
hours: 76,
projects: 4,
scraps: [
{ id: 11, name: 'esp32', image: '/hero.png' },
{ id: 12, name: 'postcard', image: '/hero.png' }
]
},
{
rank: 7,
name: 'heidi',
avatar: 'https://avatars.githubusercontent.com/u/7890123?v=4',
hours: 65,
projects: 7,
scraps: [{ id: 13, name: 'sticker', image: '/hero.png' }]
},
{
rank: 8,
name: 'leo',
avatar: 'https://avatars.githubusercontent.com/u/8901234?v=4',
hours: 54,
projects: 3,
scraps: []
},
{
rank: 9,
name: 'claire',
avatar: 'https://avatars.githubusercontent.com/u/9012345?v=4',
hours: 43,
projects: 5,
scraps: [{ id: 14, name: 'fudge', image: '/hero.png' }]
},
{
rank: 10,
name: 'thomas',
avatar: 'https://avatars.githubusercontent.com/u/1234560?v=4',
hours: 32,
projects: 2,
scraps: []
async function fetchLeaderboard() {
loading = true
try {
const response = await fetch(`${API_URL}/leaderboard?sortBy=${sortBy}`, {
credentials: 'include'
})
if (response.ok) {
leaderboard = await response.json()
}
} catch (error) {
console.error('Failed to fetch leaderboard:', error)
} finally {
loading = false
}
]
}
function setSortBy(value: 'hours' | 'scraps') {
sortBy = value
fetchLeaderboard()
}
onMount(async () => {
// TODO: Replace with actual API call to /api/leaderboard
// const response = await fetch('/api/leaderboard')
// leaderboard = await response.json()
leaderboard = dummyLeaderboard
user = await getUser()
fetchLeaderboard()
})
</script>
@ -130,76 +60,77 @@
<title>leaderboard | scraps</title>
</svelte:head>
<DashboardNavbar {screws} />
<DashboardNavbar {screws} {user} />
<div class="pt-24 px-6 md:px-12 max-w-5xl mx-auto pb-24">
<h1 class="text-4xl md:text-6xl font-bold mb-8">leaderboard</h1>
<div class="flex gap-2 mb-6">
<button
class="px-4 py-2 border-2 border-black rounded-full font-bold transition-all duration-200 {sortBy === 'scraps'
? 'bg-black text-white'
: 'hover:border-dashed'}"
onclick={() => setSortBy('scraps')}
>
scraps
</button>
<button
class="px-4 py-2 border-2 border-black rounded-full font-bold transition-all duration-200 {sortBy === 'hours'
? 'bg-black text-white'
: 'hover:border-dashed'}"
onclick={() => setSortBy('hours')}
>
hours
</button>
</div>
<div class="border-4 border-black rounded-2xl overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b-4 border-black bg-black text-white">
<th class="px-4 py-4 text-left font-bold">rank</th>
<th class="px-4 py-4 text-left font-bold">user</th>
<th class="px-4 py-4 text-right font-bold">hours</th>
<th class="px-4 py-4 text-right font-bold">projects</th>
<th class="px-4 py-4 text-left font-bold">scraps</th>
</tr>
</thead>
<tbody>
{#each leaderboard as entry (entry.rank)}
<tr class="border-b-2 border-black/20 last:border-b-0 hover:bg-gray-50 transition-colors">
<td class="px-4 py-4 font-bold text-2xl">
{#if entry.rank === 1}
🥇
{:else if entry.rank === 2}
🥈
{:else if entry.rank === 3}
🥉
{:else}
{entry.rank}
{/if}
</td>
<td class="px-4 py-4">
<div class="flex items-center gap-3">
<img
src={entry.avatar}
alt={entry.name}
class="w-10 h-10 rounded-full object-cover border-2 border-black"
/>
<span class="font-bold text-lg">{entry.name}</span>
</div>
</td>
<td class="px-4 py-4 text-right text-lg">{entry.hours}h</td>
<td class="px-4 py-4 text-right text-lg">{entry.projects}</td>
<td class="px-4 py-4">
{#if entry.scraps.length > 0}
<div class="flex items-center -space-x-2">
{#each entry.scraps.slice(0, 4) as scrap, i}
<div
class="w-8 h-8 rounded-full bg-gray-100 border-2 border-white flex items-center justify-center overflow-hidden"
style="z-index: {4 - i}"
title={scrap.name}
>
<img src={scrap.image} alt={scrap.name} class="w-6 h-6 object-contain" />
</div>
{/each}
{#if entry.scraps.length > 4}
<div
class="w-8 h-8 rounded-full bg-black text-white border-2 border-white flex items-center justify-center text-xs font-bold"
style="z-index: 0"
>
+{entry.scraps.length - 4}
</div>
{/if}
</div>
{:else}
<span class="text-gray-400 text-sm">none yet</span>
{/if}
</td>
{#if loading}
<div class="p-8 text-center text-gray-500">loading...</div>
{:else}
<table class="w-full">
<thead>
<tr class="border-b-4 border-black bg-black text-white">
<th class="px-4 py-4 text-left font-bold">rank</th>
<th class="px-4 py-4 text-left font-bold">user</th>
<th class="px-4 py-4 text-right font-bold">hours</th>
<th class="px-4 py-4 text-right font-bold">projects</th>
<th class="px-4 py-4 text-right font-bold">scraps</th>
</tr>
{/each}
</tbody>
</table>
</thead>
<tbody>
{#each leaderboard as entry (entry.id)}
<tr
class="border-b-2 border-black/20 last:border-b-0 hover:bg-gray-50 transition-colors"
>
<td class="px-4 py-4 font-bold text-2xl">
{#if entry.rank === 1}
🥇
{:else if entry.rank === 2}
🥈
{:else if entry.rank === 3}
🥉
{:else}
{entry.rank}
{/if}
</td>
<td class="px-4 py-4">
<div class="flex items-center gap-3">
<img
src={entry.avatar}
alt={entry.username}
class="w-10 h-10 rounded-full object-cover border-2 border-black"
/>
<span class="font-bold text-lg">{entry.username}</span>
</div>
</td>
<td class="px-4 py-4 text-right text-lg">{entry.hours}h</td>
<td class="px-4 py-4 text-right text-lg">{entry.projectCount}</td>
<td class="px-4 py-4 text-right text-lg font-bold">{entry.scraps}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>

View file

@ -0,0 +1 @@
export const prerender = false

View file

@ -1,9 +1,24 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Flame, ArrowRight } from '@lucide/svelte'
import DashboardNavbar from '$lib/components/DashboardNavbar.svelte'
import { getUser } from '$lib/auth-client'
let screws = $state(42)
interface User {
id: number
username: string
email: string
avatar: string | null
slackId: string | null
scraps: number
}
let user = $state<User | null>(null)
let screws = $derived(user?.scraps ?? 0)
onMount(async () => {
user = await getUser()
})
interface RefineRecipe {
id: number
@ -54,11 +69,11 @@
<title>refinery | scraps</title>
</svelte:head>
<DashboardNavbar {screws} />
<DashboardNavbar {screws} {user} />
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">
<div class="flex items-center gap-4 mb-4">
<Flame size={48} />
<!-- <Flame size={48} /> -->
<h1 class="text-4xl md:text-6xl font-bold">refinery</h1>
</div>
<p class="text-lg text-gray-600 mb-8">upgrade and combine your scraps</p>

View file

@ -0,0 +1 @@
export const prerender = false

View file

@ -1,48 +1,38 @@
<script lang="ts">
import { onMount } from 'svelte'
import DashboardNavbar from '$lib/components/DashboardNavbar.svelte'
import WishlistAvatars from '$lib/components/WishlistAvatars.svelte'
interface WishlistUser {
id: number
name: string
avatar: string
}
import HeartButton from '$lib/components/HeartButton.svelte'
import { API_URL } from '$lib/config'
import { getUser } from '$lib/auth-client'
interface ShopItem {
id: number
name: string
description: string
image: string
chance: number
price: number
category: string
wishlistUsers: WishlistUser[]
count: number
heartCount: number
userHearted: boolean
}
interface User {
id: number
username: string
email: string
avatar: string | null
slackId: string | null
scraps: number
}
let items = $state<ShopItem[]>([])
let screws = $state(42)
let selectedCategory = $state('all')
let loading = $state(true)
let user = $state<User | null>(null)
let screws = $derived(user?.scraps ?? 0)
const dummyUsers: WishlistUser[] = [
{ id: 1, name: 'zrl', avatar: 'https://avatars.githubusercontent.com/u/1234567?v=4' },
{ id: 2, name: 'msw', avatar: 'https://avatars.githubusercontent.com/u/2345678?v=4' },
{ id: 3, name: 'belle', avatar: 'https://avatars.githubusercontent.com/u/3456789?v=4' },
{ id: 4, name: 'sam', avatar: 'https://avatars.githubusercontent.com/u/4567890?v=4' },
{ id: 5, name: 'orpheus', avatar: 'https://avatars.githubusercontent.com/u/5678901?v=4' }
]
const dummyItems: ShopItem[] = [
{ id: 1, name: 'esp32', description: 'a tiny microcontroller', image: '/hero.png', chance: 15, category: 'hardware', wishlistUsers: dummyUsers.slice(0, 4) },
{ id: 2, name: 'arduino nano', description: 'compact arduino board', image: '/hero.png', chance: 10, category: 'hardware', wishlistUsers: dummyUsers.slice(1, 3) },
{ id: 3, name: 'breadboard', description: 'for prototyping', image: '/hero.png', chance: 20, category: 'hardware', wishlistUsers: [] },
{ id: 4, name: 'resistor pack', description: 'assorted resistors', image: '/hero.png', chance: 25, category: 'hardware', wishlistUsers: dummyUsers.slice(0, 1) },
{ id: 5, name: 'vermont fudge', description: 'delicious!', image: '/hero.png', chance: 5, category: 'food', wishlistUsers: dummyUsers },
{ id: 6, name: 'rare sticker', description: 'limited edition', image: '/hero.png', chance: 8, category: 'sticker', wishlistUsers: dummyUsers.slice(2, 5) },
{ id: 7, name: 'postcard', description: 'from hq', image: '/hero.png', chance: 12, category: 'misc', wishlistUsers: dummyUsers.slice(0, 2) },
{ id: 8, name: 'sensor kit', description: 'various sensors', image: '/hero.png', chance: 5, category: 'hardware', wishlistUsers: [] }
]
const categories = ['all', 'hardware', 'sticker', 'food', 'misc']
let categories = $derived(['all', ...new Set(items.map((item) => item.category))])
let filteredItems = $derived(
selectedCategory === 'all'
@ -51,15 +41,42 @@
)
onMount(async () => {
// TODO: Replace with actual API call to /api/items
// const response = await fetch('/api/items')
// items = await response.json()
items = dummyItems
user = await getUser()
try {
const response = await fetch(`${API_URL}/shop/items`, {
credentials: 'include'
})
if (response.ok) {
items = await response.json()
}
} catch (error) {
console.error('Failed to fetch shop items:', error)
} finally {
loading = false
}
})
function addToWishlist(itemId: number) {
// TODO: Call API to add item to user's wishlist
console.log('Adding to wishlist:', itemId)
async function toggleHeart(itemId: number) {
try {
const response = await fetch(`${API_URL}/shop/items/${itemId}/heart`, {
method: 'POST',
credentials: 'include'
})
if (response.ok) {
items = items.map((item) => {
if (item.id === itemId) {
return {
...item,
userHearted: !item.userHearted,
heartCount: item.userHearted ? item.heartCount - 1 : item.heartCount + 1
}
}
return item
})
}
} catch (error) {
console.error('Failed to toggle heart:', error)
}
}
</script>
@ -67,7 +84,7 @@
<title>shop | scraps</title>
</svelte:head>
<DashboardNavbar {screws} />
<DashboardNavbar {screws} {user} />
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
<h1 class="text-4xl md:text-6xl font-bold mb-4">shop</h1>
@ -87,26 +104,37 @@
{/each}
</div>
<!-- Items Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{#each filteredItems as item (item.id)}
<div
class="border-4 border-black rounded-2xl p-4 hover:border-dashed transition-all"
>
<img src={item.image} alt={item.name} class="w-full h-32 object-contain mb-4" />
<h3 class="font-bold text-xl mb-1">{item.name}</h3>
<p class="text-sm text-gray-600 mb-2">{item.description}</p>
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-bold">{item.chance}% chance</span>
<span class="text-xs px-2 py-1 bg-gray-100 rounded-full">{item.category}</span>
<!-- Loading State -->
{#if loading}
<div class="text-center py-12">
<p class="text-gray-600">Loading items...</p>
</div>
{:else if items.length === 0}
<div class="text-center py-12">
<p class="text-gray-600">No items available</p>
</div>
{:else}
<!-- Items Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{#each filteredItems as item (item.id)}
<div class="border-4 border-black rounded-2xl p-4 hover:border-dashed transition-all">
<img src={item.image} alt={item.name} class="w-full h-32 object-contain mb-4" />
<h3 class="font-bold text-xl mb-1">{item.name}</h3>
<p class="text-sm text-gray-600 mb-2">{item.description}</p>
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-bold">{item.price} screws</span>
<span class="text-xs px-2 py-1 bg-gray-100 rounded-full">{item.category}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">{item.count} left</span>
<HeartButton
count={item.heartCount}
hearted={item.userHearted}
onclick={() => toggleHeart(item.id)}
/>
</div>
</div>
<div class="flex items-center justify-between">
<WishlistAvatars
users={item.wishlistUsers}
onWishlist={() => addToWishlist(item.id)}
/>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1 @@
export const prerender = false

View file

@ -4,13 +4,11 @@ import adapter from '@sveltejs/adapter-static';
const config = {
kit: {
adapter: adapter({
// default options are shown. On some platforms
// these options are set automatically — see below
pages: 'build',
assets: 'build',
fallback: undefined,
fallback: '200.html',
precompress: false,
strict: true
strict: false
})
}
};