before mobile optimize

This commit is contained in:
Nathan 2026-02-03 15:56:26 -05:00
parent f5f688e4fa
commit fc66096949
60 changed files with 5112 additions and 842 deletions

View file

@ -0,0 +1,116 @@
# Details
Date : 2026-02-03 15:48:00
Directory c:\\Users\\Nathan\\Documents\\GitHub\\scraps
Total : 101 files, 40545 codes, 597 comments, 2288 blanks, all 43430 lines
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
| [AGENTS.md](/AGENTS.md) | Markdown | 98 | 2 | 21 | 121 |
| [README.md](/README.md) | Markdown | 3 | 0 | 2 | 5 |
| [backend/Dockerfile](/backend/Dockerfile) | Docker | 14 | 2 | 10 | 26 |
| [backend/README.md](/backend/README.md) | Markdown | 1 | 0 | 0 | 1 |
| [backend/bun.lock](/backend/bun.lock) | JSON with Comments | 170 | 0 | 145 | 315 |
| [backend/dist/index.js](/backend/dist/index.js) | JavaScript | 29,463 | 323 | 446 | 30,232 |
| [backend/drizzle.config.ts](/backend/drizzle.config.ts) | TypeScript | 10 | 0 | 2 | 12 |
| [backend/package.json](/backend/package.json) | JSON | 25 | 0 | 1 | 26 |
| [backend/scripts/reset-projects.ts](/backend/scripts/reset-projects.ts) | TypeScript | 6 | 0 | 3 | 9 |
| [backend/src/config.ts](/backend/src/config.ts) | TypeScript | 19 | 5 | 8 | 32 |
| [backend/src/db/index.ts](/backend/src/db/index.ts) | TypeScript | 3 | 0 | 2 | 5 |
| [backend/src/db/schema.ts](/backend/src/db/schema.ts) | TypeScript | 2 | 0 | 1 | 3 |
| [backend/src/index.ts](/backend/src/index.ts) | TypeScript | 35 | 0 | 4 | 39 |
| [backend/src/lib/auth.ts](/backend/src/lib/auth.ts) | TypeScript | 181 | 0 | 30 | 211 |
| [backend/src/lib/scraps.ts](/backend/src/lib/scraps.ts) | TypeScript | 52 | 1 | 10 | 63 |
| [backend/src/lib/slack.ts](/backend/src/lib/slack.ts) | TypeScript | 42 | 0 | 7 | 49 |
| [backend/src/routes/admin.ts](/backend/src/routes/admin.ts) | TypeScript | 505 | 14 | 109 | 628 |
| [backend/src/routes/auth.ts](/backend/src/routes/auth.ts) | TypeScript | 100 | 4 | 17 | 121 |
| [backend/src/routes/hackatime.ts](/backend/src/routes/hackatime.ts) | TypeScript | 53 | 0 | 13 | 66 |
| [backend/src/routes/items.ts](/backend/src/routes/items.ts) | TypeScript | 73 | 5 | 5 | 83 |
| [backend/src/routes/leaderboard.ts](/backend/src/routes/leaderboard.ts) | TypeScript | 146 | 0 | 21 | 167 |
| [backend/src/routes/news.ts](/backend/src/routes/news.ts) | TypeScript | 23 | 2 | 7 | 32 |
| [backend/src/routes/projects.ts](/backend/src/routes/projects.ts) | TypeScript | 329 | 10 | 63 | 402 |
| [backend/src/routes/shop.ts](/backend/src/routes/shop.ts) | TypeScript | 653 | 2 | 122 | 777 |
| [backend/src/routes/upload.ts](/backend/src/routes/upload.ts) | TypeScript | 60 | 0 | 16 | 76 |
| [backend/src/routes/user.ts](/backend/src/routes/user.ts) | TypeScript | 141 | 4 | 23 | 168 |
| [backend/src/schemas/activity.ts](/backend/src/schemas/activity.ts) | TypeScript | 10 | 0 | 2 | 12 |
| [backend/src/schemas/index.ts](/backend/src/schemas/index.ts) | TypeScript | 7 | 0 | 1 | 8 |
| [backend/src/schemas/news.ts](/backend/src/schemas/news.ts) | TypeScript | 9 | 0 | 2 | 11 |
| [backend/src/schemas/projects.ts](/backend/src/schemas/projects.ts) | TypeScript | 19 | 8 | 8 | 35 |
| [backend/src/schemas/reviews.ts](/backend/src/schemas/reviews.ts) | TypeScript | 12 | 0 | 4 | 16 |
| [backend/src/schemas/sessions.ts](/backend/src/schemas/sessions.ts) | TypeScript | 8 | 0 | 2 | 10 |
| [backend/src/schemas/shop.ts](/backend/src/schemas/shop.ts) | TypeScript | 66 | 0 | 7 | 73 |
| [backend/src/schemas/users.ts](/backend/src/schemas/users.ts) | TypeScript | 24 | 5 | 9 | 38 |
| [backend/tsconfig.json](/backend/tsconfig.json) | JSON with Comments | 12 | 83 | 9 | 104 |
| [frontend/.prettierignore](/frontend/.prettierignore) | Ignore | 6 | 2 | 2 | 10 |
| [frontend/.prettierrc](/frontend/.prettierrc) | JSON | 16 | 0 | 1 | 17 |
| [frontend/Dockerfile](/frontend/Dockerfile) | Docker | 13 | 2 | 10 | 25 |
| [frontend/bun.lock](/frontend/bun.lock) | JSON with Comments | 397 | 0 | 359 | 756 |
| [frontend/eslint.config.js](/frontend/eslint.config.js) | JavaScript | 35 | 2 | 3 | 40 |
| [frontend/nginx.conf](/frontend/nginx.conf) | Properties | 15 | 3 | 4 | 22 |
| [frontend/package.json](/frontend/package.json) | JSON | 47 | 0 | 1 | 48 |
| [frontend/src/app.d.ts](/frontend/src/app.d.ts) | TypeScript | 5 | 7 | 2 | 14 |
| [frontend/src/app.html](/frontend/src/app.html) | HTML | 11 | 0 | 1 | 12 |
| [frontend/src/lib/auth-client.ts](/frontend/src/lib/auth-client.ts) | TypeScript | 63 | 0 | 11 | 74 |
| [frontend/src/lib/components/AddressSelectModal.svelte](/frontend/src/lib/components/AddressSelectModal.svelte) | Svelte | 315 | 0 | 28 | 343 |
| [frontend/src/lib/components/ConfirmModal.svelte](/frontend/src/lib/components/ConfirmModal.svelte) | Svelte | 69 | 0 | 4 | 73 |
| [frontend/src/lib/components/CreateProjectModal.svelte](/frontend/src/lib/components/CreateProjectModal.svelte) | Svelte | 350 | 6 | 38 | 394 |
| [frontend/src/lib/components/Footer.svelte](/frontend/src/lib/components/Footer.svelte) | Svelte | 5 | 0 | 2 | 7 |
| [frontend/src/lib/components/HeartButton.svelte](/frontend/src/lib/components/HeartButton.svelte) | Svelte | 24 | 0 | 3 | 27 |
| [frontend/src/lib/components/Navbar.svelte](/frontend/src/lib/components/Navbar.svelte) | Svelte | 300 | 8 | 25 | 333 |
| [frontend/src/lib/components/NewsCarousel.svelte](/frontend/src/lib/components/NewsCarousel.svelte) | Svelte | 96 | 0 | 13 | 109 |
| [frontend/src/lib/components/ProjectModal.svelte](/frontend/src/lib/components/ProjectModal.svelte) | Svelte | 343 | 3 | 35 | 381 |
| [frontend/src/lib/components/ProjectPlaceholder.svelte](/frontend/src/lib/components/ProjectPlaceholder.svelte) | Svelte | 35 | 0 | 7 | 42 |
| [frontend/src/lib/components/RandomPhrase.svelte](/frontend/src/lib/components/RandomPhrase.svelte) | Svelte | 15 | 0 | 3 | 18 |
| [frontend/src/lib/components/ShopItemModal.svelte](/frontend/src/lib/components/ShopItemModal.svelte) | Svelte | 389 | 0 | 30 | 419 |
| [frontend/src/lib/components/Superscript.svelte](/frontend/src/lib/components/Superscript.svelte) | Svelte | 11 | 0 | 2 | 13 |
| [frontend/src/lib/components/Tutorial.svelte](/frontend/src/lib/components/Tutorial.svelte) | Svelte | 359 | 15 | 33 | 407 |
| [frontend/src/lib/components/WishlistAvatars.svelte](/frontend/src/lib/components/WishlistAvatars.svelte) | Svelte | 108 | 3 | 12 | 123 |
| [frontend/src/lib/config.ts](/frontend/src/lib/config.ts) | TypeScript | 1 | 0 | 1 | 2 |
| [frontend/src/lib/stores.ts](/frontend/src/lib/stores.ts) | TypeScript | 235 | 7 | 35 | 277 |
| [frontend/src/lib/utils.ts](/frontend/src/lib/utils.ts) | TypeScript | 3 | 0 | 1 | 4 |
| [frontend/src/routes/+layout.svelte](/frontend/src/routes/+layout.svelte) | Svelte | 51 | 0 | 9 | 60 |
| [frontend/src/routes/+layout.ts](/frontend/src/routes/+layout.ts) | TypeScript | 1 | 0 | 0 | 1 |
| [frontend/src/routes/+page.svelte](/frontend/src/routes/+page.svelte) | Svelte | 249 | 6 | 40 | 295 |
| [frontend/src/routes/admin/news/+page.svelte](/frontend/src/routes/admin/news/+page.svelte) | Svelte | 307 | 0 | 32 | 339 |
| [frontend/src/routes/admin/reviews/+page.svelte](/frontend/src/routes/admin/reviews/+page.svelte) | Svelte | 123 | 1 | 12 | 136 |
| [frontend/src/routes/admin/reviews/\[id\]/+page.svelte](/frontend/src/routes/admin/reviews/%5Bid%5D/+page.svelte) | Svelte | 472 | 7 | 37 | 516 |
| [frontend/src/routes/admin/shop/+page.svelte](/frontend/src/routes/admin/shop/+page.svelte) | Svelte | 400 | 0 | 34 | 434 |
| [frontend/src/routes/admin/users/+page.svelte](/frontend/src/routes/admin/users/+page.svelte) | Svelte | 291 | 3 | 26 | 320 |
| [frontend/src/routes/admin/users/\[id\]/+page.svelte](/frontend/src/routes/admin/users/%5Bid%5D/+page.svelte) | Svelte | 313 | 4 | 25 | 342 |
| [frontend/src/routes/admin/users/\[id\]/+page.ts](/frontend/src/routes/admin/users/%5Bid%5D/+page.ts) | TypeScript | 5 | 0 | 1 | 6 |
| [frontend/src/routes/auth/callback/+page.svelte](/frontend/src/routes/auth/callback/+page.svelte) | Svelte | 52 | 0 | 7 | 59 |
| [frontend/src/routes/auth/callback/+page.ts](/frontend/src/routes/auth/callback/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
| [frontend/src/routes/auth/error/+page.svelte](/frontend/src/routes/auth/error/+page.svelte) | Svelte | 50 | 0 | 8 | 58 |
| [frontend/src/routes/auth/error/+page.ts](/frontend/src/routes/auth/error/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
| [frontend/src/routes/dashboard/+page.svelte](/frontend/src/routes/dashboard/+page.svelte) | Svelte | 113 | 4 | 14 | 131 |
| [frontend/src/routes/dashboard/+page.ts](/frontend/src/routes/dashboard/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
| [frontend/src/routes/layout.css](/frontend/src/routes/layout.css) | PostCSS | 20 | 1 | 4 | 25 |
| [frontend/src/routes/leaderboard/+page.svelte](/frontend/src/routes/leaderboard/+page.svelte) | Svelte | 156 | 0 | 8 | 164 |
| [frontend/src/routes/leaderboard/+page.ts](/frontend/src/routes/leaderboard/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
| [frontend/src/routes/project/\[id\]/+page.svelte](/frontend/src/routes/project/%5Bid%5D/+page.svelte) | Svelte | 186 | 4 | 23 | 213 |
| [frontend/src/routes/projects/\[id\]/+page.svelte](/frontend/src/routes/projects/%5Bid%5D/+page.svelte) | Svelte | 349 | 7 | 26 | 382 |
| [frontend/src/routes/projects/\[id\]/+page.ts](/frontend/src/routes/projects/%5Bid%5D/+page.ts) | TypeScript | 6 | 0 | 2 | 8 |
| [frontend/src/routes/projects/\[id\]/edit/+page.svelte](/frontend/src/routes/projects/%5Bid%5D/edit/+page.svelte) | Svelte | 399 | 9 | 44 | 452 |
| [frontend/src/routes/projects/\[id\]/edit/+page.ts](/frontend/src/routes/projects/%5Bid%5D/edit/+page.ts) | TypeScript | 6 | 0 | 2 | 8 |
| [frontend/src/routes/projects/\[id\]/submit/+page.svelte](/frontend/src/routes/projects/%5Bid%5D/submit/+page.svelte) | Svelte | 330 | 9 | 32 | 371 |
| [frontend/src/routes/projects/\[id\]/submit/+page.ts](/frontend/src/routes/projects/%5Bid%5D/submit/+page.ts) | TypeScript | 6 | 0 | 2 | 8 |
| [frontend/src/routes/projects/\[id\]/view/+page.svelte](/frontend/src/routes/projects/%5Bid%5D/view/+page.svelte) | Svelte | 148 | 3 | 17 | 168 |
| [frontend/src/routes/projects/\[id\]/view/+page.ts](/frontend/src/routes/projects/%5Bid%5D/view/+page.ts) | TypeScript | 6 | 0 | 2 | 8 |
| [frontend/src/routes/refinery/+page.svelte](/frontend/src/routes/refinery/+page.svelte) | Svelte | 134 | 0 | 10 | 144 |
| [frontend/src/routes/refinery/+page.ts](/frontend/src/routes/refinery/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
| [frontend/src/routes/shop/+page.svelte](/frontend/src/routes/shop/+page.svelte) | Svelte | 258 | 5 | 23 | 286 |
| [frontend/src/routes/shop/+page.ts](/frontend/src/routes/shop/+page.ts) | TypeScript | 1 | 0 | 1 | 2 |
| [frontend/src/routes/submit/+page.svelte](/frontend/src/routes/submit/+page.svelte) | Svelte | 151 | 0 | 19 | 170 |
| [frontend/src/routes/users/\[id\]/+page.svelte](/frontend/src/routes/users/%5Bid%5D/+page.svelte) | Svelte | 277 | 5 | 18 | 300 |
| [frontend/src/routes/users/\[id\]/+page.ts](/frontend/src/routes/users/%5Bid%5D/+page.ts) | TypeScript | 5 | 0 | 1 | 6 |
| [frontend/static/site.webmanifest](/frontend/static/site.webmanifest) | JSON | 1 | 0 | 0 | 1 |
| [frontend/svelte.config.js](/frontend/svelte.config.js) | JavaScript | 16 | 1 | 2 | 19 |
| [frontend/tsconfig.json](/frontend/tsconfig.json) | JSON with Comments | 14 | 0 | 1 | 15 |
| [frontend/vite.config.ts](/frontend/vite.config.ts) | TypeScript | 4 | 0 | 3 | 7 |
[Summary](results.md) / Details / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View file

@ -0,0 +1,15 @@
# Diff Details
Date : 2026-02-03 15:48:00
Directory c:\\Users\\Nathan\\Documents\\GitHub\\scraps
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details
## Files
| filename | language | code | comment | blank | total |
| :--- | :--- | ---: | ---: | ---: | ---: |
[Summary](results.md) / [Details](details.md) / [Diff Summary](diff.md) / Diff Details

View file

@ -0,0 +1,2 @@
"filename", "language", "", "comment", "blank", "total"
"Total", "-", , 0, 0, 0
1 filename language comment blank total
2 Total - 0 0 0

View file

@ -0,0 +1,19 @@
# Diff Summary
Date : 2026-02-03 15:48:00
Directory c:\\Users\\Nathan\\Documents\\GitHub\\scraps
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
[Summary](results.md) / [Details](details.md) / Diff Summary / [Diff Details](diff-details.md)

View file

@ -0,0 +1,22 @@
Date : 2026-02-03 15:48:00
Directory : c:\Users\Nathan\Documents\GitHub\scraps
Total : 0 files, 0 codes, 0 comments, 0 blanks, all 0 lines
Languages
+----------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+----------+------------+------------+------------+------------+------------+
+----------+------------+------------+------------+------------+------------+
Directories
+------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+------+------------+------------+------------+------------+------------+
+------+------------+------------+------------+------------+------------+
Files
+----------+----------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+----------+----------+------------+------------+------------+------------+
| Total | | 0 | 0 | 0 | 0 |
+----------+----------+------------+------------+------------+------------+

View file

@ -0,0 +1,103 @@
"filename", "language", "Markdown", "TypeScript", "JSON with Comments", "JavaScript", "Svelte", "PostCSS", "Properties", "JSON", "HTML", "Docker", "Ignore", "comment", "blank", "total"
"c:\Users\Nathan\Documents\GitHub\scraps\AGENTS.md", "Markdown", 98, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 21, 121
"c:\Users\Nathan\Documents\GitHub\scraps\README.md", "Markdown", 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 5
"c:\Users\Nathan\Documents\GitHub\scraps\backend\Dockerfile", "Docker", 0, 0, 0, 0, 0, 0, 0, 0, 0, 14, 0, 2, 10, 26
"c:\Users\Nathan\Documents\GitHub\scraps\backend\README.md", "Markdown", 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
"c:\Users\Nathan\Documents\GitHub\scraps\backend\bun.lock", "JSON with Comments", 0, 0, 170, 0, 0, 0, 0, 0, 0, 0, 0, 0, 145, 315
"c:\Users\Nathan\Documents\GitHub\scraps\backend\dist\index.js", "JavaScript", 0, 0, 0, 29463, 0, 0, 0, 0, 0, 0, 0, 323, 446, 30232
"c:\Users\Nathan\Documents\GitHub\scraps\backend\drizzle.config.ts", "TypeScript", 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 12
"c:\Users\Nathan\Documents\GitHub\scraps\backend\package.json", "JSON", 0, 0, 0, 0, 0, 0, 0, 25, 0, 0, 0, 0, 1, 26
"c:\Users\Nathan\Documents\GitHub\scraps\backend\scripts\reset-projects.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 9
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\config.ts", "TypeScript", 0, 19, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 8, 32
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\index.ts", "TypeScript", 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 5
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\schema.ts", "TypeScript", 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 3
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\index.ts", "TypeScript", 0, 35, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 39
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\auth.ts", "TypeScript", 0, 181, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 211
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\scraps.ts", "TypeScript", 0, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 10, 63
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\slack.ts", "TypeScript", 0, 42, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 49
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\admin.ts", "TypeScript", 0, 505, 0, 0, 0, 0, 0, 0, 0, 0, 0, 14, 109, 628
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\auth.ts", "TypeScript", 0, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 17, 121
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\hackatime.ts", "TypeScript", 0, 53, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 66
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\items.ts", "TypeScript", 0, 73, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 83
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\leaderboard.ts", "TypeScript", 0, 146, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 21, 167
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\news.ts", "TypeScript", 0, 23, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 7, 32
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\projects.ts", "TypeScript", 0, 329, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 63, 402
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\shop.ts", "TypeScript", 0, 653, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 122, 777
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\upload.ts", "TypeScript", 0, 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 16, 76
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\user.ts", "TypeScript", 0, 141, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 23, 168
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\activity.ts", "TypeScript", 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 12
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\index.ts", "TypeScript", 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 8
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\news.ts", "TypeScript", 0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 11
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\projects.ts", "TypeScript", 0, 19, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8, 8, 35
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\reviews.ts", "TypeScript", 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 16
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\sessions.ts", "TypeScript", 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 10
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\shop.ts", "TypeScript", 0, 66, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 73
"c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\users.ts", "TypeScript", 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 9, 38
"c:\Users\Nathan\Documents\GitHub\scraps\backend\tsconfig.json", "JSON with Comments", 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 83, 9, 104
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierignore", "Ignore", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 2, 2, 10
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierrc", "JSON", 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 1, 17
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\Dockerfile", "Docker", 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 0, 2, 10, 25
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\bun.lock", "JSON with Comments", 0, 0, 397, 0, 0, 0, 0, 0, 0, 0, 0, 0, 359, 756
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\eslint.config.js", "JavaScript", 0, 0, 0, 35, 0, 0, 0, 0, 0, 0, 0, 2, 3, 40
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\nginx.conf", "Properties", 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 3, 4, 22
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\package.json", "JSON", 0, 0, 0, 0, 0, 0, 0, 47, 0, 0, 0, 0, 1, 48
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.d.ts", "TypeScript", 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 2, 14
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.html", "HTML", 0, 0, 0, 0, 0, 0, 0, 0, 11, 0, 0, 0, 1, 12
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\auth-client.ts", "TypeScript", 0, 63, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 11, 74
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\AddressSelectModal.svelte", "Svelte", 0, 0, 0, 0, 315, 0, 0, 0, 0, 0, 0, 0, 28, 343
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ConfirmModal.svelte", "Svelte", 0, 0, 0, 0, 69, 0, 0, 0, 0, 0, 0, 0, 4, 73
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\CreateProjectModal.svelte", "Svelte", 0, 0, 0, 0, 350, 0, 0, 0, 0, 0, 0, 6, 38, 394
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Footer.svelte", "Svelte", 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 2, 7
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\HeartButton.svelte", "Svelte", 0, 0, 0, 0, 24, 0, 0, 0, 0, 0, 0, 0, 3, 27
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Navbar.svelte", "Svelte", 0, 0, 0, 0, 300, 0, 0, 0, 0, 0, 0, 8, 25, 333
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\NewsCarousel.svelte", "Svelte", 0, 0, 0, 0, 96, 0, 0, 0, 0, 0, 0, 0, 13, 109
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectModal.svelte", "Svelte", 0, 0, 0, 0, 343, 0, 0, 0, 0, 0, 0, 3, 35, 381
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectPlaceholder.svelte", "Svelte", 0, 0, 0, 0, 35, 0, 0, 0, 0, 0, 0, 0, 7, 42
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\RandomPhrase.svelte", "Svelte", 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 3, 18
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ShopItemModal.svelte", "Svelte", 0, 0, 0, 0, 389, 0, 0, 0, 0, 0, 0, 0, 30, 419
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Superscript.svelte", "Svelte", 0, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 2, 13
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Tutorial.svelte", "Svelte", 0, 0, 0, 0, 359, 0, 0, 0, 0, 0, 0, 15, 33, 407
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\WishlistAvatars.svelte", "Svelte", 0, 0, 0, 0, 108, 0, 0, 0, 0, 0, 0, 3, 12, 123
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\config.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\stores.ts", "TypeScript", 0, 235, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 35, 277
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\utils.ts", "TypeScript", 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 4
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.svelte", "Svelte", 0, 0, 0, 0, 51, 0, 0, 0, 0, 0, 0, 0, 9, 60
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+page.svelte", "Svelte", 0, 0, 0, 0, 249, 0, 0, 0, 0, 0, 0, 6, 40, 295
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\news\+page.svelte", "Svelte", 0, 0, 0, 0, 307, 0, 0, 0, 0, 0, 0, 0, 32, 339
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\+page.svelte", "Svelte", 0, 0, 0, 0, 123, 0, 0, 0, 0, 0, 0, 1, 12, 136
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 472, 0, 0, 0, 0, 0, 0, 7, 37, 516
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\shop\+page.svelte", "Svelte", 0, 0, 0, 0, 400, 0, 0, 0, 0, 0, 0, 0, 34, 434
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\+page.svelte", "Svelte", 0, 0, 0, 0, 291, 0, 0, 0, 0, 0, 0, 3, 26, 320
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 313, 0, 0, 0, 0, 0, 0, 4, 25, 342
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.ts", "TypeScript", 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 6
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.svelte", "Svelte", 0, 0, 0, 0, 52, 0, 0, 0, 0, 0, 0, 0, 7, 59
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.svelte", "Svelte", 0, 0, 0, 0, 50, 0, 0, 0, 0, 0, 0, 0, 8, 58
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.svelte", "Svelte", 0, 0, 0, 0, 113, 0, 0, 0, 0, 0, 0, 4, 14, 131
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\layout.css", "PostCSS", 0, 0, 0, 0, 0, 20, 0, 0, 0, 0, 0, 1, 4, 25
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.svelte", "Svelte", 0, 0, 0, 0, 156, 0, 0, 0, 0, 0, 0, 0, 8, 164
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\project\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 186, 0, 0, 0, 0, 0, 0, 4, 23, 213
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 349, 0, 0, 0, 0, 0, 0, 7, 26, 382
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.svelte", "Svelte", 0, 0, 0, 0, 399, 0, 0, 0, 0, 0, 0, 9, 44, 452
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.svelte", "Svelte", 0, 0, 0, 0, 330, 0, 0, 0, 0, 0, 0, 9, 32, 371
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.svelte", "Svelte", 0, 0, 0, 0, 148, 0, 0, 0, 0, 0, 0, 3, 17, 168
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.ts", "TypeScript", 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 8
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.svelte", "Svelte", 0, 0, 0, 0, 134, 0, 0, 0, 0, 0, 0, 0, 10, 144
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.svelte", "Svelte", 0, 0, 0, 0, 258, 0, 0, 0, 0, 0, 0, 5, 23, 286
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.ts", "TypeScript", 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\submit\+page.svelte", "Svelte", 0, 0, 0, 0, 151, 0, 0, 0, 0, 0, 0, 0, 19, 170
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.svelte", "Svelte", 0, 0, 0, 0, 277, 0, 0, 0, 0, 0, 0, 5, 18, 300
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.ts", "TypeScript", 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 6
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\static\site.webmanifest", "JSON", 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\svelte.config.js", "JavaScript", 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 1, 2, 19
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\tsconfig.json", "JSON with Comments", 0, 0, 14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 15
"c:\Users\Nathan\Documents\GitHub\scraps\frontend\vite.config.ts", "TypeScript", 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 7
"Total", "-", 102, 2940, 593, 29514, 7228, 20, 15, 89, 11, 27, 6, 597, 2288, 43430
1 filename language Markdown TypeScript JSON with Comments JavaScript Svelte PostCSS Properties JSON HTML Docker Ignore comment blank total
2 c:\Users\Nathan\Documents\GitHub\scraps\AGENTS.md Markdown 98 0 0 0 0 0 0 0 0 0 0 2 21 121
3 c:\Users\Nathan\Documents\GitHub\scraps\README.md Markdown 3 0 0 0 0 0 0 0 0 0 0 0 2 5
4 c:\Users\Nathan\Documents\GitHub\scraps\backend\Dockerfile Docker 0 0 0 0 0 0 0 0 0 14 0 2 10 26
5 c:\Users\Nathan\Documents\GitHub\scraps\backend\README.md Markdown 1 0 0 0 0 0 0 0 0 0 0 0 0 1
6 c:\Users\Nathan\Documents\GitHub\scraps\backend\bun.lock JSON with Comments 0 0 170 0 0 0 0 0 0 0 0 0 145 315
7 c:\Users\Nathan\Documents\GitHub\scraps\backend\dist\index.js JavaScript 0 0 0 29463 0 0 0 0 0 0 0 323 446 30232
8 c:\Users\Nathan\Documents\GitHub\scraps\backend\drizzle.config.ts TypeScript 0 10 0 0 0 0 0 0 0 0 0 0 2 12
9 c:\Users\Nathan\Documents\GitHub\scraps\backend\package.json JSON 0 0 0 0 0 0 0 25 0 0 0 0 1 26
10 c:\Users\Nathan\Documents\GitHub\scraps\backend\scripts\reset-projects.ts TypeScript 0 6 0 0 0 0 0 0 0 0 0 0 3 9
11 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\config.ts TypeScript 0 19 0 0 0 0 0 0 0 0 0 5 8 32
12 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\index.ts TypeScript 0 3 0 0 0 0 0 0 0 0 0 0 2 5
13 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\schema.ts TypeScript 0 2 0 0 0 0 0 0 0 0 0 0 1 3
14 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\index.ts TypeScript 0 35 0 0 0 0 0 0 0 0 0 0 4 39
15 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\auth.ts TypeScript 0 181 0 0 0 0 0 0 0 0 0 0 30 211
16 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\scraps.ts TypeScript 0 52 0 0 0 0 0 0 0 0 0 1 10 63
17 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\slack.ts TypeScript 0 42 0 0 0 0 0 0 0 0 0 0 7 49
18 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\admin.ts TypeScript 0 505 0 0 0 0 0 0 0 0 0 14 109 628
19 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\auth.ts TypeScript 0 100 0 0 0 0 0 0 0 0 0 4 17 121
20 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\hackatime.ts TypeScript 0 53 0 0 0 0 0 0 0 0 0 0 13 66
21 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\items.ts TypeScript 0 73 0 0 0 0 0 0 0 0 0 5 5 83
22 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\leaderboard.ts TypeScript 0 146 0 0 0 0 0 0 0 0 0 0 21 167
23 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\news.ts TypeScript 0 23 0 0 0 0 0 0 0 0 0 2 7 32
24 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\projects.ts TypeScript 0 329 0 0 0 0 0 0 0 0 0 10 63 402
25 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\shop.ts TypeScript 0 653 0 0 0 0 0 0 0 0 0 2 122 777
26 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\upload.ts TypeScript 0 60 0 0 0 0 0 0 0 0 0 0 16 76
27 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\user.ts TypeScript 0 141 0 0 0 0 0 0 0 0 0 4 23 168
28 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\activity.ts TypeScript 0 10 0 0 0 0 0 0 0 0 0 0 2 12
29 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\index.ts TypeScript 0 7 0 0 0 0 0 0 0 0 0 0 1 8
30 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\news.ts TypeScript 0 9 0 0 0 0 0 0 0 0 0 0 2 11
31 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\projects.ts TypeScript 0 19 0 0 0 0 0 0 0 0 0 8 8 35
32 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\reviews.ts TypeScript 0 12 0 0 0 0 0 0 0 0 0 0 4 16
33 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\sessions.ts TypeScript 0 8 0 0 0 0 0 0 0 0 0 0 2 10
34 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\shop.ts TypeScript 0 66 0 0 0 0 0 0 0 0 0 0 7 73
35 c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\users.ts TypeScript 0 24 0 0 0 0 0 0 0 0 0 5 9 38
36 c:\Users\Nathan\Documents\GitHub\scraps\backend\tsconfig.json JSON with Comments 0 0 12 0 0 0 0 0 0 0 0 83 9 104
37 c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierignore Ignore 0 0 0 0 0 0 0 0 0 0 6 2 2 10
38 c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierrc JSON 0 0 0 0 0 0 0 16 0 0 0 0 1 17
39 c:\Users\Nathan\Documents\GitHub\scraps\frontend\Dockerfile Docker 0 0 0 0 0 0 0 0 0 13 0 2 10 25
40 c:\Users\Nathan\Documents\GitHub\scraps\frontend\bun.lock JSON with Comments 0 0 397 0 0 0 0 0 0 0 0 0 359 756
41 c:\Users\Nathan\Documents\GitHub\scraps\frontend\eslint.config.js JavaScript 0 0 0 35 0 0 0 0 0 0 0 2 3 40
42 c:\Users\Nathan\Documents\GitHub\scraps\frontend\nginx.conf Properties 0 0 0 0 0 0 15 0 0 0 0 3 4 22
43 c:\Users\Nathan\Documents\GitHub\scraps\frontend\package.json JSON 0 0 0 0 0 0 0 47 0 0 0 0 1 48
44 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.d.ts TypeScript 0 5 0 0 0 0 0 0 0 0 0 7 2 14
45 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.html HTML 0 0 0 0 0 0 0 0 11 0 0 0 1 12
46 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\auth-client.ts TypeScript 0 63 0 0 0 0 0 0 0 0 0 0 11 74
47 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\AddressSelectModal.svelte Svelte 0 0 0 0 315 0 0 0 0 0 0 0 28 343
48 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ConfirmModal.svelte Svelte 0 0 0 0 69 0 0 0 0 0 0 0 4 73
49 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\CreateProjectModal.svelte Svelte 0 0 0 0 350 0 0 0 0 0 0 6 38 394
50 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Footer.svelte Svelte 0 0 0 0 5 0 0 0 0 0 0 0 2 7
51 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\HeartButton.svelte Svelte 0 0 0 0 24 0 0 0 0 0 0 0 3 27
52 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Navbar.svelte Svelte 0 0 0 0 300 0 0 0 0 0 0 8 25 333
53 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\NewsCarousel.svelte Svelte 0 0 0 0 96 0 0 0 0 0 0 0 13 109
54 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectModal.svelte Svelte 0 0 0 0 343 0 0 0 0 0 0 3 35 381
55 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectPlaceholder.svelte Svelte 0 0 0 0 35 0 0 0 0 0 0 0 7 42
56 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\RandomPhrase.svelte Svelte 0 0 0 0 15 0 0 0 0 0 0 0 3 18
57 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ShopItemModal.svelte Svelte 0 0 0 0 389 0 0 0 0 0 0 0 30 419
58 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Superscript.svelte Svelte 0 0 0 0 11 0 0 0 0 0 0 0 2 13
59 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Tutorial.svelte Svelte 0 0 0 0 359 0 0 0 0 0 0 15 33 407
60 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\WishlistAvatars.svelte Svelte 0 0 0 0 108 0 0 0 0 0 0 3 12 123
61 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\config.ts TypeScript 0 1 0 0 0 0 0 0 0 0 0 0 1 2
62 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\stores.ts TypeScript 0 235 0 0 0 0 0 0 0 0 0 7 35 277
63 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\utils.ts TypeScript 0 3 0 0 0 0 0 0 0 0 0 0 1 4
64 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.svelte Svelte 0 0 0 0 51 0 0 0 0 0 0 0 9 60
65 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.ts TypeScript 0 1 0 0 0 0 0 0 0 0 0 0 0 1
66 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+page.svelte Svelte 0 0 0 0 249 0 0 0 0 0 0 6 40 295
67 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\news\+page.svelte Svelte 0 0 0 0 307 0 0 0 0 0 0 0 32 339
68 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\+page.svelte Svelte 0 0 0 0 123 0 0 0 0 0 0 1 12 136
69 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\[id]\+page.svelte Svelte 0 0 0 0 472 0 0 0 0 0 0 7 37 516
70 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\shop\+page.svelte Svelte 0 0 0 0 400 0 0 0 0 0 0 0 34 434
71 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\+page.svelte Svelte 0 0 0 0 291 0 0 0 0 0 0 3 26 320
72 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.svelte Svelte 0 0 0 0 313 0 0 0 0 0 0 4 25 342
73 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.ts TypeScript 0 5 0 0 0 0 0 0 0 0 0 0 1 6
74 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.svelte Svelte 0 0 0 0 52 0 0 0 0 0 0 0 7 59
75 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.ts TypeScript 0 1 0 0 0 0 0 0 0 0 0 0 1 2
76 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.svelte Svelte 0 0 0 0 50 0 0 0 0 0 0 0 8 58
77 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.ts TypeScript 0 1 0 0 0 0 0 0 0 0 0 0 1 2
78 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.svelte Svelte 0 0 0 0 113 0 0 0 0 0 0 4 14 131
79 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.ts TypeScript 0 1 0 0 0 0 0 0 0 0 0 0 1 2
80 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\layout.css PostCSS 0 0 0 0 0 20 0 0 0 0 0 1 4 25
81 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.svelte Svelte 0 0 0 0 156 0 0 0 0 0 0 0 8 164
82 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.ts TypeScript 0 1 0 0 0 0 0 0 0 0 0 0 1 2
83 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\project\[id]\+page.svelte Svelte 0 0 0 0 186 0 0 0 0 0 0 4 23 213
84 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.svelte Svelte 0 0 0 0 349 0 0 0 0 0 0 7 26 382
85 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.ts TypeScript 0 6 0 0 0 0 0 0 0 0 0 0 2 8
86 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.svelte Svelte 0 0 0 0 399 0 0 0 0 0 0 9 44 452
87 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.ts TypeScript 0 6 0 0 0 0 0 0 0 0 0 0 2 8
88 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.svelte Svelte 0 0 0 0 330 0 0 0 0 0 0 9 32 371
89 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.ts TypeScript 0 6 0 0 0 0 0 0 0 0 0 0 2 8
90 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.svelte Svelte 0 0 0 0 148 0 0 0 0 0 0 3 17 168
91 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.ts TypeScript 0 6 0 0 0 0 0 0 0 0 0 0 2 8
92 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.svelte Svelte 0 0 0 0 134 0 0 0 0 0 0 0 10 144
93 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.ts TypeScript 0 1 0 0 0 0 0 0 0 0 0 0 1 2
94 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.svelte Svelte 0 0 0 0 258 0 0 0 0 0 0 5 23 286
95 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.ts TypeScript 0 1 0 0 0 0 0 0 0 0 0 0 1 2
96 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\submit\+page.svelte Svelte 0 0 0 0 151 0 0 0 0 0 0 0 19 170
97 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.svelte Svelte 0 0 0 0 277 0 0 0 0 0 0 5 18 300
98 c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.ts TypeScript 0 5 0 0 0 0 0 0 0 0 0 0 1 6
99 c:\Users\Nathan\Documents\GitHub\scraps\frontend\static\site.webmanifest JSON 0 0 0 0 0 0 0 1 0 0 0 0 0 1
100 c:\Users\Nathan\Documents\GitHub\scraps\frontend\svelte.config.js JavaScript 0 0 0 16 0 0 0 0 0 0 0 1 2 19
101 c:\Users\Nathan\Documents\GitHub\scraps\frontend\tsconfig.json JSON with Comments 0 0 14 0 0 0 0 0 0 0 0 0 1 15
102 c:\Users\Nathan\Documents\GitHub\scraps\frontend\vite.config.ts TypeScript 0 4 0 0 0 0 0 0 0 0 0 0 3 7
103 Total - 102 2940 593 29514 7228 20 15 89 11 27 6 597 2288 43430

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,79 @@
# Summary
Date : 2026-02-03 15:48:00
Directory c:\\Users\\Nathan\\Documents\\GitHub\\scraps
Total : 101 files, 40545 codes, 597 comments, 2288 blanks, all 43430 lines
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)
## Languages
| language | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| JavaScript | 3 | 29,514 | 326 | 451 | 30,291 |
| Svelte | 35 | 7,228 | 102 | 699 | 8,029 |
| TypeScript | 46 | 2,940 | 74 | 567 | 3,581 |
| JSON with Comments | 4 | 593 | 83 | 514 | 1,190 |
| Markdown | 3 | 102 | 2 | 23 | 127 |
| JSON | 4 | 89 | 0 | 3 | 92 |
| Docker | 2 | 27 | 4 | 20 | 51 |
| PostCSS | 1 | 20 | 1 | 4 | 25 |
| Properties | 1 | 15 | 3 | 4 | 22 |
| HTML | 1 | 11 | 0 | 1 | 12 |
| Ignore | 1 | 6 | 2 | 2 | 10 |
## Directories
| path | files | code | comment | blank | total |
| :--- | ---: | ---: | ---: | ---: | ---: |
| . | 101 | 40,545 | 597 | 2,288 | 43,430 |
| . (Files) | 2 | 101 | 2 | 23 | 126 |
| backend | 33 | 32,273 | 468 | 1,109 | 33,850 |
| backend (Files) | 6 | 232 | 85 | 167 | 484 |
| backend\\dist | 1 | 29,463 | 323 | 446 | 30,232 |
| backend\\scripts | 1 | 6 | 0 | 3 | 9 |
| backend\\src | 25 | 2,572 | 60 | 493 | 3,125 |
| backend\\src (Files) | 2 | 54 | 5 | 12 | 71 |
| backend\\src\\db | 2 | 5 | 0 | 3 | 8 |
| backend\\src\\lib | 3 | 275 | 1 | 47 | 323 |
| backend\\src\\routes | 10 | 2,083 | 41 | 396 | 2,520 |
| backend\\src\\schemas | 8 | 155 | 13 | 35 | 203 |
| frontend | 66 | 8,171 | 127 | 1,156 | 9,454 |
| frontend (Files) | 10 | 563 | 10 | 386 | 959 |
| frontend\\src | 55 | 7,607 | 117 | 770 | 8,494 |
| frontend\\src (Files) | 2 | 16 | 7 | 3 | 26 |
| frontend\\src\\lib | 18 | 2,721 | 42 | 283 | 3,046 |
| frontend\\src\\lib (Files) | 4 | 302 | 7 | 48 | 357 |
| frontend\\src\\lib\\components | 14 | 2,419 | 35 | 235 | 2,689 |
| frontend\\src\\routes | 35 | 4,870 | 68 | 484 | 5,422 |
| frontend\\src\\routes (Files) | 4 | 321 | 7 | 53 | 381 |
| frontend\\src\\routes\\admin | 7 | 1,911 | 15 | 167 | 2,093 |
| frontend\\src\\routes\\admin\\news | 1 | 307 | 0 | 32 | 339 |
| frontend\\src\\routes\\admin\\reviews | 2 | 595 | 8 | 49 | 652 |
| frontend\\src\\routes\\admin\\reviews (Files) | 1 | 123 | 1 | 12 | 136 |
| frontend\\src\\routes\\admin\\reviews\\[id] | 1 | 472 | 7 | 37 | 516 |
| frontend\\src\\routes\\admin\\shop | 1 | 400 | 0 | 34 | 434 |
| frontend\\src\\routes\\admin\\users | 3 | 609 | 7 | 52 | 668 |
| frontend\\src\\routes\\admin\\users (Files) | 1 | 291 | 3 | 26 | 320 |
| frontend\\src\\routes\\admin\\users\\[id] | 2 | 318 | 4 | 26 | 348 |
| frontend\\src\\routes\\auth | 4 | 104 | 0 | 17 | 121 |
| frontend\\src\\routes\\auth\\callback | 2 | 53 | 0 | 8 | 61 |
| frontend\\src\\routes\\auth\\error | 2 | 51 | 0 | 9 | 60 |
| frontend\\src\\routes\\dashboard | 2 | 114 | 4 | 15 | 133 |
| frontend\\src\\routes\\leaderboard | 2 | 157 | 0 | 9 | 166 |
| frontend\\src\\routes\\project | 1 | 186 | 4 | 23 | 213 |
| frontend\\src\\routes\\project\\[id] | 1 | 186 | 4 | 23 | 213 |
| frontend\\src\\routes\\projects | 8 | 1,250 | 28 | 127 | 1,405 |
| frontend\\src\\routes\\projects\\[id] | 8 | 1,250 | 28 | 127 | 1,405 |
| frontend\\src\\routes\\projects\\[id] (Files) | 2 | 355 | 7 | 28 | 390 |
| frontend\\src\\routes\\projects\\[id]\\edit | 2 | 405 | 9 | 46 | 460 |
| frontend\\src\\routes\\projects\\[id]\\submit | 2 | 336 | 9 | 34 | 379 |
| frontend\\src\\routes\\projects\\[id]\\view | 2 | 154 | 3 | 19 | 176 |
| frontend\\src\\routes\\refinery | 2 | 135 | 0 | 11 | 146 |
| frontend\\src\\routes\\shop | 2 | 259 | 5 | 24 | 288 |
| frontend\\src\\routes\\submit | 1 | 151 | 0 | 19 | 170 |
| frontend\\src\\routes\\users | 2 | 282 | 5 | 19 | 306 |
| frontend\\src\\routes\\users\\[id] | 2 | 282 | 5 | 19 | 306 |
| frontend\\static | 1 | 1 | 0 | 0 | 1 |
Summary / [Details](details.md) / [Diff Summary](diff.md) / [Diff Details](diff-details.md)

View file

@ -0,0 +1,183 @@
Date : 2026-02-03 15:48:00
Directory : c:\Users\Nathan\Documents\GitHub\scraps
Total : 101 files, 40545 codes, 597 comments, 2288 blanks, all 43430 lines
Languages
+--------------------+------------+------------+------------+------------+------------+
| language | files | code | comment | blank | total |
+--------------------+------------+------------+------------+------------+------------+
| JavaScript | 3 | 29,514 | 326 | 451 | 30,291 |
| Svelte | 35 | 7,228 | 102 | 699 | 8,029 |
| TypeScript | 46 | 2,940 | 74 | 567 | 3,581 |
| JSON with Comments | 4 | 593 | 83 | 514 | 1,190 |
| Markdown | 3 | 102 | 2 | 23 | 127 |
| JSON | 4 | 89 | 0 | 3 | 92 |
| Docker | 2 | 27 | 4 | 20 | 51 |
| PostCSS | 1 | 20 | 1 | 4 | 25 |
| Properties | 1 | 15 | 3 | 4 | 22 |
| HTML | 1 | 11 | 0 | 1 | 12 |
| Ignore | 1 | 6 | 2 | 2 | 10 |
+--------------------+------------+------------+------------+------------+------------+
Directories
+-----------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| path | files | code | comment | blank | total |
+-----------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
| . | 101 | 40,545 | 597 | 2,288 | 43,430 |
| . (Files) | 2 | 101 | 2 | 23 | 126 |
| backend | 33 | 32,273 | 468 | 1,109 | 33,850 |
| backend (Files) | 6 | 232 | 85 | 167 | 484 |
| backend\dist | 1 | 29,463 | 323 | 446 | 30,232 |
| backend\scripts | 1 | 6 | 0 | 3 | 9 |
| backend\src | 25 | 2,572 | 60 | 493 | 3,125 |
| backend\src (Files) | 2 | 54 | 5 | 12 | 71 |
| backend\src\db | 2 | 5 | 0 | 3 | 8 |
| backend\src\lib | 3 | 275 | 1 | 47 | 323 |
| backend\src\routes | 10 | 2,083 | 41 | 396 | 2,520 |
| backend\src\schemas | 8 | 155 | 13 | 35 | 203 |
| frontend | 66 | 8,171 | 127 | 1,156 | 9,454 |
| frontend (Files) | 10 | 563 | 10 | 386 | 959 |
| frontend\src | 55 | 7,607 | 117 | 770 | 8,494 |
| frontend\src (Files) | 2 | 16 | 7 | 3 | 26 |
| frontend\src\lib | 18 | 2,721 | 42 | 283 | 3,046 |
| frontend\src\lib (Files) | 4 | 302 | 7 | 48 | 357 |
| frontend\src\lib\components | 14 | 2,419 | 35 | 235 | 2,689 |
| frontend\src\routes | 35 | 4,870 | 68 | 484 | 5,422 |
| frontend\src\routes (Files) | 4 | 321 | 7 | 53 | 381 |
| frontend\src\routes\admin | 7 | 1,911 | 15 | 167 | 2,093 |
| frontend\src\routes\admin\news | 1 | 307 | 0 | 32 | 339 |
| frontend\src\routes\admin\reviews | 2 | 595 | 8 | 49 | 652 |
| frontend\src\routes\admin\reviews (Files) | 1 | 123 | 1 | 12 | 136 |
| frontend\src\routes\admin\reviews\[id] | 1 | 472 | 7 | 37 | 516 |
| frontend\src\routes\admin\shop | 1 | 400 | 0 | 34 | 434 |
| frontend\src\routes\admin\users | 3 | 609 | 7 | 52 | 668 |
| frontend\src\routes\admin\users (Files) | 1 | 291 | 3 | 26 | 320 |
| frontend\src\routes\admin\users\[id] | 2 | 318 | 4 | 26 | 348 |
| frontend\src\routes\auth | 4 | 104 | 0 | 17 | 121 |
| frontend\src\routes\auth\callback | 2 | 53 | 0 | 8 | 61 |
| frontend\src\routes\auth\error | 2 | 51 | 0 | 9 | 60 |
| frontend\src\routes\dashboard | 2 | 114 | 4 | 15 | 133 |
| frontend\src\routes\leaderboard | 2 | 157 | 0 | 9 | 166 |
| frontend\src\routes\project | 1 | 186 | 4 | 23 | 213 |
| frontend\src\routes\project\[id] | 1 | 186 | 4 | 23 | 213 |
| frontend\src\routes\projects | 8 | 1,250 | 28 | 127 | 1,405 |
| frontend\src\routes\projects\[id] | 8 | 1,250 | 28 | 127 | 1,405 |
| frontend\src\routes\projects\[id] (Files) | 2 | 355 | 7 | 28 | 390 |
| frontend\src\routes\projects\[id]\edit | 2 | 405 | 9 | 46 | 460 |
| frontend\src\routes\projects\[id]\submit | 2 | 336 | 9 | 34 | 379 |
| frontend\src\routes\projects\[id]\view | 2 | 154 | 3 | 19 | 176 |
| frontend\src\routes\refinery | 2 | 135 | 0 | 11 | 146 |
| frontend\src\routes\shop | 2 | 259 | 5 | 24 | 288 |
| frontend\src\routes\submit | 1 | 151 | 0 | 19 | 170 |
| frontend\src\routes\users | 2 | 282 | 5 | 19 | 306 |
| frontend\src\routes\users\[id] | 2 | 282 | 5 | 19 | 306 |
| frontend\static | 1 | 1 | 0 | 0 | 1 |
+-----------------------------------------------------------------------------------------------+------------+------------+------------+------------+------------+
Files
+-----------------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
| filename | language | code | comment | blank | total |
+-----------------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
| c:\Users\Nathan\Documents\GitHub\scraps\AGENTS.md | Markdown | 98 | 2 | 21 | 121 |
| c:\Users\Nathan\Documents\GitHub\scraps\README.md | Markdown | 3 | 0 | 2 | 5 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\Dockerfile | Docker | 14 | 2 | 10 | 26 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\README.md | Markdown | 1 | 0 | 0 | 1 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\bun.lock | JSON with Comments | 170 | 0 | 145 | 315 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\dist\index.js | JavaScript | 29,463 | 323 | 446 | 30,232 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\drizzle.config.ts | TypeScript | 10 | 0 | 2 | 12 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\package.json | JSON | 25 | 0 | 1 | 26 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\scripts\reset-projects.ts | TypeScript | 6 | 0 | 3 | 9 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\config.ts | TypeScript | 19 | 5 | 8 | 32 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\index.ts | TypeScript | 3 | 0 | 2 | 5 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\db\schema.ts | TypeScript | 2 | 0 | 1 | 3 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\index.ts | TypeScript | 35 | 0 | 4 | 39 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\auth.ts | TypeScript | 181 | 0 | 30 | 211 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\scraps.ts | TypeScript | 52 | 1 | 10 | 63 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\lib\slack.ts | TypeScript | 42 | 0 | 7 | 49 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\admin.ts | TypeScript | 505 | 14 | 109 | 628 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\auth.ts | TypeScript | 100 | 4 | 17 | 121 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\hackatime.ts | TypeScript | 53 | 0 | 13 | 66 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\items.ts | TypeScript | 73 | 5 | 5 | 83 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\leaderboard.ts | TypeScript | 146 | 0 | 21 | 167 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\news.ts | TypeScript | 23 | 2 | 7 | 32 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\projects.ts | TypeScript | 329 | 10 | 63 | 402 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\shop.ts | TypeScript | 653 | 2 | 122 | 777 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\upload.ts | TypeScript | 60 | 0 | 16 | 76 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\routes\user.ts | TypeScript | 141 | 4 | 23 | 168 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\activity.ts | TypeScript | 10 | 0 | 2 | 12 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\index.ts | TypeScript | 7 | 0 | 1 | 8 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\news.ts | TypeScript | 9 | 0 | 2 | 11 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\projects.ts | TypeScript | 19 | 8 | 8 | 35 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\reviews.ts | TypeScript | 12 | 0 | 4 | 16 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\sessions.ts | TypeScript | 8 | 0 | 2 | 10 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\shop.ts | TypeScript | 66 | 0 | 7 | 73 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\src\schemas\users.ts | TypeScript | 24 | 5 | 9 | 38 |
| c:\Users\Nathan\Documents\GitHub\scraps\backend\tsconfig.json | JSON with Comments | 12 | 83 | 9 | 104 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierignore | Ignore | 6 | 2 | 2 | 10 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\.prettierrc | JSON | 16 | 0 | 1 | 17 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\Dockerfile | Docker | 13 | 2 | 10 | 25 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\bun.lock | JSON with Comments | 397 | 0 | 359 | 756 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\eslint.config.js | JavaScript | 35 | 2 | 3 | 40 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\nginx.conf | Properties | 15 | 3 | 4 | 22 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\package.json | JSON | 47 | 0 | 1 | 48 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.d.ts | TypeScript | 5 | 7 | 2 | 14 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\app.html | HTML | 11 | 0 | 1 | 12 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\auth-client.ts | TypeScript | 63 | 0 | 11 | 74 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\AddressSelectModal.svelte | Svelte | 315 | 0 | 28 | 343 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ConfirmModal.svelte | Svelte | 69 | 0 | 4 | 73 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\CreateProjectModal.svelte | Svelte | 350 | 6 | 38 | 394 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Footer.svelte | Svelte | 5 | 0 | 2 | 7 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\HeartButton.svelte | Svelte | 24 | 0 | 3 | 27 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Navbar.svelte | Svelte | 300 | 8 | 25 | 333 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\NewsCarousel.svelte | Svelte | 96 | 0 | 13 | 109 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectModal.svelte | Svelte | 343 | 3 | 35 | 381 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ProjectPlaceholder.svelte | Svelte | 35 | 0 | 7 | 42 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\RandomPhrase.svelte | Svelte | 15 | 0 | 3 | 18 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\ShopItemModal.svelte | Svelte | 389 | 0 | 30 | 419 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Superscript.svelte | Svelte | 11 | 0 | 2 | 13 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\Tutorial.svelte | Svelte | 359 | 15 | 33 | 407 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\components\WishlistAvatars.svelte | Svelte | 108 | 3 | 12 | 123 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\config.ts | TypeScript | 1 | 0 | 1 | 2 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\stores.ts | TypeScript | 235 | 7 | 35 | 277 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\lib\utils.ts | TypeScript | 3 | 0 | 1 | 4 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.svelte | Svelte | 51 | 0 | 9 | 60 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+layout.ts | TypeScript | 1 | 0 | 0 | 1 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\+page.svelte | Svelte | 249 | 6 | 40 | 295 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\news\+page.svelte | Svelte | 307 | 0 | 32 | 339 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\+page.svelte | Svelte | 123 | 1 | 12 | 136 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\reviews\[id]\+page.svelte | Svelte | 472 | 7 | 37 | 516 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\shop\+page.svelte | Svelte | 400 | 0 | 34 | 434 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\+page.svelte | Svelte | 291 | 3 | 26 | 320 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.svelte | Svelte | 313 | 4 | 25 | 342 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\admin\users\[id]\+page.ts | TypeScript | 5 | 0 | 1 | 6 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.svelte | Svelte | 52 | 0 | 7 | 59 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\callback\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.svelte | Svelte | 50 | 0 | 8 | 58 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\auth\error\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.svelte | Svelte | 113 | 4 | 14 | 131 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\dashboard\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\layout.css | PostCSS | 20 | 1 | 4 | 25 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.svelte | Svelte | 156 | 0 | 8 | 164 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\leaderboard\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\project\[id]\+page.svelte | Svelte | 186 | 4 | 23 | 213 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.svelte | Svelte | 349 | 7 | 26 | 382 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\+page.ts | TypeScript | 6 | 0 | 2 | 8 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.svelte | Svelte | 399 | 9 | 44 | 452 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\edit\+page.ts | TypeScript | 6 | 0 | 2 | 8 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.svelte | Svelte | 330 | 9 | 32 | 371 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\submit\+page.ts | TypeScript | 6 | 0 | 2 | 8 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.svelte | Svelte | 148 | 3 | 17 | 168 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\projects\[id]\view\+page.ts | TypeScript | 6 | 0 | 2 | 8 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.svelte | Svelte | 134 | 0 | 10 | 144 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\refinery\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.svelte | Svelte | 258 | 5 | 23 | 286 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\shop\+page.ts | TypeScript | 1 | 0 | 1 | 2 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\submit\+page.svelte | Svelte | 151 | 0 | 19 | 170 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.svelte | Svelte | 277 | 5 | 18 | 300 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\src\routes\users\[id]\+page.ts | TypeScript | 5 | 0 | 1 | 6 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\static\site.webmanifest | JSON | 1 | 0 | 0 | 1 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\svelte.config.js | JavaScript | 16 | 1 | 2 | 19 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\tsconfig.json | JSON with Comments | 14 | 0 | 1 | 15 |
| c:\Users\Nathan\Documents\GitHub\scraps\frontend\vite.config.ts | TypeScript | 4 | 0 | 3 | 7 |
| Total | | 40,545 | 597 | 2,288 | 43,430 |
+-----------------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+

View file

@ -57,6 +57,57 @@ class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focu
class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto"
```
### Confirmation Modals
Use the `ConfirmModal` component from `$lib/components/ConfirmModal.svelte` or follow this pattern:
**Backdrop**
```html
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={(e) => e.target === e.currentTarget && onCancel()}
onkeydown={(e) => e.key === 'Escape' && onCancel()}
role="dialog"
tabindex="-1"
>
```
**Modal Container**
```html
class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black"
```
**Title**
```html
<h2 class="text-2xl font-bold mb-4">{title}</h2>
```
**Message**
```html
<p class="text-gray-600 mb-6">{message}</p>
```
**Button Row**
```html
<div class="flex gap-3">
<!-- Cancel button (secondary) -->
<button class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer">
cancel
</button>
<!-- Confirm button (primary/success/warning/danger) -->
<button class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold border-4 border-black hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer">
confirm
</button>
</div>
```
**Confirm Button Variants**
- Primary: `bg-black text-white`
- Success: `bg-green-600 text-white`
- Warning: `bg-yellow-500 text-white`
- Danger: `bg-red-600 text-white`
**Do NOT use browser `alert()` or `confirm()`. Always use styled modals.**
### Key Patterns
- **Border style**: `border-4` for buttons, `border-2` for inputs, `border-4` for cards/containers
- **Rounding**: `rounded-full` for buttons, `rounded-2xl` for cards, `rounded-lg` for inputs
@ -66,3 +117,4 @@ class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90v
- **Animation**: Always include `transition-all duration-200`
- **Colors**: Black borders, white backgrounds, no colors except for errors (red)
- **Cursor**: Always include `cursor-pointer` on clickable elements (buttons, links, interactive cards)
- **Disabled cursor**: Always include `disabled:cursor-not-allowed` on buttons that can be disabled

25
backend/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
# Build stage
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build src/index.ts --target bun --outdir ./dist
# Production stage
FROM oven/bun:1-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["bun", "run", "dist/index.js"]

1651
backend/dist/index.js vendored

File diff suppressed because it is too large Load diff

31
backend/src/config.ts Normal file
View file

@ -0,0 +1,31 @@
import 'dotenv/config'
const isDev = process.env.NODE_ENV !== 'production'
export const config = {
isDev,
port: 3000,
// Database (always from .env)
databaseUrl: process.env.DATABASE_URL!,
// Frontend
frontendUrl: isDev
? 'http://localhost:5173'
: process.env.FRONTEND_URL!,
// HackClub Auth
hcauth: {
clientId: process.env.HCAUTH_CLIENT_ID!,
clientSecret: process.env.HCAUTH_CLIENT_SECRET!,
redirectUri: isDev
? 'http://localhost:3000/auth/callback'
: process.env.HCAUTH_REDIRECT_URI!
},
// Slack
slackBotToken: process.env.SLACK_BOT_TOKEN,
// HCCDN
hccdnKey: process.env.HCCDN_KEY
}

View file

@ -1,4 +1,4 @@
import 'dotenv/config'
import { drizzle } from 'drizzle-orm/node-postgres'
import { config } from '../config'
export const db = drizzle(process.env.DATABASE_URL!)
export const db = drizzle(config.databaseUrl)

View file

@ -1,5 +1,6 @@
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { config } from './config'
import projects from './routes/projects'
import news from './routes/news'
import items from './routes/items'
@ -26,11 +27,11 @@ const api = new Elysia()
const app = new Elysia()
.use(cors({
origin: [process.env.FRONTEND_URL as string],
origin: [config.frontendUrl],
credentials: true
}))
.use(api)
.listen(3000)
.listen(config.port)
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`

View file

@ -3,11 +3,12 @@ import { db } from "../db"
import { usersTable } from "../schemas/users"
import { sessionsTable } from "../schemas/sessions"
import { getSlackProfile, getAvatarUrl } from "./slack"
import { config } from "../config"
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"
const CLIENT_ID = config.hcauth.clientId
const CLIENT_SECRET = config.hcauth.clientSecret
const REDIRECT_URI = config.hcauth.redirectUri
interface OIDCTokenResponse {
access_token: string
@ -95,8 +96,8 @@ export async function createOrUpdateUser(identity: HackClubIdentity, tokens: OID
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 (identity.slack_id && config.slackBotToken) {
const slackProfile = await getSlackProfile(identity.slack_id, config.slackBotToken)
if (slackProfile) {
username = slackProfile.display_name || slackProfile.real_name || null
avatarUrl = getAvatarUrl(slackProfile)

62
backend/src/lib/scraps.ts Normal file
View file

@ -0,0 +1,62 @@
import { eq, sql } from 'drizzle-orm'
import { db } from '../db'
import { projectsTable } from '../schemas/projects'
import { shopOrdersTable, refineryOrdersTable } from '../schemas/shop'
import { userBonusesTable } from '../schemas/users'
export const PHI = (1 + Math.sqrt(5)) / 2
export const MULTIPLIER = 120
export function calculateScrapsFromHours(hours: number): number {
return Math.floor(hours * PHI * MULTIPLIER)
}
export async function getUserScrapsBalance(userId: number): Promise<{
earned: number
spent: number
balance: number
}> {
const earnedResult = await db
.select({
total: sql<number>`COALESCE(SUM(${projectsTable.scrapsAwarded}), 0)`
})
.from(projectsTable)
.where(eq(projectsTable.userId, userId))
const bonusResult = await db
.select({
total: sql<number>`COALESCE(SUM(${userBonusesTable.amount}), 0)`
})
.from(userBonusesTable)
.where(eq(userBonusesTable.userId, userId))
const spentResult = await db
.select({
total: sql<number>`COALESCE(SUM(${shopOrdersTable.totalPrice}), 0)`
})
.from(shopOrdersTable)
.where(eq(shopOrdersTable.userId, userId))
// Calculate scraps spent on probability upgrades from refinery_orders
const upgradeSpentResult = await db
.select({
total: sql<number>`COALESCE(SUM(${refineryOrdersTable.cost}), 0)`
})
.from(refineryOrdersTable)
.where(eq(refineryOrdersTable.userId, userId))
const projectEarned = Number(earnedResult[0]?.total) || 0
const bonusEarned = Number(bonusResult[0]?.total) || 0
const earned = projectEarned + bonusEarned
const shopSpent = Number(spentResult[0]?.total) || 0
const upgradeSpent = Number(upgradeSpentResult[0]?.total) || 0
const spent = shopSpent + upgradeSpent
const balance = earned - spent
return { earned, spent, balance }
}
export async function canAfford(userId: number, cost: number): Promise<boolean> {
const { balance } = await getUserScrapsBalance(userId)
return balance >= cost
}

View file

@ -4,9 +4,11 @@ import { db } from '../db'
import { usersTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
import { reviewsTable } from '../schemas/reviews'
import { shopItemsTable } from '../schemas/shop'
import { shopItemsTable, shopOrdersTable, shopHeartsTable } from '../schemas/shop'
import { newsTable } from '../schemas/news'
import { activityTable } from '../schemas/activity'
import { getUserFromSession } from '../lib/auth'
import { calculateScrapsFromHours, getUserScrapsBalance } from '../lib/scraps'
const admin = new Elysia({ prefix: '/admin' })
@ -43,7 +45,18 @@ admin.get('/users', async ({ headers, query }) => {
: undefined
const [users, countResult] = await Promise.all([
db.select().from(usersTable).where(searchCondition).orderBy(desc(usersTable.createdAt)).limit(limit).offset(offset),
db.select({
id: usersTable.id,
username: usersTable.username,
email: usersTable.email,
avatar: usersTable.avatar,
slackId: usersTable.slackId,
role: usersTable.role,
internalNotes: usersTable.internalNotes,
createdAt: usersTable.createdAt,
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
scrapsSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent')
}).from(usersTable).where(searchCondition).orderBy(desc(usersTable.createdAt)).limit(limit).offset(offset),
db.select({ count: sql<number>`count(*)` }).from(usersTable).where(searchCondition)
])
@ -56,7 +69,7 @@ admin.get('/users', async ({ headers, query }) => {
email: user.role === 'admin' ? u.email : undefined,
avatar: u.avatar,
slackId: u.slackId,
scraps: u.scraps,
scraps: Number(u.scrapsEarned) - Number(u.scrapsSpent),
role: u.role,
internalNotes: u.internalNotes,
createdAt: u.createdAt
@ -75,10 +88,12 @@ admin.get('/users/:id', async ({ params, headers }) => {
const user = await requireReviewer(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const targetUserId = parseInt(params.id)
const targetUser = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, parseInt(params.id)))
.where(eq(usersTable.id, targetUserId))
.limit(1)
if (!targetUser[0]) return { error: 'User not found' }
@ -86,7 +101,7 @@ admin.get('/users/:id', async ({ params, headers }) => {
const projects = await db
.select()
.from(projectsTable)
.where(eq(projectsTable.userId, parseInt(params.id)))
.where(eq(projectsTable.userId, targetUserId))
.orderBy(desc(projectsTable.updatedAt))
const projectStats = {
@ -99,6 +114,8 @@ admin.get('/users/:id', async ({ params, headers }) => {
const totalHours = projects.reduce((sum, p) => sum + (p.hoursOverride ?? p.hours ?? 0), 0)
const scrapsBalance = await getUserScrapsBalance(targetUserId)
return {
user: {
id: targetUser[0].id,
@ -106,7 +123,7 @@ admin.get('/users/:id', async ({ params, headers }) => {
email: user.role === 'admin' ? targetUser[0].email : undefined,
avatar: targetUser[0].avatar,
slackId: targetUser[0].slackId,
scraps: targetUser[0].scraps,
scraps: scrapsBalance.balance,
role: targetUser[0].role,
internalNotes: targetUser[0].internalNotes,
createdAt: targetUser[0].createdAt
@ -319,11 +336,26 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
updateData.hoursOverride = hoursOverride
}
let scrapsAwarded = 0
if (action === 'approved') {
const hours = hoursOverride ?? project[0].hours ?? 0
scrapsAwarded = calculateScrapsFromHours(hours)
updateData.scrapsAwarded = scrapsAwarded
}
await db
.update(projectsTable)
.set(updateData)
.where(eq(projectsTable.id, projectId))
if (action === 'approved' && scrapsAwarded > 0) {
await db.insert(activityTable).values({
userId: project[0].userId,
projectId,
action: `earned ${scrapsAwarded} scraps`
})
}
// Update user internal notes if provided
if (userInternalNotes !== undefined) {
if (userInternalNotes.length <= 2500) {
@ -354,13 +386,17 @@ admin.post('/shop/items', async ({ headers, body }) => {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const { name, image, description, price, category, count } = body as {
const { name, image, description, price, category, count, baseProbability, baseUpgradeCost, costMultiplier, boostAmount } = body as {
name: string
image: string
description: string
price: number
category: string
count: number
baseProbability?: number
baseUpgradeCost?: number
costMultiplier?: number
boostAmount?: number
}
if (!name?.trim() || !image?.trim() || !description?.trim() || !category?.trim()) {
@ -371,6 +407,10 @@ admin.post('/shop/items', async ({ headers, body }) => {
return { error: 'Invalid price' }
}
if (baseProbability !== undefined && (typeof baseProbability !== 'number' || baseProbability < 0 || baseProbability > 100)) {
return { error: 'baseProbability must be between 0 and 100' }
}
const inserted = await db
.insert(shopItemsTable)
.values({
@ -379,7 +419,11 @@ admin.post('/shop/items', async ({ headers, body }) => {
description: description.trim(),
price,
category: category.trim(),
count: count || 0
count: count || 0,
baseProbability: baseProbability ?? 50,
baseUpgradeCost: baseUpgradeCost ?? 10,
costMultiplier: costMultiplier ?? 115,
boostAmount: boostAmount ?? 1
})
.returning()
@ -390,13 +434,21 @@ admin.put('/shop/items/:id', async ({ params, headers, body }) => {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const { name, image, description, price, category, count } = body as {
const { name, image, description, price, category, count, baseProbability, baseUpgradeCost, costMultiplier, boostAmount } = body as {
name?: string
image?: string
description?: string
price?: number
category?: string
count?: number
baseProbability?: number
baseUpgradeCost?: number
costMultiplier?: number
boostAmount?: number
}
if (baseProbability !== undefined && (typeof baseProbability !== 'number' || baseProbability < 0 || baseProbability > 100)) {
return { error: 'baseProbability must be between 0 and 100' }
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
@ -407,6 +459,10 @@ admin.put('/shop/items/:id', async ({ params, headers, body }) => {
if (price !== undefined) updateData.price = price
if (category !== undefined) updateData.category = category.trim()
if (count !== undefined) updateData.count = count
if (baseProbability !== undefined) updateData.baseProbability = baseProbability
if (baseUpgradeCost !== undefined) updateData.baseUpgradeCost = baseUpgradeCost
if (costMultiplier !== undefined) updateData.costMultiplier = costMultiplier
if (boostAmount !== undefined) updateData.boostAmount = boostAmount
const updated = await db
.update(shopItemsTable)
@ -421,9 +477,15 @@ admin.delete('/shop/items/:id', async ({ params, headers }) => {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const itemId = parseInt(params.id)
await db
.delete(shopHeartsTable)
.where(eq(shopHeartsTable.shopItemId, itemId))
await db
.delete(shopItemsTable)
.where(eq(shopItemsTable.id, parseInt(params.id)))
.where(eq(shopItemsTable.id, itemId))
return { success: true }
})
@ -503,4 +565,63 @@ admin.delete('/news/:id', async ({ params, headers }) => {
return { success: true }
})
admin.get('/orders', async ({ headers, query }) => {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const status = query.status as string | undefined
let ordersQuery = db
.select({
id: shopOrdersTable.id,
quantity: shopOrdersTable.quantity,
pricePerItem: shopOrdersTable.pricePerItem,
totalPrice: shopOrdersTable.totalPrice,
status: shopOrdersTable.status,
shippingAddress: shopOrdersTable.shippingAddress,
notes: shopOrdersTable.notes,
createdAt: shopOrdersTable.createdAt,
itemId: shopItemsTable.id,
itemName: shopItemsTable.name,
itemImage: shopItemsTable.image,
userId: usersTable.id,
username: usersTable.username,
userEmail: usersTable.email
})
.from(shopOrdersTable)
.innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
.innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id))
.orderBy(desc(shopOrdersTable.createdAt))
if (status) {
ordersQuery = ordersQuery.where(eq(shopOrdersTable.status, status)) as typeof ordersQuery
}
return await ordersQuery
})
admin.patch('/orders/:id', async ({ params, body, headers }) => {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const { status, notes } = body as { status?: string; notes?: string }
const validStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled']
if (status && !validStatuses.includes(status)) {
return { error: 'Invalid status' }
}
const updateData: Record<string, unknown> = { updatedAt: new Date() }
if (status) updateData.status = status
if (notes !== undefined) updateData.notes = notes
const updated = await db
.update(shopOrdersTable)
.set(updateData)
.where(eq(shopOrdersTable.id, parseInt(params.id)))
.returning()
return updated[0] || { error: 'Not found' }
})
export default admin

View file

@ -8,8 +8,10 @@ import {
deleteSession,
getUserFromSession
} from "../lib/auth"
import { config } from "../config"
import { getUserScrapsBalance } from "../lib/scraps"
const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:5173"
const FRONTEND_URL = config.frontendUrl
const authRoutes = new Elysia({ prefix: "/auth" })
@ -64,7 +66,7 @@ authRoutes.get("/callback", async ({ query, redirect, cookie }) => {
cookie.session.set({
value: sessionToken,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: !config.isDev,
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60,
path: "/"
@ -89,6 +91,7 @@ authRoutes.get("/me", async ({ headers }) => {
if (user.role === 'banned') {
return { user: null, banned: true }
}
const scrapsBalance = await getUserScrapsBalance(user.id)
return {
user: {
id: user.id,
@ -96,8 +99,9 @@ authRoutes.get("/me", async ({ headers }) => {
email: user.email,
avatar: user.avatar,
slackId: user.slackId,
scraps: user.scraps,
role: user.role
scraps: scrapsBalance.balance,
role: user.role,
tutorialCompleted: user.tutorialCompleted
}
}
})

View file

@ -2,6 +2,7 @@ import { Elysia, t } from 'elysia'
import { db } from '../db'
import { usersTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
import { shopItemsTable, shopOrdersTable, refineryOrdersTable, shopPenaltiesTable } from '../schemas/shop'
import { sql, desc, eq, and, or, isNull } from 'drizzle-orm'
const leaderboard = new Elysia({ prefix: '/leaderboard' })
@ -15,7 +16,8 @@ leaderboard.get('/', async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
scraps: usersTable.scraps,
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
scrapsSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent'),
hours: sql<number>`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql<number>`COUNT(${projectsTable.id})`.as('project_count')
})
@ -34,7 +36,8 @@ leaderboard.get('/', async ({ query }) => {
username: user.username,
avatar: user.avatar,
hours: Number(user.hours),
scraps: user.scraps,
scraps: Number(user.scrapsEarned) - Number(user.scrapsSpent),
scrapsEarned: Number(user.scrapsEarned),
projectCount: Number(user.projectCount)
}))
}
@ -44,7 +47,8 @@ leaderboard.get('/', async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
scraps: usersTable.scraps,
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
scrapsSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent'),
hours: sql<number>`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql<number>`COUNT(${projectsTable.id})`.as('project_count')
})
@ -54,7 +58,7 @@ leaderboard.get('/', async ({ query }) => {
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted))
))
.groupBy(usersTable.id)
.orderBy(desc(usersTable.scraps))
.orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`))
.limit(10)
return results.map((user, index) => ({
@ -63,7 +67,8 @@ leaderboard.get('/', async ({ query }) => {
username: user.username,
avatar: user.avatar,
hours: Number(user.hours),
scraps: user.scraps,
scraps: Number(user.scrapsEarned) - Number(user.scrapsSpent),
scrapsEarned: Number(user.scrapsEarned),
projectCount: Number(user.projectCount)
}))
}, {
@ -72,4 +77,90 @@ leaderboard.get('/', async ({ query }) => {
})
})
leaderboard.get('/probability-leaders', async () => {
const items = await db
.select({
id: shopItemsTable.id,
name: shopItemsTable.name,
image: shopItemsTable.image,
baseProbability: shopItemsTable.baseProbability
})
.from(shopItemsTable)
const allBoosts = await db
.select({
userId: refineryOrdersTable.userId,
shopItemId: refineryOrdersTable.shopItemId,
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.groupBy(refineryOrdersTable.userId, refineryOrdersTable.shopItemId)
const allPenalties = await db
.select({
userId: shopPenaltiesTable.userId,
shopItemId: shopPenaltiesTable.shopItemId,
probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier
})
.from(shopPenaltiesTable)
const boostMap = new Map<string, number>()
for (const b of allBoosts) {
boostMap.set(`${b.userId}-${b.shopItemId}`, Number(b.boostPercent))
}
const penaltyMap = new Map<string, number>()
for (const p of allPenalties) {
penaltyMap.set(`${p.userId}-${p.shopItemId}`, p.probabilityMultiplier)
}
const userIds = new Set<number>()
for (const b of allBoosts) userIds.add(b.userId)
for (const p of allPenalties) userIds.add(p.userId)
const users = userIds.size > 0
? await db
.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar
})
.from(usersTable)
: []
const userMap = new Map(users.map(u => [u.id, u]))
const result = items.map(item => {
let topUser: { id: number; username: string; avatar: string | null } | null = null
let topProbability = item.baseProbability
for (const userId of userIds) {
const boost = boostMap.get(`${userId}-${item.id}`) ?? 0
const penaltyMultiplier = penaltyMap.get(`${userId}-${item.id}`) ?? 100
const adjustedBase = Math.floor(item.baseProbability * penaltyMultiplier / 100)
const effectiveProbability = Math.min(adjustedBase + boost, 100)
if (effectiveProbability > topProbability) {
topProbability = effectiveProbability
topUser = userMap.get(userId) ?? null
}
}
return {
itemId: item.id,
itemName: item.name,
itemImage: item.image,
baseProbability: item.baseProbability,
topUser: topUser ? {
id: topUser.id,
username: topUser.username,
avatar: topUser.avatar
} : null,
effectiveProbability: topProbability
}
})
return result
})
export default leaderboard

View file

@ -1,8 +1,10 @@
import { Elysia } from 'elysia'
import { eq, sql, and } from 'drizzle-orm'
import { eq, sql, and, desc, isNull } from 'drizzle-orm'
import { db } from '../db'
import { shopItemsTable, shopHeartsTable } from '../schemas/shop'
import { shopItemsTable, shopHeartsTable, shopOrdersTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable } from '../schemas/shop'
import { usersTable } from '../schemas/users'
import { getUserFromSession } from '../lib/auth'
import { getUserScrapsBalance, canAfford } from '../lib/scraps'
const shop = new Elysia({ prefix: '/shop' })
@ -18,6 +20,9 @@ shop.get('/items', async ({ headers }) => {
price: shopItemsTable.price,
category: shopItemsTable.category,
count: shopItemsTable.count,
baseProbability: shopItemsTable.baseProbability,
baseUpgradeCost: shopItemsTable.baseUpgradeCost,
costMultiplier: shopItemsTable.costMultiplier,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql<number>`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as('heart_count')
@ -30,17 +35,48 @@ shop.get('/items', async ({ headers }) => {
.from(shopHeartsTable)
.where(eq(shopHeartsTable.userId, user.id))
const heartedIds = new Set(userHearts.map(h => h.shopItemId))
const userBoosts = await db
.select({
shopItemId: refineryOrdersTable.shopItemId,
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.where(eq(refineryOrdersTable.userId, user.id))
.groupBy(refineryOrdersTable.shopItemId)
return items.map(item => ({
const userPenalties = await db
.select({
shopItemId: shopPenaltiesTable.shopItemId,
probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier
})
.from(shopPenaltiesTable)
.where(eq(shopPenaltiesTable.userId, user.id))
const heartedIds = new Set(userHearts.map(h => h.shopItemId))
const boostMap = new Map(userBoosts.map(b => [b.shopItemId, Number(b.boostPercent)]))
const penaltyMap = new Map(userPenalties.map(p => [p.shopItemId, p.probabilityMultiplier]))
return items.map(item => {
const userBoostPercent = boostMap.get(item.id) ?? 0
const penaltyMultiplier = penaltyMap.get(item.id) ?? 100
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
return {
...item,
hearted: heartedIds.has(item.id)
}))
heartCount: Number(item.heartCount) || 0,
userBoostPercent,
adjustedBaseProbability,
effectiveProbability: Math.min(adjustedBaseProbability + userBoostPercent, 100),
userHearted: heartedIds.has(item.id)
}
})
}
return items.map(item => ({
...item,
hearted: false
heartCount: Number(item.heartCount) || 0,
userBoostPercent: 0,
effectiveProbability: Math.min(item.baseProbability, 100),
userHearted: false
}))
})
@ -57,6 +93,9 @@ shop.get('/items/:id', async ({ params, headers }) => {
price: shopItemsTable.price,
category: shopItemsTable.category,
count: shopItemsTable.count,
baseProbability: shopItemsTable.baseProbability,
baseUpgradeCost: shopItemsTable.baseUpgradeCost,
costMultiplier: shopItemsTable.costMultiplier,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql<number>`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as('heart_count')
@ -71,6 +110,8 @@ shop.get('/items/:id', async ({ params, headers }) => {
const item = items[0]
let hearted = false
let userBoostPercent = 0
let penaltyMultiplier = 100
if (user) {
const heart = await db
@ -83,9 +124,41 @@ shop.get('/items/:id', async ({ params, headers }) => {
.limit(1)
hearted = heart.length > 0
const boost = await db
.select({
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
userBoostPercent = boost.length > 0 ? Number(boost[0].boostPercent) : 0
const penalty = await db
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
.limit(1)
penaltyMultiplier = penalty.length > 0 ? penalty[0].probabilityMultiplier : 100
}
return { ...item, hearted }
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
return {
...item,
heartCount: Number(item.heartCount) || 0,
userBoostPercent,
adjustedBaseProbability,
effectiveProbability: Math.min(adjustedBaseProbability + userBoostPercent, 100),
userHearted: hearted
}
})
shop.post('/items/:id/heart', async ({ params, headers }) => {
@ -95,6 +168,9 @@ shop.post('/items/:id/heart', async ({ params, headers }) => {
}
const itemId = parseInt(params.id)
if (!Number.isInteger(itemId)) {
return { error: 'Invalid item id' }
}
const item = await db
.select()
@ -106,32 +182,27 @@ shop.post('/items/:id/heart', async ({ params, headers }) => {
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
const deleted = await db
.delete(shopHeartsTable)
.where(and(
eq(shopHeartsTable.userId, user.id),
eq(shopHeartsTable.shopItemId, itemId)
))
.returning({ userId: shopHeartsTable.userId })
if (deleted.length > 0) {
return { hearted: false }
} else {
}
await db
.insert(shopHeartsTable)
.values({
userId: user.id,
shopItemId: itemId
})
.onConflictDoNothing()
return { hearted: true }
}
})
shop.get('/categories', async () => {
@ -142,4 +213,564 @@ shop.get('/categories', async () => {
return result.map(r => r.category)
})
shop.get('/balance', async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
return await getUserScrapsBalance(user.id)
})
shop.post('/items/:id/purchase', async ({ params, body, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const itemId = parseInt(params.id)
if (!Number.isInteger(itemId)) {
return { error: 'Invalid item id' }
}
const { quantity = 1, shippingAddress } = body as { quantity?: number; shippingAddress?: string }
if (quantity < 1 || !Number.isInteger(quantity)) {
return { error: 'Invalid quantity' }
}
const items = await db
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (items.length === 0) {
return { error: 'Item not found' }
}
const item = items[0]
if (item.count < quantity) {
return { error: 'Not enough stock available' }
}
const totalPrice = item.price * quantity
const affordable = await canAfford(user.id, totalPrice)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id)
return { error: 'Insufficient scraps', required: totalPrice, available: balance }
}
const order = await db.transaction(async (tx) => {
const currentItem = await tx
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (currentItem.length === 0 || currentItem[0].count < quantity) {
throw new Error('Not enough stock')
}
await tx
.update(shopItemsTable)
.set({
count: currentItem[0].count - quantity,
updatedAt: new Date()
})
.where(eq(shopItemsTable.id, itemId))
const newOrder = await tx
.insert(shopOrdersTable)
.values({
userId: user.id,
shopItemId: itemId,
quantity,
pricePerItem: item.price,
totalPrice,
shippingAddress: shippingAddress || null,
status: 'pending'
})
.returning()
return newOrder[0]
})
return {
success: true,
order: {
id: order.id,
itemName: item.name,
quantity: order.quantity,
totalPrice: order.totalPrice,
status: order.status
}
}
})
shop.get('/orders', async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const orders = await db
.select({
id: shopOrdersTable.id,
quantity: shopOrdersTable.quantity,
pricePerItem: shopOrdersTable.pricePerItem,
totalPrice: shopOrdersTable.totalPrice,
status: shopOrdersTable.status,
createdAt: shopOrdersTable.createdAt,
itemId: shopItemsTable.id,
itemName: shopItemsTable.name,
itemImage: shopItemsTable.image
})
.from(shopOrdersTable)
.innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
.where(eq(shopOrdersTable.userId, user.id))
.orderBy(desc(shopOrdersTable.createdAt))
return orders
})
shop.post('/items/:id/try-luck', async ({ params, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const itemId = parseInt(params.id)
if (!Number.isInteger(itemId)) {
return { error: 'Invalid item id' }
}
const items = await db
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (items.length === 0) {
return { error: 'Item not found' }
}
const item = items[0]
if (item.count < 1) {
return { error: 'Out of stock' }
}
const boostResult = await db
.select({
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
const penaltyResult = await db
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
.limit(1)
const boostPercent = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100)
const affordable = await canAfford(user.id, item.price)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id)
return { error: 'Insufficient scraps', required: item.price, available: balance }
}
const rolled = Math.floor(Math.random() * 100) + 1
const won = rolled <= effectiveProbability
// Record the roll
await db.insert(shopRollsTable).values({
userId: user.id,
shopItemId: itemId,
rolled,
threshold: effectiveProbability,
won
})
if (won) {
const order = await db.transaction(async (tx) => {
const currentItem = await tx
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (currentItem.length === 0 || currentItem[0].count < 1) {
throw new Error('Out of stock')
}
await tx
.update(shopItemsTable)
.set({
count: currentItem[0].count - 1,
updatedAt: new Date()
})
.where(eq(shopItemsTable.id, itemId))
const newOrder = await tx
.insert(shopOrdersTable)
.values({
userId: user.id,
shopItemId: itemId,
quantity: 1,
pricePerItem: item.price,
totalPrice: item.price,
shippingAddress: null,
status: 'pending',
orderType: 'luck_win'
})
.returning()
await tx
.delete(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
const existingPenalty = await tx
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
.limit(1)
if (existingPenalty.length > 0) {
const newMultiplier = Math.max(1, Math.floor(existingPenalty[0].probabilityMultiplier / 2))
await tx
.update(shopPenaltiesTable)
.set({
probabilityMultiplier: newMultiplier,
updatedAt: new Date()
})
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
} else {
await tx
.insert(shopPenaltiesTable)
.values({
userId: user.id,
shopItemId: itemId,
probabilityMultiplier: 50
})
}
return newOrder[0]
})
return { success: true, won: true, orderId: order.id, effectiveProbability, rolled, refineryReset: true, probabilityHalved: true }
}
return { success: true, won: false, effectiveProbability, rolled }
})
shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const itemId = parseInt(params.id)
if (!Number.isInteger(itemId)) {
return { error: 'Invalid item id' }
}
const items = await db
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (items.length === 0) {
return { error: 'Item not found' }
}
const item = items[0]
const boostResult = await db
.select({
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.where(and(
eq(refineryOrdersTable.userId, user.id),
eq(refineryOrdersTable.shopItemId, itemId)
))
const currentBoost = boostResult.length > 0 ? Number(boostResult[0].boostPercent) : 0
const penaltyResult = await db
.select({ probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier })
.from(shopPenaltiesTable)
.where(and(
eq(shopPenaltiesTable.userId, user.id),
eq(shopPenaltiesTable.shopItemId, itemId)
))
.limit(1)
const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100
const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100)
const maxBoost = 100 - adjustedBaseProbability
if (currentBoost >= maxBoost) {
return { error: 'Already at maximum probability' }
}
const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost))
const affordable = await canAfford(user.id, cost)
if (!affordable) {
const { balance } = await getUserScrapsBalance(user.id)
return { error: 'Insufficient scraps', required: cost, available: balance }
}
const newBoost = currentBoost + 1
// Record the refinery order
await db.insert(refineryOrdersTable).values({
userId: user.id,
shopItemId: itemId,
cost,
boostAmount: 1
})
const nextCost = newBoost >= maxBoost
? null
: Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost))
return {
boostPercent: newBoost,
nextCost,
effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100)
}
})
shop.get('/items/:id/leaderboard', async ({ params }) => {
const itemId = parseInt(params.id)
if (!Number.isInteger(itemId)) {
return { error: 'Invalid item id' }
}
const items = await db
.select()
.from(shopItemsTable)
.where(eq(shopItemsTable.id, itemId))
.limit(1)
if (items.length === 0) {
return { error: 'Item not found' }
}
const item = items[0]
const leaderboard = await db
.select({
userId: refineryOrdersTable.userId,
username: usersTable.username,
avatar: usersTable.avatar,
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.innerJoin(usersTable, eq(refineryOrdersTable.userId, usersTable.id))
.where(eq(refineryOrdersTable.shopItemId, itemId))
.groupBy(refineryOrdersTable.userId, usersTable.username, usersTable.avatar)
.orderBy(desc(sql`SUM(${refineryOrdersTable.boostAmount})`))
.limit(20)
return leaderboard.map(entry => ({
...entry,
boostPercent: Number(entry.boostPercent),
effectiveProbability: Math.min(item.baseProbability + Number(entry.boostPercent), 100)
}))
})
shop.get('/items/:id/buyers', async ({ params }) => {
const itemId = parseInt(params.id)
if (!Number.isInteger(itemId)) {
return { error: 'Invalid item id' }
}
const buyers = await db
.select({
userId: shopOrdersTable.userId,
username: usersTable.username,
avatar: usersTable.avatar,
quantity: shopOrdersTable.quantity,
createdAt: shopOrdersTable.createdAt
})
.from(shopOrdersTable)
.innerJoin(usersTable, eq(shopOrdersTable.userId, usersTable.id))
.where(eq(shopOrdersTable.shopItemId, itemId))
.orderBy(desc(shopOrdersTable.createdAt))
.limit(20)
return buyers
})
shop.get('/items/:id/hearts', async ({ params }) => {
const itemId = parseInt(params.id)
if (!Number.isInteger(itemId)) {
return { error: 'Invalid item id' }
}
const hearts = await db
.select({
userId: shopHeartsTable.userId,
username: usersTable.username,
avatar: usersTable.avatar,
createdAt: shopHeartsTable.createdAt
})
.from(shopHeartsTable)
.innerJoin(usersTable, eq(shopHeartsTable.userId, usersTable.id))
.where(eq(shopHeartsTable.shopItemId, itemId))
.orderBy(desc(shopHeartsTable.createdAt))
.limit(20)
return hearts
})
shop.get('/addresses', async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const userData = await db
.select({ accessToken: usersTable.accessToken })
.from(usersTable)
.where(eq(usersTable.id, user.id))
.limit(1)
if (userData.length === 0 || !userData[0].accessToken) {
return []
}
try {
const response = await fetch('https://identity.hackclub.com/api/v1/me', {
headers: {
Authorization: `Bearer ${userData[0].accessToken}`
}
})
if (!response.ok) {
return []
}
const data = await response.json() as {
identity?: {
addresses?: Array<{
id: string
first_name: string
last_name: string
line_1: string
line_2: string
city: string
state: string
postal_code: string
country: string
phone_number: string
primary: boolean
}>
}
}
return data.identity?.addresses ?? []
} catch {
return []
}
})
shop.get('/orders/pending-address', async ({ headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const orders = await db
.select({
id: shopOrdersTable.id,
quantity: shopOrdersTable.quantity,
pricePerItem: shopOrdersTable.pricePerItem,
totalPrice: shopOrdersTable.totalPrice,
status: shopOrdersTable.status,
orderType: shopOrdersTable.orderType,
createdAt: shopOrdersTable.createdAt,
itemId: shopItemsTable.id,
itemName: shopItemsTable.name,
itemImage: shopItemsTable.image
})
.from(shopOrdersTable)
.innerJoin(shopItemsTable, eq(shopOrdersTable.shopItemId, shopItemsTable.id))
.where(and(
eq(shopOrdersTable.userId, user.id),
isNull(shopOrdersTable.shippingAddress)
))
.orderBy(desc(shopOrdersTable.createdAt))
return orders
})
shop.post('/orders/:id/address', async ({ params, body, headers }) => {
const user = await getUserFromSession(headers as Record<string, string>)
if (!user) {
return { error: 'Unauthorized' }
}
const orderId = parseInt(params.id)
if (!Number.isInteger(orderId)) {
return { error: 'Invalid order id' }
}
const { shippingAddress } = body as { shippingAddress?: string }
if (!shippingAddress) {
return { error: 'Shipping address is required' }
}
const orders = await db
.select()
.from(shopOrdersTable)
.where(eq(shopOrdersTable.id, orderId))
.limit(1)
if (orders.length === 0) {
return { error: 'Order not found' }
}
if (orders[0].userId !== user.id) {
return { error: 'Unauthorized' }
}
await db
.update(shopOrdersTable)
.set({
shippingAddress,
updatedAt: new Date()
})
.where(eq(shopOrdersTable.id, orderId))
return { success: true }
})
export default shop

View file

@ -1,8 +1,9 @@
import { Elysia } from 'elysia'
import { getUserFromSession } from '../lib/auth'
import { config } from '../config'
const HCCDN_URL = 'https://cdn.hackclub.com/api/v4/upload'
const HCCDN_KEY = process.env.HCCDN_KEY
const HCCDN_KEY = config.hccdnKey
interface CDNUploadResponse {
id: string

View file

@ -1,10 +1,11 @@
import { Elysia } from 'elysia'
import { eq, inArray } from 'drizzle-orm'
import { eq, inArray, sql } from 'drizzle-orm'
import { getUserFromSession, checkUserEligibility } from '../lib/auth'
import { db } from '../db'
import { usersTable } from '../schemas/users'
import { usersTable, userBonusesTable } from '../schemas/users'
import { projectsTable } from '../schemas/projects'
import { shopHeartsTable, shopItemsTable } from '../schemas/shop'
import { shopHeartsTable, shopItemsTable, refineryOrdersTable } from '../schemas/shop'
import { getUserScrapsBalance } from '../lib/scraps'
const user = new Elysia({ prefix: '/user' })
@ -23,18 +24,45 @@ user.get('/me', async ({ headers }) => {
}
}
const scrapsBalance = await getUserScrapsBalance(userData.id)
return {
id: userData.id,
username: userData.username,
email: userData.email,
avatar: userData.avatar,
slackId: userData.slackId,
scraps: userData.scraps,
scraps: scrapsBalance.balance,
scrapsEarned: scrapsBalance.earned,
scrapsSpent: scrapsBalance.spent,
yswsEligible,
verificationStatus
verificationStatus,
tutorialCompleted: userData.tutorialCompleted
}
})
user.post('/complete-tutorial', async ({ headers }) => {
const userData = await getUserFromSession(headers as Record<string, string>)
if (!userData) return { error: 'Unauthorized' }
if (userData.tutorialCompleted) {
return { success: true, alreadyCompleted: true }
}
await db
.update(usersTable)
.set({ tutorialCompleted: true, updatedAt: new Date() })
.where(eq(usersTable.id, userData.id))
await db.insert(userBonusesTable).values({
userId: userData.id,
type: 'tutorial_bonus',
amount: 10
})
return { success: true, bonusAwarded: 10 }
})
// Public profile - anyone logged in can view
user.get('/profile/:id', async ({ params, headers }) => {
const currentUser = await getUserFromSession(headers as Record<string, string>)
@ -85,12 +113,28 @@ user.get('/profile/:id', async ({ params, headers }) => {
.where(inArray(shopItemsTable.id, heartedItemIds))
}
const scrapsBalance = await getUserScrapsBalance(parseInt(params.id))
// Get user's refinery boosts
const refinements = await db
.select({
shopItemId: refineryOrdersTable.shopItemId,
itemName: shopItemsTable.name,
itemImage: shopItemsTable.image,
baseProbability: shopItemsTable.baseProbability,
totalBoost: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
})
.from(refineryOrdersTable)
.innerJoin(shopItemsTable, eq(refineryOrdersTable.shopItemId, shopItemsTable.id))
.where(eq(refineryOrdersTable.userId, parseInt(params.id)))
.groupBy(refineryOrdersTable.shopItemId, shopItemsTable.name, shopItemsTable.image, shopItemsTable.baseProbability)
return {
user: {
id: targetUser[0].id,
username: targetUser[0].username,
avatar: targetUser[0].avatar,
scraps: targetUser[0].scraps,
scraps: scrapsBalance.balance,
createdAt: targetUser[0].createdAt
},
projects: visibleProjects.map(p => ({
@ -104,6 +148,14 @@ user.get('/profile/:id', async ({ params, headers }) => {
createdAt: p.createdAt
})),
heartedItems,
refinements: refinements.map(r => ({
shopItemId: r.shopItemId,
itemName: r.itemName,
itemImage: r.itemImage,
baseProbability: r.baseProbability,
totalBoost: Number(r.totalBoost),
effectiveProbability: Math.min(r.baseProbability + Number(r.totalBoost), 100)
})),
stats: {
projectCount: shippedCount,
inProgressCount,

View file

@ -2,6 +2,6 @@ export { usersTable } from './users'
export { projectsTable } from './projects'
export { reviewsTable } from './reviews'
export { sessionsTable } from './sessions'
export { shopItemsTable, shopHeartsTable } from './shop'
export { shopItemsTable, shopHeartsTable, shopOrdersTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable } from './shop'
export { newsTable } from './news'
export { activityTable } from './activity'

View file

@ -17,6 +17,7 @@ export const projectsTable = pgTable('projects', {
hoursOverride: real('hours_override'),
status: varchar().notNull().default('in_progress'),
deleted: integer('deleted').default(0),
scrapsAwarded: integer('scraps_awarded').notNull().default(0),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()

View file

@ -1,4 +1,4 @@
import { integer, pgTable, varchar, timestamp, unique } from 'drizzle-orm/pg-core'
import { integer, pgTable, varchar, timestamp, unique, text, boolean } from 'drizzle-orm/pg-core'
import { usersTable } from './users'
export const shopItemsTable = pgTable('shop_items', {
@ -9,6 +9,10 @@ export const shopItemsTable = pgTable('shop_items', {
price: integer().notNull(),
category: varchar().notNull(),
count: integer().notNull().default(0),
baseProbability: integer('base_probability').notNull().default(50),
baseUpgradeCost: integer('base_upgrade_cost').notNull().default(10),
costMultiplier: integer('cost_multiplier').notNull().default(115),
boostAmount: integer('boost_amount').notNull().default(1),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
@ -21,3 +25,48 @@ export const shopHeartsTable = pgTable('shop_hearts', {
}, (table) => [
unique().on(table.userId, table.shopItemId)
])
export const shopOrdersTable = pgTable('shop_orders', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => usersTable.id),
shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
quantity: integer().notNull().default(1),
pricePerItem: integer('price_per_item').notNull(),
totalPrice: integer('total_price').notNull(),
status: varchar().notNull().default('pending'),
orderType: varchar('order_type').notNull().default('purchase'),
shippingAddress: text('shipping_address'),
notes: text(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
export const shopRollsTable = pgTable('shop_rolls', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => usersTable.id),
shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
rolled: integer().notNull(),
threshold: integer().notNull(),
won: boolean().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const refineryOrdersTable = pgTable('refinery_orders', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => usersTable.id),
shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
cost: integer().notNull(),
boostAmount: integer('boost_amount').notNull().default(1),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const shopPenaltiesTable = pgTable('shop_penalties', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => usersTable.id),
shopItemId: integer('shop_item_id').notNull().references(() => shopItemsTable.id),
probabilityMultiplier: integer('probability_multiplier').notNull().default(100),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
}, (table) => [
unique().on(table.userId, table.shopItemId)
])

View file

@ -1,4 +1,4 @@
import { integer, pgTable, varchar, text, timestamp } from 'drizzle-orm/pg-core'
import { integer, pgTable, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core'
export const usersTable = pgTable('users', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
@ -17,11 +17,21 @@ export const usersTable = pgTable('users', {
refreshToken: text('refresh_token'),
idToken: text('id_token'),
// scraps info
scraps: integer().notNull().default(0),
// user role
role: varchar().notNull().default('member'),
internalNotes: text('internal_notes'),
// tutorial status
tutorialCompleted: boolean('tutorial_completed').notNull().default(false),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
})
export const userBonusesTable = pgTable('user_bonuses', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull().references(() => usersTable.id),
type: varchar().notNull(),
amount: integer().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})

View file

@ -1,4 +1,5 @@
import { API_URL } from '$lib/config'
import { writable } from 'svelte/store'
export interface User {
id: number
@ -8,8 +9,11 @@ export interface User {
slackId: string | null
scraps: number
role: string
tutorialCompleted: boolean
}
export const userScrapsStore = writable<number>(0)
let cachedUser: User | null | undefined = undefined
let fetchPromise: Promise<User | null> | null = null
@ -27,8 +31,8 @@ export async function logout() {
window.location.href = "/"
}
export async function getUser(): Promise<User | null> {
if (cachedUser !== undefined) return cachedUser
export async function getUser(forceRefresh = false): Promise<User | null> {
if (!forceRefresh && cachedUser !== undefined) return cachedUser
if (fetchPromise) return fetchPromise
@ -48,6 +52,9 @@ export async function getUser(): Promise<User | null> {
return null
}
cachedUser = (data.user as User) || null
if (cachedUser) {
userScrapsStore.set(cachedUser.scraps)
}
return cachedUser
} catch {
cachedUser = null
@ -59,3 +66,8 @@ export async function getUser(): Promise<User | null> {
return fetchPromise
}
export async function refreshUserScraps(): Promise<number | null> {
const user = await getUser(true)
return user?.scraps ?? null
}

View file

@ -0,0 +1,342 @@
<script lang="ts">
import { X, ChevronDown } from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { onMount } from 'svelte'
interface Address {
id: string
first_name: string
last_name: string
line_1: string
line_2: string | null
city: string
state: string
postal_code: string
country: string
phone_number: string | null
primary: boolean
}
let {
orderId,
itemName,
onClose,
onComplete
}: {
orderId: number
itemName: string
onClose: () => void
onComplete: () => void
} = $props()
let addresses = $state<Address[]>([])
let selectedAddressId = $state<string | null>(null)
let showDropdown = $state(false)
let loading = $state(false)
let loadingAddresses = $state(true)
let error = $state<string | null>(null)
let firstName = $state('')
let lastName = $state('')
let address1 = $state('')
let address2 = $state('')
let city = $state('')
let stateProvince = $state('')
let postalCode = $state('')
let country = $state('')
let phone = $state('')
let useNewAddress = $derived(addresses.length === 0 || selectedAddressId === 'new')
let formValid = $derived(
firstName.trim() !== '' &&
lastName.trim() !== '' &&
address1.trim() !== '' &&
city.trim() !== '' &&
stateProvince.trim() !== '' &&
postalCode.trim() !== '' &&
country.trim() !== ''
)
let canSubmit = $derived(useNewAddress ? formValid : selectedAddressId !== null)
onMount(async () => {
try {
const response = await fetch(`${API_URL}/shop/addresses`, {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
addresses = Array.isArray(data) ? data : []
}
} catch (e) {
console.error('Failed to fetch addresses:', e)
} finally {
loadingAddresses = false
}
})
function selectAddress(id: string) {
selectedAddressId = id
showDropdown = false
if (id !== 'new') {
const addr = addresses.find(a => a.id === id)
if (addr) {
firstName = addr.first_name
lastName = addr.last_name
address1 = addr.line_1
address2 = addr.line_2 || ''
city = addr.city
stateProvince = addr.state
postalCode = addr.postal_code
country = addr.country
phone = addr.phone_number || ''
}
} else {
firstName = ''
lastName = ''
address1 = ''
address2 = ''
city = ''
stateProvince = ''
postalCode = ''
country = ''
phone = ''
}
}
function getSelectedAddressLabel(): string {
if (selectedAddressId === 'new') return 'Enter new address'
const addr = addresses.find(a => a.id === selectedAddressId)
if (addr) return `${addr.first_name} ${addr.last_name}, ${addr.city}`
return 'Select an address'
}
async function handleSubmit() {
if (!canSubmit) return
loading = true
error = null
const shippingAddress = JSON.stringify({
firstName,
lastName,
address1,
address2: address2 || null,
city,
state: stateProvince,
postalCode,
country,
phone: phone || null
})
try {
const response = await fetch(`${API_URL}/shop/orders/${orderId}/address`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ shippingAddress })
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.message || 'Failed to save address')
}
onComplete()
onClose()
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save address'
} finally {
loading = false
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose()
}
}
</script>
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onClose()}
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">shipping address</h2>
<button onclick={onClose} class="p-1 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<X size={24} />
</button>
</div>
<div class="mb-6 p-4 border-2 border-black rounded-lg bg-gray-50">
<p class="text-lg font-bold">🎉 congratulations!</p>
<p class="text-gray-600 mt-1">you won <span class="font-bold">{itemName}</span>! enter your shipping address to receive it.</p>
</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">
{#if loadingAddresses}
<div class="text-center py-4 text-gray-500">loading addresses...</div>
{:else if addresses.length > 0}
<div>
<label class="block text-sm font-bold mb-1">saved addresses</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 cursor-pointer"
>
<span class={selectedAddressId ? '' : 'text-gray-500'}>{getSelectedAddressLabel()}</span>
<ChevronDown size={20} class={showDropdown ? 'rotate-180 transition-transform' : 'transition-transform'} />
</button>
{#if showDropdown}
<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">
{#each addresses as addr}
<button
type="button"
onclick={() => selectAddress(addr.id)}
class="w-full px-4 py-2 text-left hover:bg-gray-100 cursor-pointer"
>
<span class="font-medium">{addr.first_name} {addr.last_name}</span>
<span class="text-gray-500 text-sm block">{addr.line_1}, {addr.city}</span>
</button>
{/each}
<button
type="button"
onclick={() => selectAddress('new')}
class="w-full px-4 py-2 text-left hover:bg-gray-100 border-t border-gray-200 cursor-pointer"
>
<span class="font-medium">+ enter new address</span>
</button>
</div>
{/if}
</div>
</div>
{/if}
{#if useNewAddress || selectedAddressId}
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-bold mb-1">first name <span class="text-red-500">*</span></label>
<input
id="firstName"
type="text"
bind:value={firstName}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="lastName" class="block text-sm font-bold mb-1">last name <span class="text-red-500">*</span></label>
<input
id="lastName"
type="text"
bind:value={lastName}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div>
<label for="address1" class="block text-sm font-bold mb-1">address line 1 <span class="text-red-500">*</span></label>
<input
id="address1"
type="text"
bind:value={address1}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="address2" class="block text-sm font-bold mb-1">address line 2</label>
<input
id="address2"
type="text"
bind:value={address2}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="city" class="block text-sm font-bold mb-1">city <span class="text-red-500">*</span></label>
<input
id="city"
type="text"
bind:value={city}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="stateProvince" class="block text-sm font-bold mb-1">state <span class="text-red-500">*</span></label>
<input
id="stateProvince"
type="text"
bind:value={stateProvince}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="postalCode" class="block text-sm font-bold mb-1">postal code <span class="text-red-500">*</span></label>
<input
id="postalCode"
type="text"
bind:value={postalCode}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="country" class="block text-sm font-bold mb-1">country <span class="text-red-500">*</span></label>
<input
id="country"
type="text"
bind:value={country}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div>
<label for="phone" class="block text-sm font-bold mb-1">phone number</label>
<input
id="phone"
type="tel"
bind:value={phone}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
{/if}
</div>
<div class="flex gap-3 mt-6">
<button
onclick={onClose}
disabled={loading}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer"
>
cancel
</button>
<button
onclick={handleSubmit}
disabled={loading || !canSubmit}
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 cursor-pointer"
>
{loading ? 'saving...' : 'confirm shipping address'}
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,72 @@
<script lang="ts">
let {
title,
message,
confirmText = 'confirm',
cancelText = 'cancel',
confirmStyle = 'primary',
loading = false,
onConfirm,
onCancel
}: {
title: string
message: string
confirmText?: string
cancelText?: string
confirmStyle?: 'primary' | 'success' | 'warning' | 'danger'
loading?: boolean
onConfirm: () => void
onCancel: () => void
} = $props()
function getConfirmClass(): string {
const base = 'flex-1 px-4 py-2 rounded-full font-bold border-4 border-black transition-all duration-200 disabled:opacity-50 cursor-pointer hover:border-dashed'
switch (confirmStyle) {
case 'success':
return `${base} bg-green-600 text-white`
case 'warning':
return `${base} bg-yellow-500 text-white`
case 'danger':
return `${base} bg-red-600 text-white`
default:
return `${base} bg-black text-white`
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onCancel()
}
}
</script>
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onCancel()}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black">
<h2 class="text-2xl font-bold mb-4">{title}</h2>
<p class="text-gray-600 mb-6">
{@html message}
</p>
<div class="flex gap-3">
<button
onclick={onCancel}
disabled={loading}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer"
>
{cancelText}
</button>
<button
onclick={onConfirm}
disabled={loading}
class={getConfirmClass()}
>
{loading ? 'loading...' : confirmText}
</button>
</div>
</div>
</div>

View file

@ -2,6 +2,8 @@
import { X, ChevronDown, Upload, Check } from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { formatHours } from '$lib/utils'
import { tutorialProjectIdStore } from '$lib/stores'
import { goto } from '$app/navigation'
interface Project {
id: number
@ -25,15 +27,25 @@
let {
open,
onClose,
onCreated
onCreated,
tutorialMode = false
}: {
open: boolean
onClose: () => void
onCreated: (project: Project) => void
tutorialMode?: boolean
} = $props()
let name = $state('')
let description = $state('')
// Pre-fill values when modal opens in tutorial mode
$effect(() => {
if (open && tutorialMode && name === '' && description === '') {
name = 'my first scrap'
description = "this is my first project on scraps! i'm excited to start building and earning rewards."
}
})
let githubUrl = $state('')
let imageUrl = $state<string | null>(null)
let imagePreview = $state<string | null>(null)
@ -180,7 +192,14 @@
const newProject = await response.json()
resetForm()
if (tutorialMode) {
tutorialProjectIdStore.set(newProject.id)
window.dispatchEvent(new CustomEvent('tutorial:project-created'))
onCreated(newProject)
goto(`/projects/${newProject.id}`, { invalidateAll: false })
} else {
onCreated(newProject)
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create project'
} finally {
@ -194,6 +213,7 @@
}
function handleBackdropClick(e: MouseEvent) {
if (tutorialMode) return
if (e.target === e.currentTarget) {
handleClose()
}
@ -202,13 +222,13 @@
{#if open}
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
class="fixed inset-0 flex items-center justify-center p-4 {tutorialMode ? 'z-[200] bg-transparent' : 'z-50 bg-black/50'}"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
onkeydown={(e) => !tutorialMode && 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="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto {tutorialMode ? 'z-[250]' : ''}" data-tutorial="create-project-modal">
<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 cursor-pointer">

View file

@ -8,7 +8,7 @@
}: {
count: number
hearted: boolean
onclick: () => void
onclick: (e: MouseEvent) => void
} = $props()
</script>

View file

@ -17,7 +17,7 @@
ShoppingBag,
Newspaper
} from '@lucide/svelte'
import { logout, getUser } from '$lib/auth-client'
import { logout, getUser, userScrapsStore } from '$lib/auth-client'
interface User {
id: number
@ -41,7 +41,6 @@
let isReviewer = $derived(user?.role === 'admin' || user?.role === 'reviewer')
let isAdminOnly = $derived(user?.role === 'admin')
let isInAdminSection = $derived(currentPath.startsWith('/admin'))
let scraps = $derived(user?.scraps ?? 0)
let observer: IntersectionObserver | null = null
@ -273,7 +272,7 @@
{:else if user}
<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">{scraps}</span>
<span class="text-lg font-bold">{$userScrapsStore}</span>
</div>
<div class="relative profile-menu-container">

View file

@ -41,7 +41,7 @@
}
</script>
{#if !$newsLoading && $newsStore.length > 0}
{#if !$newsLoading}
<div
class="border-4 border-black rounded-2xl p-6 mb-8 relative overflow-hidden"
onmouseenter={() => (isPaused = true)}
@ -71,6 +71,9 @@
{/if}
</div>
{#if $newsStore.length === 0}
<p class="text-gray-500">no news right now</p>
{:else}
<div class="relative h-20 overflow-hidden">
{#each $newsStore as item, index (item.id)}
<div
@ -100,5 +103,6 @@
{/each}
</div>
{/if}
{/if}
</div>
{/if}

View file

@ -0,0 +1,418 @@
<script lang="ts">
import { X, Spool, Trophy, Heart, ShoppingBag } from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { refreshUserScraps, userScrapsStore } from '$lib/auth-client'
import HeartButton from './HeartButton.svelte'
import { type ShopItem, updateShopItemHeart } from '$lib/stores'
interface LeaderboardUser {
userId: string
username: string
avatar: string
boostPercent: number
effectiveProbability: number
}
interface Buyer {
userId: string
username: string
avatar: string
purchasedAt: string
}
interface HeartUser {
userId: string
username: string
avatar: string
}
let {
item,
onClose,
onTryLuck
}: {
item: ShopItem
onClose: () => void
onTryLuck: (orderId: number) => void
} = $props()
let activeTab = $state<'leaderboard' | 'wishlist' | 'buyers'>('leaderboard')
let leaderboard = $state<LeaderboardUser[]>([])
let buyers = $state<Buyer[]>([])
let heartUsers = $state<HeartUser[]>([])
let loadingLeaderboard = $state(false)
let loadingBuyers = $state(false)
let loadingHearts = $state(false)
let tryingLuck = $state(false)
let showConfirmation = $state(false)
let localHearted = $state(item.userHearted)
let localHeartCount = $state(item.heartCount)
let canAfford = $derived($userScrapsStore >= item.price)
let alertMessage = $state<string | null>(null)
let alertType = $state<'error' | 'info'>('info')
function getProbabilityColor(prob: number): string {
if (prob >= 70) return 'text-green-600'
if (prob >= 40) return 'text-yellow-600'
return 'text-red-600'
}
function getProbabilityBgColor(prob: number): string {
if (prob >= 70) return 'bg-green-100'
if (prob >= 40) return 'bg-yellow-100'
return 'bg-red-100'
}
async function fetchLeaderboard() {
loadingLeaderboard = true
try {
const response = await fetch(`${API_URL}/shop/items/${item.id}/leaderboard`, {
credentials: 'include'
})
if (response.ok) {
leaderboard = await response.json()
}
} catch (e) {
console.error('Failed to fetch leaderboard:', e)
} finally {
loadingLeaderboard = false
}
}
async function fetchBuyers() {
loadingBuyers = true
try {
const response = await fetch(`${API_URL}/shop/items/${item.id}/buyers`, {
credentials: 'include'
})
if (response.ok) {
buyers = await response.json()
}
} catch (e) {
console.error('Failed to fetch buyers:', e)
} finally {
loadingBuyers = false
}
}
async function fetchHeartUsers() {
loadingHearts = true
try {
const response = await fetch(`${API_URL}/shop/items/${item.id}/hearts`, {
credentials: 'include'
})
if (response.ok) {
heartUsers = await response.json()
}
} catch (e) {
console.error('Failed to fetch heart users:', e)
} finally {
loadingHearts = false
}
}
async function handleToggleHeart() {
try {
const response = await fetch(`${API_URL}/shop/items/${item.id}/heart`, {
method: 'POST',
credentials: 'include'
})
if (response.ok) {
localHearted = !localHearted
localHeartCount = localHearted ? localHeartCount + 1 : localHeartCount - 1
// Sync with the store so the shop page updates
updateShopItemHeart(item.id, localHearted)
}
} catch (e) {
console.error('Failed to toggle heart:', e)
}
}
async function handleTryLuck() {
tryingLuck = true
try {
const response = await fetch(`${API_URL}/shop/items/${item.id}/try-luck`, {
method: 'POST',
credentials: 'include'
})
const data = await response.json()
if (!response.ok) {
alertType = 'error'
alertMessage = data.error || 'Failed to try luck'
return
}
if (data.won) {
await refreshUserScraps()
onTryLuck(data.orderId)
} else {
alertType = 'info'
alertMessage = 'Better luck next time! You rolled ' + data.rolled + ' but needed ' + data.effectiveProbability.toFixed(0) + ' or less.'
}
} catch (e) {
console.error('Failed to try luck:', e)
alertType = 'error'
alertMessage = 'Something went wrong'
} finally {
tryingLuck = false
showConfirmation = false
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
if (showConfirmation) {
showConfirmation = false
} else {
onClose()
}
}
}
let leaderboardFetched = false
let buyersFetched = false
let heartsFetched = false
$effect(() => {
if (activeTab === 'leaderboard' && !leaderboardFetched) {
leaderboardFetched = true
fetchLeaderboard()
} else if (activeTab === 'buyers' && !buyersFetched) {
buyersFetched = true
fetchBuyers()
} else if (activeTab === 'wishlist' && !heartsFetched) {
heartsFetched = true
fetchHeartUsers()
}
})
</script>
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && (showConfirmation ? (showConfirmation = false) : onClose())}
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-start justify-between mb-4">
<h2 class="text-2xl font-bold">{item.name}</h2>
<button onclick={onClose} class="p-1 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<X size={24} />
</button>
</div>
<img src={item.image} alt={item.name} class="w-full h-48 object-contain mb-4 bg-gray-50 rounded-lg" />
<p class="text-gray-600 mb-4">{item.description}</p>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-4">
<span class="text-xl font-bold flex items-center gap-1">
<Spool size={20} />
{item.price}
</span>
<span class="text-sm text-gray-500">{item.count} left</span>
</div>
<HeartButton count={localHeartCount} hearted={localHearted} onclick={() => handleToggleHeart()} />
</div>
<div class="flex gap-2 flex-wrap mb-4">
{#each item.category.split(',').map((c) => c.trim()).filter(Boolean) as cat}
<span class="text-xs px-2 py-1 bg-gray-100 rounded-full">{cat}</span>
{/each}
</div>
<div class="p-4 border-2 border-black rounded-lg mb-4 {getProbabilityBgColor(item.effectiveProbability)}">
<p class="text-sm font-bold mb-2">your chance</p>
<p class="text-3xl font-bold {getProbabilityColor(item.effectiveProbability)}">
{item.effectiveProbability.toFixed(1)}%
</p>
<div class="text-xs text-gray-600 mt-2 flex gap-4">
<span>base: {item.baseProbability}%</span>
<span>your boost: +{item.userBoostPercent}%</span>
</div>
</div>
<div class="flex gap-2 mb-4">
<button
onclick={() => (activeTab = 'leaderboard')}
class="flex-1 px-3 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer flex items-center justify-center gap-1 {activeTab === 'leaderboard'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
<Trophy size={16} />
leaderboard
</button>
<button
onclick={() => (activeTab = 'wishlist')}
class="flex-1 px-3 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer flex items-center justify-center gap-1 {activeTab === 'wishlist'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
<Heart size={16} />
wishlist
</button>
<button
onclick={() => (activeTab = 'buyers')}
class="flex-1 px-3 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer flex items-center justify-center gap-1 {activeTab === 'buyers'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
<ShoppingBag size={16} />
buyers
</button>
</div>
<div class="border-2 border-black rounded-lg p-4 mb-6 min-h-[120px]">
{#if activeTab === 'leaderboard'}
{#if loadingLeaderboard}
<p class="text-gray-500 text-center">loading...</p>
{:else if leaderboard.length === 0}
<p class="text-gray-500 text-center">no one has boosted yet</p>
{:else}
<div class="space-y-2">
{#each leaderboard as user, i}
<div class="flex items-center gap-3">
<span class="font-bold w-6">{i + 1}.</span>
<img src={user.avatar} alt={user.username} class="w-8 h-8 rounded-full" />
<span class="font-medium flex-1">{user.username}</span>
<span class="text-sm {getProbabilityColor(user.effectiveProbability)}">
{user.effectiveProbability.toFixed(1)}%
</span>
</div>
{/each}
</div>
{/if}
{:else if activeTab === 'wishlist'}
<div class="text-center">
<p class="text-2xl font-bold mb-2">{localHeartCount}</p>
<p class="text-gray-600 mb-4">people want this item</p>
{#if localHearted}
<p class="text-sm text-green-600 mb-4 font-medium">including you!</p>
{/if}
{#if loadingHearts}
<p class="text-gray-500 text-sm">loading...</p>
{:else if heartUsers.length > 0}
<div class="flex flex-wrap justify-center gap-2 mt-2">
{#each heartUsers as heartUser, i}
<div
class="relative"
style="animation: float 3s ease-in-out infinite; animation-delay: {i * 0.2}s"
>
<img
src={heartUser.avatar}
alt={heartUser.username}
title={heartUser.username}
class="w-10 h-10 rounded-full border-2 border-pink-300 shadow-md hover:scale-110 transition-transform cursor-pointer"
/>
</div>
{/each}
</div>
{/if}
</div>
{:else if activeTab === 'buyers'}
{#if loadingBuyers}
<p class="text-gray-500 text-center">loading...</p>
{:else if buyers.length === 0}
<p class="text-gray-500 text-center">no one has won this item yet</p>
{:else}
<div class="space-y-2">
{#each buyers as buyer}
<div class="flex items-center gap-3">
<img src={buyer.avatar} alt={buyer.username} class="w-8 h-8 rounded-full" />
<span class="font-medium flex-1">{buyer.username}</span>
<span class="text-xs text-gray-500">
{new Date(buyer.purchasedAt).toLocaleDateString()}
</span>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<button
onclick={() => (showConfirmation = true)}
disabled={item.count === 0 || tryingLuck || !canAfford}
class="w-full px-4 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 cursor-pointer text-lg"
>
{#if item.count === 0}
out of stock
{:else if !canAfford}
not enough scraps
{:else}
try your luck
{/if}
</button>
</div>
{#if showConfirmation}
<div
class="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4"
onclick={(e) => e.target === e.currentTarget && (showConfirmation = false)}
onkeydown={(e) => e.key === 'Escape' && (showConfirmation = false)}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black">
<h2 class="text-2xl font-bold mb-4">confirm try your luck</h2>
<p class="text-gray-600 mb-6">
are you sure you want to try your luck? this will cost <strong>{item.price} scraps</strong>.
<span class="block mt-2">
your chance: <strong class={getProbabilityColor(item.effectiveProbability)}>{item.effectiveProbability.toFixed(1)}%</strong>
</span>
</p>
<div class="flex gap-3">
<button
onclick={() => (showConfirmation = false)}
disabled={tryingLuck}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer"
>
cancel
</button>
<button
onclick={handleTryLuck}
disabled={tryingLuck}
class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold border-4 border-black hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer"
>
{tryingLuck ? 'trying...' : 'try luck'}
</button>
</div>
</div>
</div>
{/if}
{#if alertMessage}
<div
class="fixed inset-0 bg-black/50 z-[70] flex items-center justify-center p-4"
onclick={(e) => e.target === e.currentTarget && (alertMessage = null)}
onkeydown={(e) => e.key === 'Escape' && (alertMessage = null)}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black">
<h2 class="text-2xl font-bold mb-4">{alertType === 'error' ? 'error' : 'result'}</h2>
<p class="text-gray-600 mb-6">{alertMessage}</p>
<button
onclick={() => (alertMessage = null)}
class="w-full px-4 py-2 bg-black text-white rounded-full font-bold border-4 border-black hover:border-dashed transition-all duration-200 cursor-pointer"
>
ok
</button>
</div>
</div>
{/if}
</div>
<style>
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-6px);
}
}
</style>

View file

@ -0,0 +1,464 @@
<script lang="ts">
import {
Origami,
ShoppingBag,
Flame,
Trophy,
Clock,
Gift,
Sparkles,
ArrowRight,
X,
LayoutDashboard,
Plus
} from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { refreshUserScraps } from '$lib/auth-client'
import { goto, preloadData } from '$app/navigation'
import { tutorialActiveStore } from '$lib/stores'
import { onMount, onDestroy } from 'svelte'
let { onComplete }: { onComplete: () => void } = $props()
let currentStep = $state(0)
let loading = $state(false)
// Dragging state
let isDragging = $state(false)
let dragOffset = $state({ x: 0, y: 0 })
let cardOffset = $state<{ x: number; y: number } | null>(null)
function handleDragStart(e: MouseEvent) {
isDragging = true
const card = (e.target as HTMLElement).closest('[data-tutorial-card]') as HTMLElement
if (card) {
const rect = card.getBoundingClientRect()
dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
if (!cardOffset) {
cardOffset = { x: rect.left, y: rect.top }
}
}
}
function handleDragMove(e: MouseEvent) {
if (!isDragging) return
cardOffset = {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y
}
}
function handleDragEnd() {
isDragging = false
}
// Reset card position when step changes
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
currentStep
cardOffset = null
})
onMount(() => {
tutorialActiveStore.set(true)
window.addEventListener('mousemove', handleDragMove)
window.addEventListener('mouseup', handleDragEnd)
// Preload pages that the tutorial will navigate to
preloadData('/dashboard')
preloadData('/shop')
})
onDestroy(() => {
tutorialActiveStore.set(false)
window.removeEventListener('mousemove', handleDragMove)
window.removeEventListener('mouseup', handleDragEnd)
})
const steps = [
{
title: 'welcome to scraps!',
description:
'scraps is a program where you earn rewards for building cool projects. let\'s walk through how it works!',
highlight: null
},
{
title: 'navigation',
description:
'use the navbar to navigate between dashboard (your projects), shop (spend scraps), refinery (boost odds), and leaderboard (see top builders).',
highlight: 'navbar'
},
{
title: 'create projects',
description:
'start by creating projects on your dashboard. link them to Hackatime (hackatime.hackclub.com) to automatically track your coding time.',
highlight: 'dashboard'
},
{
title: 'create your first project',
description:
'click the "new project" button on the right to open the project creation modal.',
highlight: 'new-project-button',
waitForClick: true
},
{
title: 'fill in project details',
description:
'we\'ve pre-filled some example text for you. feel free to customize it or just click "create" to continue!',
highlight: 'create-project-modal',
waitForEvent: 'tutorial:project-created'
},
{
title: 'your project page',
description:
'great job! this is your project page. you can see details, edit your project, and submit it for review when ready.',
highlight: null,
position: 'bottom-center'
},
{
title: 'submit for review',
description:
'when you\'re ready to ship, click "review & submit" to submit your project. once approved, you\'ll earn scraps based on your coding time!',
highlight: 'submit-button'
},
{
title: 'earn scraps',
description:
'you earn scraps for the time you put in. the more you build, the more you earn!',
highlight: null
},
{
title: 'the shop',
description:
'spend your scraps in the shop to try your luck at winning prizes. each item has a base probability of success.',
highlight: 'shop'
},
{
title: 'the refinery',
description:
'invest scraps in the refinery to boost your probability for specific items. higher probability = better odds!',
highlight: 'refinery'
},
{
title: 'strategy time',
description:
'you have a choice: try your luck at base probability OR invest in the refinery first to boost your odds. choose wisely!',
highlight: null
},
{
title: 'you\'re ready!',
description:
'here\'s 10 bonus scraps to get you started. now go build something awesome!',
highlight: null
}
]
let currentStepData = $derived(steps[currentStep])
let isLastStep = $derived(currentStep === steps.length - 1)
let stepProgress = $derived(`step ${currentStep + 1} of ${steps.length}`)
function getHighlightPosition(highlight: string | null): {
top: number
left: number
width: number
height: number
} | null {
if (!highlight) return null
const selectors: Record<string, string> = {
navbar: 'nav',
dashboard: 'a[href="/dashboard"]',
shop: 'a[href="/shop"]',
refinery: 'a[href="/refinery"]',
leaderboard: 'a[href="/leaderboard"]',
'new-project-button': 'button[data-tutorial="new-project"]',
'create-project-modal': '[data-tutorial="create-project-modal"]',
'submit-button': '[data-tutorial="submit-button"]'
}
const selector = selectors[highlight]
if (!selector) return null
const element = document.querySelector(selector)
if (!element) return null
const rect = element.getBoundingClientRect()
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height
}
}
let highlightTick = $state(0)
let highlightRect = $derived.by(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
highlightTick // trigger reactivity
return getHighlightPosition(currentStepData.highlight)
})
// Re-calculate highlight position after a short delay when step changes
$effect(() => {
const highlight = currentStepData.highlight
if (highlight) {
const timeout = setTimeout(() => {
highlightTick++
}, 100)
return () => clearTimeout(timeout)
}
})
let cardPosition = $derived.by(() => {
// Check for explicit position override first
const explicitPosition = (currentStepData as { position?: string }).position
if (explicitPosition) {
return explicitPosition
}
const highlight = currentStepData.highlight
if (highlight === 'navbar' || highlight === 'dashboard' || highlight === 'shop' || highlight === 'refinery' || highlight === 'leaderboard') {
return 'bottom'
}
if (highlight === 'new-project-button' || highlight === 'create-project-modal' || highlight === 'submit-button') {
return 'left'
}
return 'center'
})
$effect(() => {
if (currentStepData.highlight === 'shop') {
goto('/shop', { invalidateAll: false })
} else if (currentStepData.highlight === 'dashboard' || currentStepData.highlight === 'new-project-button') {
goto('/dashboard', { invalidateAll: false })
}
})
// Listen for clickable element clicks to advance tutorial
$effect(() => {
if (currentStepData.waitForClick && currentStepData.highlight) {
const selectors: Record<string, string> = {
'new-project-button': 'button[data-tutorial="new-project"]',
'submit-button': '[data-tutorial="submit-button"]'
}
const selector = selectors[currentStepData.highlight]
if (selector) {
const element = document.querySelector(selector)
if (element) {
const handleClick = () => {
nextStep()
}
element.addEventListener('click', handleClick)
return () => element.removeEventListener('click', handleClick)
}
}
}
})
// Listen for custom events to advance tutorial (e.g., project created)
$effect(() => {
const waitForEvent = (currentStepData as { waitForEvent?: string }).waitForEvent
if (waitForEvent) {
const handleEvent = () => {
nextStep()
}
window.addEventListener(waitForEvent, handleEvent)
return () => window.removeEventListener(waitForEvent, handleEvent)
}
})
function nextStep() {
if (isLastStep) {
completeTutorial()
} else {
currentStep++
}
}
function skip() {
completeTutorial()
}
async function completeTutorial() {
loading = true
try {
await fetch(`${API_URL}/user/complete-tutorial`, {
method: 'POST',
credentials: 'include'
})
await refreshUserScraps()
onComplete()
} catch {
onComplete()
}
}
function handleBackdropClick(e: MouseEvent) {
// Don't close on backdrop click when waiting for user to click a specific element
if (currentStepData.waitForClick) return
if (e.target === e.currentTarget) {
skip()
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
skip()
} else if (e.key === 'ArrowRight' || e.key === 'Enter') {
nextStep()
} else if (e.key === 'ArrowLeft' && currentStep > 0) {
currentStep--
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 z-[100] flex items-center justify-center {highlightRect ? 'pointer-events-none' : ''}"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && skip()}
role="dialog"
tabindex="-1"
>
<!-- Dark overlay with cutout for highlighted element -->
{#if highlightRect}
<!-- Dark overlay using 4 divs around the spotlight to allow clicking through the cutout -->
<div class="absolute inset-0 pointer-events-none">
<!-- Top -->
<div class="absolute top-0 left-0 right-0 bg-black/75 pointer-events-auto" style="height: {highlightRect.top - 8}px;"></div>
<!-- Bottom -->
<div class="absolute bottom-0 left-0 right-0 bg-black/75 pointer-events-auto" style="top: {highlightRect.top + highlightRect.height + 8}px;"></div>
<!-- Left -->
<div class="absolute bg-black/75 pointer-events-auto" style="top: {highlightRect.top - 8}px; left: 0; width: {highlightRect.left - 8}px; height: {highlightRect.height + 16}px;"></div>
<!-- Right -->
<div class="absolute bg-black/75 pointer-events-auto" style="top: {highlightRect.top - 8}px; right: 0; left: {highlightRect.left + highlightRect.width + 8}px; height: {highlightRect.height + 16}px;"></div>
</div>
<!-- Highlight border -->
<div
class="absolute border-4 border-white rounded-2xl pointer-events-none animate-pulse"
style="top: {highlightRect.top - 8}px; left: {highlightRect.left - 8}px; width: {highlightRect.width + 16}px; height: {highlightRect.height + 16}px;"
></div>
{:else}
<div class="absolute inset-0 bg-black/75"></div>
{/if}
<!-- Tutorial card -->
<div
data-tutorial-card
class="bg-white rounded-2xl w-full max-w-lg border-4 border-black pointer-events-auto {currentStepData.highlight === 'create-project-modal' ? 'z-[300]' : ''} {cardOffset ? 'fixed' : 'relative mx-4'} {!cardOffset && cardPosition === 'bottom' ? 'mt-auto mb-8' : !cardOffset && cardPosition === 'bottom-center' ? 'mt-auto mb-8' : !cardOffset && cardPosition === 'top' ? 'mb-auto mt-8' : !cardOffset && cardPosition === 'left' ? 'mr-auto ml-8' : ''}"
style={cardOffset ? `left: ${cardOffset.x}px; top: ${cardOffset.y}px;` : ''}
>
<!-- Drag handle -->
<div
onmousedown={handleDragStart}
role="toolbar"
class="flex items-center justify-between px-6 pt-4 pb-2 cursor-move border-b-2 border-dashed border-gray-200 select-none"
>
<div class="text-sm text-gray-500 font-bold">{stepProgress}</div>
<!-- Close button -->
<button
onclick={skip}
class="p-2 hover:bg-gray-100 rounded-full transition-all duration-200 cursor-pointer"
aria-label="Skip tutorial"
>
<X size={20} />
</button>
</div>
<div class="p-6 pt-4">
<!-- Progress dots -->
<div class="flex gap-1 mb-6">
{#each steps as _, i}
<div
class="h-1 flex-1 rounded-full transition-all duration-200 {i <= currentStep
? 'bg-black'
: 'bg-gray-200'}"
></div>
{/each}
</div>
<!-- Icon -->
<div class="w-16 h-16 rounded-full bg-black text-white flex items-center justify-center mb-4">
{#if currentStep === 0}
<Origami size={32} />
{:else if currentStep === 1}
<LayoutDashboard size={32} />
{:else if currentStep === 2}
<Clock size={32} />
{:else if currentStep === 3}
<Plus size={32} />
{:else if currentStep === 4}
<Gift size={32} />
{:else if currentStep === 5}
<Sparkles size={32} />
{:else if currentStep === 6}
<ShoppingBag size={32} />
{:else if currentStep === 7}
<Flame size={32} />
{:else if currentStep === 8}
<Trophy size={32} />
{:else}
<Gift size={32} />
{/if}
</div>
<!-- Title -->
<h2 class="text-2xl font-bold mb-2">{currentStepData.title}</h2>
<!-- Description -->
<p class="text-gray-600 mb-6">{currentStepData.description}</p>
{#if currentStepData.highlight === null && currentStep === 2}
<a
href="https://hackatime.hackclub.com"
target="_blank"
rel="noopener noreferrer"
class="inline-block text-black font-bold underline hover:no-underline mb-4"
>
set up hackatime →
</a>
{/if}
<!-- Buttons -->
<div class="flex gap-3">
<button
onclick={skip}
disabled={loading}
class="px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
>
skip
</button>
{#if currentStepData.waitForClick}
<div class="flex-1 px-4 py-2 bg-gray-200 text-gray-600 rounded-full font-bold flex items-center justify-center gap-2">
<ArrowRight size={18} />
<span>click the button to continue</span>
</div>
{:else if (currentStepData as { waitForEvent?: string }).waitForEvent}
<div class="flex-1 px-4 py-2 bg-gray-200 text-gray-600 rounded-full font-bold flex items-center justify-center gap-2">
<ArrowRight size={18} />
<span>complete the form to continue</span>
</div>
{:else}
<button
onclick={nextStep}
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 cursor-pointer disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{#if loading}
<span>completing...</span>
{:else if isLastStep}
<Gift size={18} />
<span>claim 10 scraps</span>
{:else}
<span>next</span>
<ArrowRight size={18} />
{/if}
</button>
{/if}
</div>
</div>
</div>
</div>

View file

@ -1,2 +1 @@
// .env https://belle-engines-ppc-duplicate.trycloudflare.com
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api'
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'

View file

@ -25,6 +25,11 @@ export interface ShopItem {
count: number
heartCount: number
userHearted: boolean
baseProbability: number
baseUpgradeCost: number
costMultiplier: number
userBoostPercent: number
effectiveProbability: number
}
export interface LeaderboardEntry {
@ -34,9 +39,23 @@ export interface LeaderboardEntry {
avatar: string
hours: number
scraps: number
scrapsEarned: number
projectCount: number
}
export interface ProbabilityLeader {
itemId: number
itemName: string
itemImage: string
baseProbability: number
topUser: {
id: number
username: string
avatar: string | null
} | null
effectiveProbability: number
}
export interface NewsItem {
id: number
title: string
@ -46,6 +65,8 @@ export interface NewsItem {
// Stores
export const userStore = writable<User | null>(null)
export const tutorialActiveStore = writable(false)
export const tutorialProjectIdStore = writable<number | null>(null)
export const projectsStore = writable<Project[]>([])
export const shopItemsStore = writable<ShopItem[]>([])
export const leaderboardStore = writable<{ hours: LeaderboardEntry[]; scraps: LeaderboardEntry[] }>({
@ -53,12 +74,14 @@ export const leaderboardStore = writable<{ hours: LeaderboardEntry[]; scraps: Le
scraps: []
})
export const newsStore = writable<NewsItem[]>([])
export const probabilityLeadersStore = writable<ProbabilityLeader[]>([])
// Loading states
export const projectsLoading = writable(true)
export const shopLoading = writable(true)
export const leaderboardLoading = writable(true)
export const newsLoading = writable(true)
export const probabilityLeadersLoading = writable(true)
// Track if this is a fresh page load (refresh/external) vs SPA navigation
let isInitialLoad = true
@ -90,10 +113,12 @@ export function invalidateAllStores() {
shopItemsStore.set([])
leaderboardStore.set({ hours: [], scraps: [] })
newsStore.set([])
probabilityLeadersStore.set([])
projectsLoading.set(true)
shopLoading.set(true)
leaderboardLoading.set(true)
newsLoading.set(true)
probabilityLeadersLoading.set(true)
}
// Fetch functions
@ -192,6 +217,30 @@ export async function fetchNews(force = false) {
}
}
export async function fetchProbabilityLeaders(force = false) {
if (!browser) return
const current = get(probabilityLeadersStore)
if (current.length > 0 && !force && !get(probabilityLeadersLoading)) return current
probabilityLeadersLoading.set(true)
try {
const response = await fetch(`${API_URL}/leaderboard/probability-leaders`, {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
probabilityLeadersStore.set(data)
return data
}
} catch (e) {
console.error('Failed to fetch probability leaders:', e)
} finally {
probabilityLeadersLoading.set(false)
}
return []
}
// Background prefetch for common data
export async function prefetchUserData() {
if (!browser) return

View file

@ -5,12 +5,15 @@
import favicon from '$lib/assets/favicon.ico'
import Navbar from '$lib/components/Navbar.svelte'
import Footer from '$lib/components/Footer.svelte'
import Tutorial from '$lib/components/Tutorial.svelte'
import { handleNavigation, prefetchUserData } from '$lib/stores'
import { getUser } from '$lib/auth-client'
import { getUser, type User } from '$lib/auth-client'
let { children } = $props()
let previousPath = $state('')
let showTutorial = $state(false)
let user = $state<User | null>(null)
// Handle navigation changes
$effect(() => {
@ -23,11 +26,19 @@
// Prefetch data on initial load if user is logged in
onMount(async () => {
const user = await getUser()
user = await getUser()
if (user) {
prefetchUserData()
// Show tutorial for users who haven't completed it
if (!user.tutorialCompleted) {
showTutorial = true
}
}
})
function handleTutorialComplete() {
showTutorial = false
}
</script>
<svelte:head><link rel="icon" href={favicon} />
@ -42,3 +53,7 @@
</main>
<Footer />
</div>
{#if showTutorial}
<Tutorial onComplete={handleTutorialComplete} />
{/if}

View file

@ -3,78 +3,97 @@
import { Origami } from '@lucide/svelte'
import Superscript from '$lib/components/Superscript.svelte'
import { login } from '$lib/auth-client'
import { API_URL } from '$lib/config'
function handleLogin() {
login()
}
interface ScrapItem {
id: string;
image: string;
title: string;
chance: number;
description?: string;
interface ShopItem {
id: number
name: string
description: string
image: string
price: number
category: string
}
const exampleItems: ScrapItem[] = [
{ id: '1', image: '/hero.png', title: 'esp32', chance: 15, description: 'a tiny microcontroller' },
{ id: '2', image: '/hero.png', title: 'arduino nano', chance: 10 },
{ id: '3', image: '/hero.png', title: 'breadboard', chance: 20, description: 'for prototyping' },
{ id: '4', image: '/hero.png', title: 'resistor pack', chance: 25 },
{ id: '5', image: '/hero.png', title: 'vermont fudge', chance: 5, description: 'delicious!' },
{ id: '6', image: '/hero.png', title: 'rare sticker', chance: 8 },
{ id: '7', image: '/hero.png', title: 'postcard', chance: 12 },
{ id: '8', image: '/hero.png', title: 'sensor kit', chance: 5, description: 'various sensors' }
];
interface ScrapItem {
id: string
image: string
title: string
price: number
description?: string
}
let row1ScrollPos = $state(0);
let row2ScrollPos = $state(0);
let isManualScrolling = $state(false);
let manualScrollTimeout: ReturnType<typeof setTimeout>;
let shopItems = $state<ShopItem[]>([])
let row1ScrollPos = $state(0)
let row2ScrollPos = $state(0)
let isManualScrolling = $state(false)
let manualScrollTimeout: ReturnType<typeof setTimeout>
const SCROLL_SPEED = 0.5;
const ITEM_WIDTH = 280;
const GAP = 16;
const SCROLL_SPEED = 0.5
const ITEM_WIDTH = 280
const GAP = 16
function shopToScrapItems(items: ShopItem[]): ScrapItem[] {
return items.map((item) => ({
id: String(item.id),
image: item.image || '/hero.png',
title: item.name,
price: item.price,
description: item.description
}))
}
function duplicateItems(items: ScrapItem[], times: number): ScrapItem[] {
const result: ScrapItem[] = [];
const result: ScrapItem[] = []
for (let i = 0; i < times; i++) {
items.forEach((item, idx) => {
result.push({ ...item, id: `${item.id}-${i}-${idx}` });
});
result.push({ ...item, id: `${item.id}-${i}-${idx}` })
})
}
return result;
return result
}
const row1Items = duplicateItems(exampleItems, 8);
const row2Items = duplicateItems([...exampleItems].reverse(), 8);
let scrapItems = $derived(shopToScrapItems(shopItems))
let row1Items = $derived(duplicateItems(scrapItems, 8))
let row2Items = $derived(duplicateItems([...scrapItems].reverse(), 8))
let totalSetWidth = $derived(scrapItems.length * (ITEM_WIDTH + GAP))
const totalSetWidth = exampleItems.length * (ITEM_WIDTH + GAP);
onMount(async () => {
try {
const response = await fetch(`${API_URL}/shop/items`)
if (response.ok) {
shopItems = await response.json()
}
} catch (e) {
console.error('Failed to fetch shop items:', e)
}
onMount(() => {
let animationId: number;
let animationId: number
function animate() {
if (!isManualScrolling) {
row1ScrollPos += SCROLL_SPEED;
row2ScrollPos += SCROLL_SPEED;
if (!isManualScrolling && totalSetWidth > 0) {
row1ScrollPos += SCROLL_SPEED
row2ScrollPos += SCROLL_SPEED
if (row1ScrollPos >= totalSetWidth) {
row1ScrollPos = row1ScrollPos - totalSetWidth;
row1ScrollPos = row1ScrollPos - totalSetWidth
}
if (row2ScrollPos >= totalSetWidth) {
row2ScrollPos = row2ScrollPos - totalSetWidth;
row2ScrollPos = row2ScrollPos - totalSetWidth
}
}
animationId = requestAnimationFrame(animate);
animationId = requestAnimationFrame(animate)
}
animate();
animate()
return () => {
cancelAnimationFrame(animationId);
};
});
cancelAnimationFrame(animationId)
}
})
function handleWheel(e: WheelEvent, row: 1 | 2) {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
@ -157,7 +176,7 @@
>
<img src={item.image} alt={item.title} class="w-full h-32 object-contain mb-3" />
<h3 class="font-bold text-lg">{item.title}</h3>
<p class="text-sm text-gray-600">{item.chance}% chance</p>
<p class="text-sm text-gray-600">{item.price} scraps</p>
{#if item.description}
<p class="text-sm mt-1">{item.description}</p>
{/if}
@ -184,7 +203,7 @@
>
<img src={item.image} alt={item.title} class="w-full h-32 object-contain mb-3" />
<h3 class="font-bold text-lg">{item.title}</h3>
<p class="text-sm text-gray-600">{item.chance}% chance</p>
<p class="text-sm text-gray-600">{item.price} scraps</p>
{#if item.description}
<p class="text-sm mt-1">{item.description}</p>
{/if}
@ -219,14 +238,19 @@
<p class="mb-6">
well, it's simple: you just ship any projects that are extra silly, nonsensical, or fun<Superscript number={8} tooltip="or literally any project" />, and
you will get a chance to spin a wheel<Superscript number={9} tooltip="totally not gambling" /> for every 30 minutes of work you do!
you will earn scraps for the time you put in! track your time with <a
href="https://hackatime.hackclub.com"
target="_blank"
rel="noopener noreferrer"
class="underline hover:no-underline cursor-pointer"
>hackatime</a> and watch the scraps roll in.
</p>
<p class="text-xl font-bold mb-4">what's on the wheel?</p>
<p class="text-xl font-bold mb-4">what can you win?</p>
<p class="mb-6">
currently, there is a random assortment of hardware left over from prototype, postcards, the
famous vermont fudge<Superscript number={10} tooltip="fudge fudge fudge" />, and more items planned as events wrap up. oh, and the best part,
famous vermont fudge<Superscript number={9} tooltip="fudge fudge fudge" />, and more items planned as events wrap up. oh, and the best part,
<strong>stickers!</strong>
</p>
@ -240,7 +264,25 @@
>
stickers.hackclub.com</a
>? well, here is your chance to get any sticker (that we have in stock) to complete your
collection<Superscript number={11} tooltip="soon to be made collection.hackclub.com to keep track of your sticker collection" />! this includes some of the rarest and most sought-after stickers from hack club.
collection<Superscript number={10} tooltip="soon to be made collection.hackclub.com to keep track of your sticker collection" />! this includes some of the rarest and most sought-after stickers from hack club.
</p>
<p class="text-xl font-bold mb-4">how does the shop work?</p>
<p class="mb-6">
here's where it gets interesting. each item in the shop has a <strong>base probability</strong> (like 50%). when you "try your luck," you spend scraps and roll the dice<Superscript number={11} tooltip="totally not gambling" />. if you roll under your probability, you win the item!
</p>
<p class="mb-6">
but wait, there's more! you can visit <strong>the refinery</strong> to boost your odds. spend scraps to increase your probability for any item. the catch? each upgrade costs more than the last.
</p>
<p class="mb-6">
so you have a choice: <strong>gamble at low odds</strong> and maybe get lucky, or <strong>invest in the refinery</strong> until your chances are high enough that winning is almost guaranteed. the strategy is up to you!
</p>
<p class="mb-6">
one catch: <strong>every time you win an item, your base probability for that item gets halved</strong>. so winning becomes harder each time, but the refinery is always there to help you boost your odds back up!
</p>
</div>
</div>

View file

@ -31,6 +31,7 @@
let formContent = $state('')
let formActive = $state(true)
let formError = $state<string | null>(null)
let deleteConfirmId = $state<number | null>(null)
onMount(async () => {
user = await getUser()
@ -136,11 +137,15 @@
}
}
async function deleteItem(id: number) {
if (!confirm('Are you sure you want to delete this news item?')) return
function requestDelete(id: number) {
deleteConfirmId = id
}
async function confirmDelete() {
if (!deleteConfirmId) return
try {
const response = await fetch(`${API_URL}/admin/news/${id}`, {
const response = await fetch(`${API_URL}/admin/news/${deleteConfirmId}`, {
method: 'DELETE',
credentials: 'include'
})
@ -149,6 +154,8 @@
}
} catch (e) {
console.error('Failed to delete:', e)
} finally {
deleteConfirmId = null
}
}
@ -162,7 +169,7 @@
</script>
<svelte:head>
<title>news editor | admin | scraps</title>
<title>news editor - admin - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
@ -218,7 +225,7 @@
<Pencil size={18} />
</button>
<button
onclick={() => deleteItem(item.id)}
onclick={() => requestDelete(item.id)}
class="p-2 border-4 border-red-600 text-red-600 rounded-lg hover:bg-red-50 transition-all duration-200 cursor-pointer"
>
<Trash2 size={18} />
@ -298,3 +305,34 @@
</div>
</div>
{/if}
{#if deleteConfirmId}
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={(e) => e.target === e.currentTarget && (deleteConfirmId = null)}
onkeydown={(e) => e.key === 'Escape' && (deleteConfirmId = null)}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black">
<h2 class="text-2xl font-bold mb-4">confirm delete</h2>
<p class="text-gray-600 mb-6">
are you sure you want to delete this news item? <span class="text-red-600 block mt-2">this action cannot be undone.</span>
</p>
<div class="flex gap-3">
<button
onclick={() => (deleteConfirmId = null)}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 cursor-pointer"
>
cancel
</button>
<button
onclick={confirmDelete}
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-full font-bold border-4 border-black hover:border-dashed transition-all duration-200 cursor-pointer"
>
delete
</button>
</div>
</div>
</div>
{/if}

View file

@ -73,7 +73,7 @@
</script>
<svelte:head>
<title>reviews | admin | scraps</title>
<title>reviews - admin - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">

View file

@ -230,7 +230,7 @@
</script>
<svelte:head>
<title>review {project?.name || 'project'} | admin | scraps</title>
<title>review {project?.name || 'project'} - admin - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">

View file

@ -13,6 +13,10 @@
price: number
category: string
count: number
baseProbability: number
baseUpgradeCost: number
costMultiplier: number
boostAmount: number
createdAt: string
updatedAt: string
}
@ -36,7 +40,12 @@
let formPrice = $state(0)
let formCategory = $state('')
let formCount = $state(0)
let formBaseProbability = $state(50)
let formBaseUpgradeCost = $state(10)
let formCostMultiplier = $state(115)
let formBoostAmount = $state(1)
let formError = $state<string | null>(null)
let deleteConfirmId = $state<number | null>(null)
onMount(async () => {
user = await getUser()
@ -72,6 +81,10 @@
formPrice = 0
formCategory = ''
formCount = 0
formBaseProbability = 50
formBaseUpgradeCost = 10
formCostMultiplier = 115
formBoostAmount = 1
formError = null
showModal = true
}
@ -84,6 +97,10 @@
formPrice = item.price
formCategory = item.category
formCount = item.count
formBaseProbability = item.baseProbability
formBaseUpgradeCost = item.baseUpgradeCost
formCostMultiplier = item.costMultiplier
formBoostAmount = item.boostAmount ?? 1
formError = null
showModal = true
}
@ -117,7 +134,11 @@
description: formDescription,
price: formPrice,
category: formCategory,
count: formCount
count: formCount,
baseProbability: formBaseProbability,
baseUpgradeCost: formBaseUpgradeCost,
costMultiplier: formCostMultiplier,
boostAmount: formBoostAmount
})
})
@ -135,11 +156,15 @@
}
}
async function deleteItem(id: number) {
if (!confirm('Are you sure you want to delete this item?')) return
function requestDelete(id: number) {
deleteConfirmId = id
}
async function confirmDelete() {
if (!deleteConfirmId) return
try {
const response = await fetch(`${API_URL}/admin/shop/items/${id}`, {
const response = await fetch(`${API_URL}/admin/shop/items/${deleteConfirmId}`, {
method: 'DELETE',
credentials: 'include'
})
@ -148,12 +173,14 @@
}
} catch (e) {
console.error('Failed to delete:', e)
} finally {
deleteConfirmId = null
}
}
</script>
<svelte:head>
<title>shop editor | admin | scraps</title>
<title>shop editor - admin - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
@ -193,6 +220,14 @@
<span class="px-2 py-0.5 bg-gray-100 rounded-full">{cat}</span>
{/each}
<span class="text-gray-500">{item.count} in stock</span>
<span class="text-gray-500"></span>
<span class="text-gray-500">{item.baseProbability}% base chance</span>
<span class="text-gray-500"></span>
<span class="text-gray-500">{item.baseUpgradeCost} upgrade cost</span>
<span class="text-gray-500"></span>
<span class="text-gray-500">{item.costMultiplier / 100}x multiplier</span>
<span class="text-gray-500"></span>
<span class="text-gray-500">+{item.boostAmount ?? 1}% per upgrade</span>
</div>
</div>
<div class="flex gap-2 shrink-0">
@ -203,7 +238,7 @@
<Pencil size={18} />
</button>
<button
onclick={() => deleteItem(item.id)}
onclick={() => requestDelete(item.id)}
class="p-2 border-4 border-red-600 text-red-600 rounded-lg hover:bg-red-50 transition-all duration-200 cursor-pointer"
>
<Trash2 size={18} />
@ -295,6 +330,55 @@
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="baseProbability" class="block text-sm font-bold mb-1">base probability (%)</label>
<input
id="baseProbability"
type="number"
bind:value={formBaseProbability}
min="0"
max="100"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="boostAmount" class="block text-sm font-bold mb-1">boost per upgrade (%)</label>
<input
id="boostAmount"
type="number"
bind:value={formBoostAmount}
min="1"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<p class="text-xs text-gray-500 mt-1">probability increase per upgrade</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="baseUpgradeCost" class="block text-sm font-bold mb-1">base upgrade cost (scraps)</label>
<input
id="baseUpgradeCost"
type="number"
bind:value={formBaseUpgradeCost}
min="0"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="costMultiplier" class="block text-sm font-bold mb-1">cost multiplier (%)</label>
<input
id="costMultiplier"
type="number"
bind:value={formCostMultiplier}
min="100"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
<p class="text-xs text-gray-500 mt-1">115 = 1.15x cost increase per upgrade</p>
</div>
</div>
</div>
<div class="flex gap-3 mt-6">
@ -316,3 +400,34 @@
</div>
</div>
{/if}
{#if deleteConfirmId}
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={(e) => e.target === e.currentTarget && (deleteConfirmId = null)}
onkeydown={(e) => e.key === 'Escape' && (deleteConfirmId = null)}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black">
<h2 class="text-2xl font-bold mb-4">confirm delete</h2>
<p class="text-gray-600 mb-6">
are you sure you want to delete this item? <span class="text-red-600 block mt-2">this action cannot be undone.</span>
</p>
<div class="flex gap-3">
<button
onclick={() => (deleteConfirmId = null)}
class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 cursor-pointer"
>
cancel
</button>
<button
onclick={confirmDelete}
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-full font-bold border-4 border-black hover:border-dashed transition-all duration-200 cursor-pointer"
>
delete
</button>
</div>
</div>
</div>
{/if}

View file

@ -148,7 +148,7 @@
</script>
<svelte:head>
<title>users | admin | scraps</title>
<title>users - admin - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">

View file

@ -168,7 +168,7 @@
</script>
<svelte:head>
<title>{targetUser?.username || 'user'} | admin | scraps</title>
<title>{targetUser?.username || 'user'} - admin - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">

View file

@ -34,7 +34,7 @@
</script>
<svelte:head>
<title>authenticating... | scraps</title>
<title>authenticating... - scraps</title>
</svelte:head>
<div class="min-h-dvh flex items-center justify-center">

View file

@ -23,7 +23,7 @@
</script>
<svelte:head>
<title>error | scraps</title>
<title>error - scraps</title>
</svelte:head>
<div class="min-h-dvh flex items-center justify-center px-6">

View file

@ -11,6 +11,7 @@
projectsLoading,
fetchProjects,
addProject,
tutorialActiveStore,
type Project
} from '$lib/stores'
import { formatHours } from '$lib/utils'
@ -52,7 +53,7 @@
</script>
<svelte:head>
<title>dashboard | scraps</title>
<title>dashboard - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
@ -96,6 +97,7 @@
<!-- New Draft Card -->
<button
onclick={createNewProject}
data-tutorial="new-project"
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} />
@ -108,7 +110,7 @@
<NewsCarousel />
</div>
<CreateProjectModal open={showCreateModal} onClose={() => showCreateModal = false} onCreated={handleProjectCreated} />
<CreateProjectModal open={showCreateModal} onClose={() => showCreateModal = false} onCreated={handleProjectCreated} tutorialMode={$tutorialActiveStore} />
<style>
.scrollbar-black {

View file

@ -4,51 +4,108 @@
import {
leaderboardStore,
leaderboardLoading,
fetchLeaderboard as fetchLeaderboardData
fetchLeaderboard as fetchLeaderboardData,
probabilityLeadersStore,
probabilityLeadersLoading,
fetchProbabilityLeaders
} from '$lib/stores'
import { formatHours } from '$lib/utils'
let sortBy = $state<'hours' | 'scraps'>('scraps')
let leaderboard = $derived($leaderboardStore[sortBy])
let activeTab = $state<'scraps' | 'hours' | 'probability'>('scraps')
let sortBy = $derived(activeTab === 'probability' ? 'scraps' : activeTab)
let leaderboard = $derived($leaderboardStore[sortBy as 'scraps' | 'hours'])
let probabilityLeaders = $derived($probabilityLeadersStore)
function setSortBy(value: 'hours' | 'scraps') {
sortBy = value
function setActiveTab(value: 'scraps' | 'hours' | 'probability') {
activeTab = value
if (value === 'probability') {
fetchProbabilityLeaders()
} else {
fetchLeaderboardData(value)
}
}
onMount(async () => {
await getUser()
fetchLeaderboardData(sortBy)
fetchLeaderboardData('scraps')
})
</script>
<svelte:head>
<title>leaderboard | scraps</title>
<title>leaderboard - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
<h1 class="text-4xl md:text-5xl font-bold mb-2">leaderboard</h1>
<p class="text-lg text-gray-600 mb-8">top scrappers</p>
<div class="flex gap-2 mb-6">
<div class="flex gap-2 mb-6 flex-wrap">
<button
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy === 'scraps'
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {activeTab === 'scraps'
? 'bg-black text-white'
: 'hover:border-dashed'}"
onclick={() => setSortBy('scraps')}
onclick={() => setActiveTab('scraps')}
>
scraps
</button>
<button
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy === 'hours'
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {activeTab === 'hours'
? 'bg-black text-white'
: 'hover:border-dashed'}"
onclick={() => setSortBy('hours')}
onclick={() => setActiveTab('hours')}
>
hours
</button>
<button
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {activeTab === 'probability'
? 'bg-black text-white'
: 'hover:border-dashed'}"
onclick={() => setActiveTab('probability')}
>
probability leaders
</button>
</div>
{#if activeTab === 'probability'}
<div class="border-4 border-black rounded-2xl p-6">
{#if $probabilityLeadersLoading && probabilityLeaders.length === 0}
<div class="text-center text-gray-500">loading...</div>
{:else if probabilityLeaders.length === 0}
<div class="text-center text-gray-500">no probability leaders yet</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each probabilityLeaders as leader (leader.itemId)}
<div class="border-4 border-black rounded-2xl p-4 hover:border-dashed transition-all">
<div class="flex items-center gap-3 mb-3">
<img
src={leader.itemImage}
alt={leader.itemName}
class="w-12 h-12 rounded-lg object-cover border-2 border-black"
/>
<div>
<div class="font-bold">{leader.itemName}</div>
<div class="text-sm text-gray-500">base: {leader.baseProbability}%</div>
</div>
</div>
{#if leader.topUser}
<a href="/users/{leader.topUser.id}" class="flex items-center gap-2 group">
<img
src={leader.topUser.avatar}
alt={leader.topUser.username}
class="w-8 h-8 rounded-full object-cover border-2 border-black"
/>
<span class="font-bold group-hover:underline">{leader.topUser.username}</span>
<span class="ml-auto font-bold text-green-600">{leader.effectiveProbability}%</span>
</a>
{:else}
<div class="text-gray-500 text-sm">no boosts yet</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{:else}
<div class="border-4 border-black rounded-2xl overflow-hidden">
{#if $leaderboardLoading && leaderboard.length === 0}
<div class="p-8 text-center text-gray-500">loading...</div>
@ -92,11 +149,15 @@
</td>
<td class="px-4 py-4 text-right text-lg">{formatHours(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>
<td class="px-4 py-4 text-right text-lg">
<span class="font-bold">{entry.scraps}</span>
<span class="text-gray-500 text-sm ml-1">(earned: {entry.scrapsEarned})</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</div>

View file

@ -121,7 +121,7 @@
</script>
<svelte:head>
<title>{project?.name || 'project'} | scraps</title>
<title>{project?.name || 'project'} - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">

View file

@ -6,6 +6,7 @@
import { API_URL } from '$lib/config'
import { formatHours } from '$lib/utils'
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte'
import { tutorialActiveStore } from '$lib/stores'
let { data } = $props()
@ -130,7 +131,7 @@
</script>
<svelte:head>
<title>{project?.name || 'project'} | scraps</title>
<title>{project?.name || 'project'} - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">
@ -295,9 +296,18 @@
<Send size={18} />
shipped
</span>
{:else if $tutorialActiveStore}
<span
data-tutorial="submit-button"
class="flex-1 px-6 py-3 bg-black text-white border-4 border-black rounded-full font-bold flex items-center justify-center gap-2 cursor-not-allowed"
>
<Send size={18} />
review & submit
</span>
{:else}
<a
href="/projects/{project.id}/submit"
data-tutorial="submit-button"
class="flex-1 px-6 py-3 bg-black text-white border-4 border-black rounded-full font-bold hover:bg-gray-800 transition-all duration-200 flex items-center justify-center gap-2 cursor-pointer"
>
<Send size={18} />

View file

@ -227,7 +227,7 @@
</script>
<svelte:head>
<title>edit {project?.name || 'project'} | scraps</title>
<title>edit {project?.name || 'project'} - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">

View file

@ -167,7 +167,7 @@
</script>
<svelte:head>
<title>submit {project?.name || 'project'} | scraps</title>
<title>submit {project?.name || 'project'} - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">

View file

@ -64,7 +64,7 @@
</script>
<svelte:head>
<title>{project?.name || 'project'} | scraps</title>
<title>{project?.name || 'project'} - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">

View file

@ -1,119 +1,143 @@
<script lang="ts">
import { onMount } from 'svelte'
import { Flame, ArrowRight } from '@lucide/svelte'
import { getUser } from '$lib/auth-client'
import { getUser, refreshUserScraps, userScrapsStore } from '$lib/auth-client'
import { shopItemsStore, shopLoading, fetchShopItems, type ShopItem } from '$lib/stores'
interface User {
id: number
username: string
email: string
avatar: string | null
slackId: string | null
scraps: number
role: string
let probabilityItems = $derived(
$shopItemsStore.filter(item => item.baseProbability > 0)
)
let upgrading = $state<number | null>(null)
let alertMessage = $state<string | null>(null)
function calculateNextCost(item: ShopItem): number {
return Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, item.userBoostPercent))
}
let user = $state<User | null>(null)
let scraps = $derived(user?.scraps ?? 0)
function getProbabilityColor(probability: number): string {
if (probability >= 70) return 'text-green-600'
if (probability >= 40) return 'text-yellow-600'
return 'text-red-600'
}
async function upgradeProbability(item: ShopItem) {
upgrading = item.id
try {
const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/shop/items/${item.id}/upgrade-probability`, {
method: 'POST',
credentials: 'include'
})
const data = await res.json()
if (data.error) {
alertMessage = data.error
return
}
shopItemsStore.update(items =>
items.map(i =>
i.id === item.id
? { ...i, userBoostPercent: data.boostPercent, effectiveProbability: data.effectiveProbability }
: i
)
)
await refreshUserScraps()
} catch (e) {
alertMessage = 'Failed to upgrade probability'
} finally {
upgrading = null
}
}
onMount(async () => {
user = await getUser()
await getUser()
fetchShopItems()
})
interface RefineRecipe {
id: number
name: string
description: string
inputItems: { name: string; count: number }[]
outputItem: { name: string; chance: number }
cost: number
}
const recipes: RefineRecipe[] = [
{
id: 1,
name: 'sticker fusion',
description: 'combine 3 common stickers for a chance at a rare one',
inputItems: [{ name: 'common sticker', count: 3 }],
outputItem: { name: 'rare sticker', chance: 25 },
cost: 5
},
{
id: 2,
name: 'hardware upgrade',
description: 'upgrade your components',
inputItems: [
{ name: 'resistor pack', count: 2 },
{ name: 'breadboard', count: 1 }
],
outputItem: { name: 'sensor kit', chance: 40 },
cost: 10
},
{
id: 3,
name: 'mystery box',
description: 'trade scraps for a mystery item',
inputItems: [],
outputItem: { name: '???', chance: 100 },
cost: 20
}
]
function refine(recipe: RefineRecipe) {
// TODO: Call API to perform refine action
console.log('Refining:', recipe.name)
}
</script>
<svelte:head>
<title>refinery | scraps</title>
<title>refinery - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
<div class="mb-8">
<h1 class="text-4xl md:text-5xl font-bold mb-2">refinery</h1>
<p class="text-lg text-gray-600 mb-8">upgrade and combine your scraps</p>
<p class="text-lg text-gray-600">upgrade your luck</p>
</div>
<!-- Recipes -->
{#if $shopLoading}
<div class="text-center py-12 text-gray-500">loading...</div>
{:else if probabilityItems.length > 0}
<div class="space-y-6">
{#each recipes as recipe (recipe.id)}
{#each probabilityItems as item (item.id)}
{@const nextCost = calculateNextCost(item)}
{@const maxed = item.effectiveProbability >= 100}
<div class="border-4 border-black rounded-2xl p-6 hover:border-dashed transition-all">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="font-bold text-2xl mb-1">{recipe.name}</h3>
<p class="text-gray-600">{recipe.description}</p>
</div>
<button
onclick={() => refine(recipe)}
class="px-6 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-colors flex items-center gap-2 cursor-pointer"
<div class="flex items-start gap-4">
{#if item.image}
<img
src={item.image}
alt={item.name}
class="w-16 h-16 object-cover rounded-lg border-2 border-black"
/>
{:else}
<div
class="w-16 h-16 bg-gray-100 rounded-lg border-2 border-black flex items-center justify-center"
>
<span>{recipe.cost} scraps</span>
<Flame size={16} />
</button>
<span class="text-2xl">?</span>
</div>
<div class="flex items-center gap-4 flex-wrap">
{#if recipe.inputItems.length > 0}
<div class="flex items-center gap-2">
{#each recipe.inputItems as input, i}
<span class="px-3 py-1 bg-gray-100 rounded-full text-sm font-bold">
{input.count}x {input.name}
</span>
{#if i < recipe.inputItems.length - 1}
<span class="text-gray-400">+</span>
{/if}
<div class="flex-1">
<h3 class="font-bold text-xl">{item.name}</h3>
<div class="flex items-center gap-2 mt-1">
<span class="font-bold text-2xl {getProbabilityColor(item.effectiveProbability)}">
{item.effectiveProbability}%
</span>
<span class="text-gray-600 text-sm">
({item.baseProbability}% base + {item.userBoostPercent}% boost)
</span>
</div>
</div>
<div class="text-right">
{#if maxed}
<span class="px-4 py-2 bg-gray-200 rounded-full font-bold text-gray-600">maxed</span>
{:else}
<button
onclick={() => upgradeProbability(item)}
disabled={upgrading === item.id || $userScrapsStore < nextCost}
class="px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 cursor-pointer"
>
{#if upgrading === item.id}
upgrading...
{:else}
upgrade +1% ({nextCost} scraps)
{/if}
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{:else}
<span class="px-3 py-1 bg-gray-100 rounded-full text-sm font-bold">scraps only</span>
<div class="text-center py-12 text-gray-500">no items available for upgrades</div>
{/if}
<ArrowRight size={20} class="text-gray-400" />
<span class="px-3 py-1 bg-black text-white rounded-full text-sm font-bold">
{recipe.outputItem.name} ({recipe.outputItem.chance}%)
</span>
</div>
</div>
{/each}
</div>
</div>
{#if alertMessage}
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={(e) => e.target === e.currentTarget && (alertMessage = null)}
onkeydown={(e) => e.key === 'Escape' && (alertMessage = null)}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black">
<h2 class="text-2xl font-bold mb-4">error</h2>
<p class="text-gray-600 mb-6">{alertMessage}</p>
<button
onclick={() => (alertMessage = null)}
class="w-full px-4 py-2 bg-black text-white rounded-full font-bold border-4 border-black hover:border-dashed transition-all duration-200 cursor-pointer"
>
ok
</button>
</div>
</div>
{/if}

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte'
import HeartButton from '$lib/components/HeartButton.svelte'
import ShopItemModal from '$lib/components/ShopItemModal.svelte'
import AddressSelectModal from '$lib/components/AddressSelectModal.svelte'
import { API_URL } from '$lib/config'
import { getUser } from '$lib/auth-client'
import { X, Spool } from '@lucide/svelte'
@ -8,10 +10,17 @@
shopItemsStore,
shopLoading,
fetchShopItems,
updateShopItemHeart
updateShopItemHeart,
type ShopItem
} from '$lib/stores'
let selectedCategories = $state<Set<string>>(new Set())
let sortBy = $state<'default' | 'favorites' | 'probability'>('default')
let selectedItem = $state<ShopItem | null>(null)
let winningOrderId = $state<number | null>(null)
let winningItemName = $state<string | null>(null)
let pendingOrders = $state<{ orderId: number; itemName: string }[]>([])
let categories = $derived.by(() => {
const allCategories = new Set<string>()
@ -35,6 +44,16 @@
)
)
let sortedItems = $derived.by(() => {
let items = [...filteredItems]
if (sortBy === 'favorites') {
return items.sort((a, b) => b.heartCount - a.heartCount)
} else if (sortBy === 'probability') {
return items.sort((a, b) => b.effectiveProbability - a.effectiveProbability)
}
return items
})
function toggleCategory(category: string) {
const newSet = new Set(selectedCategories)
if (newSet.has(category)) {
@ -49,9 +68,64 @@
selectedCategories = new Set()
}
function getProbabilityColor(prob: number): string {
if (prob >= 70) return 'text-green-600'
if (prob >= 40) return 'text-yellow-600'
return 'text-red-600'
}
function getProbabilityBgColor(prob: number): string {
if (prob >= 70) return 'bg-green-100'
if (prob >= 40) return 'bg-yellow-100'
return 'bg-red-100'
}
async function checkPendingOrders() {
try {
const response = await fetch(`${API_URL}/shop/orders/pending-address`, {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
if (Array.isArray(data) && data.length > 0) {
pendingOrders = data.map((o: { id: number; itemName: string }) => ({
orderId: o.id,
itemName: o.itemName
}))
const first = pendingOrders[0]
winningOrderId = first.orderId
winningItemName = first.itemName
}
}
} catch (e) {
console.error('Failed to check pending orders:', e)
}
}
function handleTryLuck(orderId: number) {
if (selectedItem) {
winningItemName = selectedItem.name
}
winningOrderId = orderId
selectedItem = null
}
function handleAddressComplete() {
fetchShopItems(true)
winningOrderId = null
winningItemName = null
pendingOrders = pendingOrders.slice(1)
if (pendingOrders.length > 0) {
const next = pendingOrders[0]
winningOrderId = next.orderId
winningItemName = next.itemName
}
}
onMount(async () => {
await getUser()
fetchShopItems()
checkPendingOrders()
})
async function toggleHeart(itemId: number) {
@ -73,15 +147,17 @@
</script>
<svelte:head>
<title>shop | scraps</title>
<title>shop - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
<h1 class="text-4xl md:text-5xl font-bold mb-2">shop</h1>
<p class="text-lg text-gray-600 mb-8">items up for grabs</p>
<!-- Filters & Sort -->
<div class="flex flex-col md:flex-row gap-4 mb-8 md:items-start justify-between">
<!-- Category Filter -->
<div class="flex gap-2 mb-8 flex-wrap items-center">
<div class="flex gap-2 flex-wrap items-center flex-1">
{#each categories as category}
<button
onclick={() => toggleCategory(category)}
@ -103,6 +179,39 @@
{/if}
</div>
<!-- Sort Options -->
<div class="flex gap-2 items-center shrink-0">
<span class="font-bold">sort:</span>
<button
onclick={() => (sortBy = 'default')}
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
'default'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
default
</button>
<button
onclick={() => (sortBy = 'favorites')}
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
'favorites'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
favorites
</button>
<button
onclick={() => (sortBy = 'probability')}
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
'probability'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
probability
</button>
</div>
</div>
<!-- Loading State -->
{#if $shopLoading}
<div class="text-center py-12">
@ -115,15 +224,25 @@
{: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">
{#each sortedItems as item (item.id)}
<button
onclick={() => (selectedItem = item)}
class="border-4 border-black rounded-2xl p-4 hover:border-dashed transition-all cursor-pointer text-left"
>
<div class="relative">
<img src={item.image} alt={item.name} class="w-full h-32 object-contain mb-4" />
<span
class="absolute top-0 right-0 text-xs font-bold px-2 py-1 rounded-full {getProbabilityBgColor(item.effectiveProbability)} {getProbabilityColor(item.effectiveProbability)}"
>
{item.effectiveProbability.toFixed(0)}% chance
</span>
</div>
<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="mb-3">
<span class="text-lg font-bold flex items-center gap-1"><Spool size={18} />{item.price}</span>
<div class="flex gap-1 flex-wrap mt-2">
{#each item.category.split(',').map(c => c.trim()).filter(Boolean) as cat}
{#each item.category.split(',').map((c) => c.trim()).filter(Boolean) as cat}
<span class="text-xs px-2 py-1 bg-gray-100 rounded-full">{cat}</span>
{/each}
</div>
@ -133,11 +252,34 @@
<HeartButton
count={item.heartCount}
hearted={item.userHearted}
onclick={() => toggleHeart(item.id)}
onclick={(e) => {
e.stopPropagation()
toggleHeart(item.id)
}}
/>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
{#if selectedItem}
<ShopItemModal
item={selectedItem}
onClose={() => (selectedItem = null)}
onTryLuck={handleTryLuck}
/>
{/if}
{#if winningOrderId && winningItemName}
<AddressSelectModal
orderId={winningOrderId}
itemName={winningItemName}
onClose={() => {
winningOrderId = null
winningItemName = null
}}
onComplete={handleAddressComplete}
/>
{/if}

View file

@ -87,7 +87,7 @@
</script>
<svelte:head>
<title>submit project | scraps</title>
<title>submit project - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-2xl mx-auto pb-24">

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import { ArrowLeft, Github, Clock, Package, CheckCircle, AlertTriangle, Heart } from '@lucide/svelte'
import { ArrowLeft, Github, Clock, Package, CheckCircle, AlertTriangle, Heart, Flame, Origami } from '@lucide/svelte'
import { API_URL } from '$lib/config'
import { formatHours } from '$lib/utils'
@ -24,6 +24,15 @@
price: number
}
interface Refinement {
shopItemId: number
itemName: string
itemImage: string
baseProbability: number
totalBoost: number
effectiveProbability: number
}
interface ProfileUser {
id: number
username: string
@ -43,6 +52,7 @@
let profileUser = $state<ProfileUser | null>(null)
let projects = $state<Project[]>([])
let heartedItems = $state<HeartedItem[]>([])
let refinements = $state<Refinement[]>([])
let stats = $state<Stats | null>(null)
let loading = $state(true)
let error = $state<string | null>(null)
@ -66,6 +76,7 @@
profileUser = result.user
projects = result.projects || []
heartedItems = result.heartedItems || []
refinements = result.refinements || []
stats = result.stats
} else {
error = 'User not found'
@ -80,7 +91,7 @@
</script>
<svelte:head>
<title>{profileUser?.username || 'profile'} | scraps</title>
<title>{profileUser?.username || 'profile'} - scraps</title>
</svelte:head>
<div class="pt-24 px-6 md:px-12 max-w-4xl mx-auto pb-24">
@ -143,7 +154,10 @@
<!-- Projects -->
<div class="border-4 border-black rounded-2xl p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<Origami size={20} />
<h2 class="text-xl font-bold">projects ({filteredProjects.length})</h2>
</div>
<div class="flex gap-2">
<button
onclick={() => (filter = 'all')}
@ -254,5 +268,32 @@
</div>
</div>
{/if}
<!-- Refinements -->
<div class="border-4 border-black rounded-2xl p-6 mt-6">
<div class="flex items-center gap-2 mb-4">
<Flame size={20} class="text-orange-500" />
<h2 class="text-xl font-bold">refinements</h2>
</div>
{#if refinements.length === 0}
<p class="text-gray-500 text-center py-4">no refinements to show</p>
{:else}
<div class="space-y-3">
{#each refinements.sort((a, b) => b.totalBoost - a.totalBoost) as refinement}
{@const maxBoost = Math.max(...refinements.map(r => r.totalBoost))}
{@const barWidth = maxBoost > 0 ? (refinement.totalBoost / maxBoost) * 100 : 0}
<div class="relative">
<div
class="h-10 rounded-lg flex items-center justify-between px-3 text-white font-bold text-sm bg-black border-2 border-black"
style="width: {Math.max(barWidth, 20)}%;"
>
<span class="truncate">{refinement.itemName}</span>
<span class="shrink-0 ml-2">+{refinement.totalBoost}%</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB