mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 19:45:14 +00:00
before mobile optimize
This commit is contained in:
parent
f5f688e4fa
commit
fc66096949
60 changed files with 5112 additions and 842 deletions
116
.VSCodeCounter/2026-02-03_15-48-00/details.md
Normal file
116
.VSCodeCounter/2026-02-03_15-48-00/details.md
Normal 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)
|
||||
15
.VSCodeCounter/2026-02-03_15-48-00/diff-details.md
Normal file
15
.VSCodeCounter/2026-02-03_15-48-00/diff-details.md
Normal 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
|
||||
2
.VSCodeCounter/2026-02-03_15-48-00/diff.csv
Normal file
2
.VSCodeCounter/2026-02-03_15-48-00/diff.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
"filename", "language", "", "comment", "blank", "total"
|
||||
"Total", "-", , 0, 0, 0
|
||||
|
19
.VSCodeCounter/2026-02-03_15-48-00/diff.md
Normal file
19
.VSCodeCounter/2026-02-03_15-48-00/diff.md
Normal 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)
|
||||
22
.VSCodeCounter/2026-02-03_15-48-00/diff.txt
Normal file
22
.VSCodeCounter/2026-02-03_15-48-00/diff.txt
Normal 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 |
|
||||
+----------+----------+------------+------------+------------+------------+
|
||||
103
.VSCodeCounter/2026-02-03_15-48-00/results.csv
Normal file
103
.VSCodeCounter/2026-02-03_15-48-00/results.csv
Normal 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
.VSCodeCounter/2026-02-03_15-48-00/results.json
Normal file
1
.VSCodeCounter/2026-02-03_15-48-00/results.json
Normal file
File diff suppressed because one or more lines are too long
79
.VSCodeCounter/2026-02-03_15-48-00/results.md
Normal file
79
.VSCodeCounter/2026-02-03_15-48-00/results.md
Normal 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)
|
||||
183
.VSCodeCounter/2026-02-03_15-48-00/results.txt
Normal file
183
.VSCodeCounter/2026-02-03_15-48-00/results.txt
Normal 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 |
|
||||
+-----------------------------------------------------------------------------------------------+--------------------+------------+------------+------------+------------+
|
||||
52
AGENTS.md
52
AGENTS.md
|
|
@ -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
25
backend/Dockerfile
Normal 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
1651
backend/dist/index.js
vendored
File diff suppressed because it is too large
Load diff
31
backend/src/config.ts
Normal file
31
backend/src/config.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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
62
backend/src/lib/scraps.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
342
frontend/src/lib/components/AddressSelectModal.svelte
Normal file
342
frontend/src/lib/components/AddressSelectModal.svelte
Normal 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>
|
||||
72
frontend/src/lib/components/ConfirmModal.svelte
Normal file
72
frontend/src/lib/components/ConfirmModal.svelte
Normal 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
}: {
|
||||
count: number
|
||||
hearted: boolean
|
||||
onclick: () => void
|
||||
onclick: (e: MouseEvent) => void
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
418
frontend/src/lib/components/ShopItemModal.svelte
Normal file
418
frontend/src/lib/components/ShopItemModal.svelte
Normal 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>
|
||||
464
frontend/src/lib/components/Tutorial.svelte
Normal file
464
frontend/src/lib/components/Tutorial.svelte
Normal 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>
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
BIN
frontend/static/images/raspberrypi.png
Normal file
BIN
frontend/static/images/raspberrypi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 426 KiB |
Loading…
Add table
Reference in a new issue