mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 18:35:20 +00:00
sutff
This commit is contained in:
parent
c504f31eff
commit
257b6fa02f
46 changed files with 31553 additions and 446 deletions
48
AGENTS.md
48
AGENTS.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
28771
backend/dist/index.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
60
backend/drizzle/0000_cool_marten_broadcloak.sql
Normal file
60
backend/drizzle/0000_cool_marten_broadcloak.sql
Normal 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;
|
||||
445
backend/drizzle/meta/0000_snapshot.json
Normal file
445
backend/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
backend/drizzle/meta/_journal.json
Normal file
13
backend/drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1769805937386,
|
||||
"tag": "0000_cool_marten_broadcloak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
8
backend/scripts/reset-projects.ts
Normal file
8
backend/scripts/reset-projects.ts
Normal 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
2
backend/src/db/schema.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from '../schemas/users'
|
||||
export * from '../schemas/shop'
|
||||
|
|
@ -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
209
backend/src/lib/auth.ts
Normal 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
48
backend/src/lib/slack.ts
Normal 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
106
backend/src/routes/auth.ts
Normal 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
|
||||
64
backend/src/routes/hackatime.ts
Normal file
64
backend/src/routes/hackatime.ts
Normal 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
|
||||
69
backend/src/routes/leaderboard.ts
Normal file
69
backend/src/routes/leaderboard.ts
Normal 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
|
||||
|
|
@ -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!"
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
145
backend/src/routes/shop.ts
Normal 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
|
||||
74
backend/src/routes/upload.ts
Normal file
74
backend/src/routes/upload.ts
Normal 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
|
||||
33
backend/src/routes/user.ts
Normal file
33
backend/src/routes/user.ts
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
9
backend/src/schemas/sessions.ts
Normal file
9
backend/src/schemas/sessions.ts
Normal 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()
|
||||
})
|
||||
23
backend/src/schemas/shop.ts
Normal file
23
backend/src/schemas/shop.ts
Normal 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)
|
||||
])
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
26
frontend/src/lib/auth-client.ts
Normal file
26
frontend/src/lib/auth-client.ts
Normal 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
|
||||
}
|
||||
}
|
||||
323
frontend/src/lib/components/CreateProjectModal.svelte
Normal file
323
frontend/src/lib/components/CreateProjectModal.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
26
frontend/src/lib/components/HeartButton.svelte
Normal file
26
frontend/src/lib/components/HeartButton.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
41
frontend/src/lib/components/ProjectPlaceholder.svelte
Normal file
41
frontend/src/lib/components/ProjectPlaceholder.svelte
Normal 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>
|
||||
1
frontend/src/lib/config.ts
Normal file
1
frontend/src/lib/config.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
58
frontend/src/routes/auth/callback/+page.svelte
Normal file
58
frontend/src/routes/auth/callback/+page.svelte
Normal 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>
|
||||
1
frontend/src/routes/auth/callback/+page.ts
Normal file
1
frontend/src/routes/auth/callback/+page.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false
|
||||
57
frontend/src/routes/auth/error/+page.svelte
Normal file
57
frontend/src/routes/auth/error/+page.svelte
Normal 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>
|
||||
1
frontend/src/routes/auth/error/+page.ts
Normal file
1
frontend/src/routes/auth/error/+page.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
1
frontend/src/routes/dashboard/+page.ts
Normal file
1
frontend/src/routes/dashboard/+page.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
1
frontend/src/routes/leaderboard/+page.ts
Normal file
1
frontend/src/routes/leaderboard/+page.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
1
frontend/src/routes/refinery/+page.ts
Normal file
1
frontend/src/routes/refinery/+page.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
1
frontend/src/routes/shop/+page.ts
Normal file
1
frontend/src/routes/shop/+page.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const prerender = false
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue