277 Commits

Author SHA1 Message Date
4fb0a185f6 feat: preliminary peer api 2025-05-13 12:28:18 +10:00
70db79b50f feat: set data dir for headscale 2025-05-12 09:44:08 +10:00
ac7ef6303b feat: new unified data folder 2025-05-12 09:18:42 +10:00
efbc86e73e feat: add new HeadscaleManager 2025-05-12 09:04:28 +10:00
a0bc4bbc4c feat: refactored into separate metadata and library pages 2025-05-10 15:22:57 +10:00
90277653cb feat: rework developer/publisher system 2025-05-10 11:59:56 +10:00
ac355918ed Merge branch 'Huskydog9988-further-improvements' into develop 2025-05-10 11:26:16 +10:00
d6830c3428 feat: update search style 2025-05-10 11:25:45 +10:00
cbf480bef9 fix: pcgamingwiki not parsing company websites 2025-05-10 11:25:45 +10:00
afaaaf2eb5 feat: unified company metadata store
still need to migrate users from old developer and publisher tables
2025-05-10 11:25:45 +10:00
14f0833d17 feat: finish migrating to new objectid naming 2025-05-10 11:25:45 +10:00
7f7d8c8f45 fix: linting errors 2025-05-10 11:25:45 +10:00
52a7de0a8b feat: minimum support for unrefed object cleanup 2025-05-10 11:25:45 +10:00
dbded55113 feat: identify unused objects 2025-05-10 11:25:44 +10:00
aa3105aecd feat: manually edit search term for game import 2025-05-10 11:25:44 +10:00
0c48d42c2d fix: missing metadata preventing game import
when a metadata provider fails to import a game's developer / publisher, the import is no longer blocked. the imports usally fail because there isn't a page for these compaines
2025-05-10 11:25:44 +10:00
836ba33fe4 feat: basic screenshot manager 2025-05-10 11:25:44 +10:00
1f41e52a86 feat: update search style 2025-05-10 11:24:04 +10:00
17e4734cfb fix: pcgamingwiki not parsing company websites 2025-05-08 21:22:15 -04:00
b681476373 feat: unified company metadata store
still need to migrate users from old developer and publisher tables
2025-05-08 20:44:37 -04:00
3a9eb82fdf fix: fix invitation register url 2025-05-09 10:02:48 +10:00
7e5e7b032b feat: finish migrating to new objectid naming 2025-05-08 19:42:46 -04:00
df291c3e9a fix: linting errors 2025-05-08 19:26:46 -04:00
bf691a7f5c feat: minimum support for unrefed object cleanup 2025-05-08 19:20:34 -04:00
597a2264e8 feat: identify unused objects 2025-05-08 19:19:10 -04:00
1a2d3c8207 feat: manually edit search term for game import 2025-05-08 12:16:12 -04:00
471e85d7c6 fix: missing metadata preventing game import
when a metadata provider fails to import a game's developer / publisher, the import is no longer blocked. the imports usally fail because there isn't a page for these compaines
2025-05-08 11:57:37 -04:00
f9f437dd85 feat: basic screenshot manager 2025-05-08 11:38:09 -04:00
143846c48a fix: revert session store 2025-05-08 21:46:12 +10:00
2fb909f73d Revert "fix: convert socket sessions to cacheHandler"
This reverts commit 733aee3977.
2025-05-08 21:43:54 +10:00
0f773a9779 fix: docker build 2025-05-08 21:30:52 +10:00
92a98a5984 fix: add prisma binary targets 2025-05-08 20:21:50 +10:00
464af37afb chore: warn when metadata fails 2025-05-08 19:49:37 +10:00
f8dc3fef55 fix: swap order of nuxt and prisma generations 2025-05-08 17:35:59 +10:00
6d35e2697d fix: switch prisma schema type 2025-05-08 17:33:07 +10:00
0d02be2392 fix: refactor prisma multifolder into docs example 2025-05-08 16:25:01 +10:00
48f796ae4b fix: update prisma schema and path 2025-05-08 16:20:19 +10:00
125fe9e6e2 fix: remove jank prisma script, and move to generated prisma client 2025-05-08 16:17:23 +10:00
29f3094ad4 Merge branch 'Huskydog9988-more-stuff' into develop 2025-05-08 15:50:47 +10:00
733aee3977 fix: convert socket sessions to cacheHandler 2025-05-08 15:50:29 +10:00
e3ed60feae feat: add oidc to admin panel 2025-05-08 15:48:46 +10:00
bfa2c0a641 feat: add oidc to admin panel 2025-05-08 15:29:50 +10:00
952df560ec chore: remove fsbackend console logs 2025-05-07 22:32:24 -04:00
1db2229ad3 fix: edgecase where object hash isn't read 2025-05-07 22:29:10 -04:00
731499be81 feat: unified cache handler 2025-05-07 22:26:34 -04:00
5aa0899bcf docs: why timeline was disabled 2025-05-07 21:45:13 -04:00
30b065dde3 fix: eslint error and server plugin name 2025-05-07 18:50:57 -04:00
1f510bee57 Merge branch 'develop' of https://github.com/Huskydog9988/drop into more-stuff 2025-05-07 18:45:36 -04:00
07b34c874d refactor: homepage link in header 2025-05-07 18:42:13 -04:00
19ff73cc30 feat: oidc 2025-05-07 22:14:04 +10:00
e8633ceca2 fix: signout, again 2025-05-07 12:26:22 +10:00
770294d559 fix: ignore error if we are unable to blacklist certificate 2025-05-07 12:16:09 +10:00
c0c55d35f4 fix: signout route 2025-05-07 11:33:21 +10:00
a47debda91 fix: notification rendering 2025-05-07 09:54:02 +10:00
c449b45009 chore: remove redundant omitApi prisma option 2025-04-24 18:34:20 -04:00
f1f19c8263 feat: run tasks at startup 2025-04-23 21:14:16 -04:00
31ad8505b7 fix: task api and other small issues 2025-04-20 23:34:22 +10:00
feedcfc5c4 fix: metadata init issues 2025-04-20 23:24:08 +10:00
a5facbd648 fix: misc fixes 2025-04-20 21:33:19 +10:00
3b1d04251c feat: log metadata error if fail 2025-04-20 21:23:09 +10:00
fd4ec3fd1c Update release.yml 2025-04-20 20:43:01 +10:00
0a270b267c fix: prisma migrations in docker
i hate prisma so so so so so much
2025-04-20 18:12:29 +10:00
ec6d38d7af fix: copy package.json to Dockerfile 2025-04-19 21:51:44 +10:00
a9d8ddc0f5 fix: prisma schema folder location 2025-04-19 21:51:22 +10:00
dada379354 Merge pull request #37 from Huskydog9988/eslint
feat: add eslint and prettier
2025-04-19 11:40:21 +10:00
86c7aa33ce fix: don't set own script csp 2025-04-17 20:57:27 -04:00
26abb75e94 ci: add prefix to branch based docker tags 2025-04-17 20:18:21 -04:00
8eec8b19dd fix: userwidget not opening 2025-04-17 20:17:31 -04:00
582acfb385 fix: missing components errors 2025-04-16 12:03:42 -04:00
456902c784 fix: type error in $dropFetch 2025-04-15 22:01:24 -04:00
87215c4a1e chore: prettier pass 2025-04-15 21:46:34 -04:00
d361e01eef fix: last eslint errors 2025-04-15 21:43:27 -04:00
8e109dd562 fix: more eslint stuff 2025-04-15 21:10:45 -04:00
8f429e1e56 fix: eslint errors, switch to using maps 2025-04-15 20:04:45 -04:00
e362f732e7 fix: more eslint issues 2025-04-13 22:10:28 -04:00
86c2d00726 feat: add lint ci job 2025-04-13 21:46:41 -04:00
d4b89b5dc5 fix: inital eslint errors 2025-04-13 21:44:29 -04:00
ff1255f948 feat: add eslint and prettier 2025-04-13 21:43:35 -04:00
96742cc918 Merge branch 'Huskydog9988-more-ui-work' into develop 2025-04-14 10:54:09 +10:00
c2bb835b0f fix: etags and other
remove sanitize-filename because IDs are internally generated
remove pulse animation on NO GAME cards
add migration
refactors to be inline with other stuff
2025-04-14 10:52:12 +10:00
f384492ed2 fix: missing key in loops 2025-04-12 16:03:35 -04:00
22a7cfa544 feat: save fs objectbackend hashes 2025-04-12 15:54:26 -04:00
228d109692 fix: incorrect perms when deleting objects 2025-04-11 19:32:30 -04:00
dc89ff95d8 feat: make internal objectbackend methods private 2025-04-10 19:57:08 -04:00
04c56fd985 chore: update @types/bcryptjs 2025-04-10 19:50:37 -04:00
ca03be7f43 fix: don't add write perms, users can only delete objects 2025-04-10 19:50:04 -04:00
35a2d98790 fix: don't use stream package 2025-04-10 19:29:11 -04:00
c4d8b24295 feat: hash objects for etag value 2025-04-09 14:48:13 -04:00
42349ad4e1 feat: allow client-based web tokens 2025-04-08 16:16:40 +10:00
e7566a6316 feat: upgrade to nuxt 3.16 2025-04-07 21:55:52 -04:00
fdffd9a32a feat: add skeleton for loading game carousel 2025-04-07 21:28:58 -04:00
4c3413ae63 feat: add nonce to scripts 2025-04-07 20:01:57 -04:00
30e3e7289a chore: drop unused dep 2025-04-07 19:57:47 -04:00
12ba416ca5 feat: enable nuxt dev tools 2025-04-07 19:57:00 -04:00
e4aeaee6e7 refactor: move tsconfig to nuxt config 2025-04-07 19:55:33 -04:00
9d943bc5dc feat: add etag to object response 2025-04-07 19:19:45 -04:00
66d1413eb5 fix: gamecarousel layout shifts (mostly) 2025-04-07 17:28:40 -04:00
e572b61af9 fix: gamecarousel not resizing 2025-04-07 16:57:44 -04:00
f9b774ddb5 fix: wordmark causing page refresh 2025-04-07 16:49:56 -04:00
106b3f66a4 feat: sleak transition from store page to item 2025-04-07 16:17:55 -04:00
657fd50702 fix: missing html attributes 2025-04-07 16:13:57 -04:00
7400fae11b fix: use nuxtlink instaed of a tag 2025-04-07 16:11:56 -04:00
043ef6dcd2 fix: remove debug values from game carousel 2025-04-07 17:44:48 +10:00
6ea50bffc8 fix: store page released date 2025-04-07 16:55:01 +10:00
9584d69e97 fix: add tsignore pending upstream fix 2025-04-07 10:52:56 +10:00
5ceff44993 Merge branch 'Huskydog9988-more-fixes' into develop 2025-04-07 10:40:20 +10:00
372a9bdd97 fix: devices page (reactivity and relative timestamps) 2025-04-07 10:37:01 +10:00
fe82c78571 refactor: remove momentjs 2025-04-06 19:44:38 -04:00
fd11d41dc5 chore: updates prisma and bycryptjs 2025-04-06 14:34:25 -04:00
9242a810b0 fix: don't prerender auth routes 2025-04-06 14:28:03 -04:00
0b9d0a4ad9 fix: don't use npm crypto 2025-04-06 14:27:31 -04:00
17d3e0ef54 refactor: use node crypto uuid 2025-04-06 14:08:36 -04:00
4fd2b159a6 fix: type error in devices page 2025-04-06 13:47:55 -04:00
d6d457f999 fix: generate prisma types on install (like nuxt) 2025-04-06 13:46:53 -04:00
54b3bc3a7e fix: devices page for mobile 2025-04-05 23:30:38 +11:00
2cbee3d495 feat: add ability to review and revoke clients 2025-04-05 17:42:32 +11:00
7263ec53ac fix: remove weird import 2025-04-05 15:38:07 +11:00
0edfdbdfce fix: return user library for client 2025-04-05 15:34:24 +11:00
114d235a6a fix: remove legacy metadata client routes 2025-04-05 15:34:12 +11:00
a47615a274 refactor: move game id fetch to index 2025-04-05 15:33:59 +11:00
1987c578bc Merge branch 'Huskydog9988-better-ci' into develop 2025-04-05 14:22:38 +11:00
b2327b21fe Merge branch 'better-ci' of https://github.com/Huskydog9988/drop into Huskydog9988-better-ci 2025-04-05 14:22:21 +11:00
b22681c555 fix: fix types, remove @nuxt/image because of broken types 2025-04-05 14:21:37 +11:00
931913b836 ci: drop PAT_TOKEN 2025-04-04 19:19:33 -04:00
2be0e2f88c ci: add typecheck ci 2025-04-04 19:15:05 -04:00
7df175b747 ci: update actions versions 2025-04-04 19:03:38 -04:00
b6d05a6d09 ci: don't mark nightly container as latest 2025-04-04 18:59:18 -04:00
8d88728c99 feat: typecheck in dev 2025-04-04 18:52:34 -04:00
7141735664 fix: auth prerender 2025-04-04 18:51:01 -04:00
82baeb909a feat: add yarn typecheck and fix all types 2025-04-05 09:40:05 +11:00
2a85322f64 Merge branch 'Huskydog9988-db-store' into develop 2025-04-04 10:37:08 +11:00
088cb68604 chore: apply schema changes to db 2025-04-04 10:34:58 +11:00
81be7ccf58 Merge remote-tracking branch 'origin/develop' into db-store 2025-04-03 19:21:05 -04:00
a9d1a442f6 refactor: session handler 2025-04-03 19:15:33 -04:00
97043d6366 fix: register page validation 2025-04-03 19:11:35 -04:00
756bf8f93f fix: add missing dev deps 2025-04-03 18:15:39 -04:00
9dc35c80c5 Merge branch 'develop' into db-store 2025-04-03 18:12:07 -04:00
0f35d4a445 feat: cache for session store in db 2025-04-01 21:32:13 -04:00
57f50b0306 fix: renable nuxt-security with xss validator off 2025-04-01 21:20:54 +11:00
065951d91f fix: update last accessed client on push 2025-04-01 21:16:54 +11:00
36e6c92938 feat: add cloud save backend 2025-04-01 21:08:57 +11:00
e7109e58bb feat: update readme 2025-04-01 18:36:51 +11:00
17372a9c06 feat: account pages framework & updates to library 2025-04-01 18:28:34 +11:00
d7297707d7 fix: register redirect 2025-04-01 18:18:57 +11:00
c809c8fcbf fix: re-request fix for $dropFetch 2025-04-01 16:58:53 +11:00
68f5f88347 fix: carousel pagination 2025-04-01 16:42:08 +11:00
6f8e28d711 fix: sidebar in library page 2025-04-01 16:19:03 +11:00
47dc364d4e Merge branch 'develop' of https://github.com/AdenMGB/drop into AdenMGB-develop 2025-04-01 12:11:57 +11:00
3b4f940983 feat: slight optimisation with removing from collection 2025-04-01 12:08:39 +11:00
1048653eef fix: $dropFetch SSR and rate limiting 2025-04-01 12:02:34 +11:00
f1c932b7d7 fix: remove previous fix and add longer yarn install timeout 2025-03-31 12:35:34 +11:00
7c420ba7d7 fix: revert previous and add yarn clean/configs 2025-03-31 12:24:50 +11:00
7974361e5b fix: potential fix for timeout: switch to full image from slim 2025-03-31 12:18:14 +11:00
01171d788c fix: pin nuxt to 3.15.4 and recreate lockfile 2025-03-31 11:50:15 +11:00
eec709a6e9 fix: recreate lockfile 2025-03-31 11:37:09 +11:00
5384759261 fix: update workflow to recurse submodules properly 2025-03-31 09:47:54 +11:00
e3022bc52b fix: add submodules 2025-03-31 09:38:58 +11:00
c7af02c15e fix: update dockerfile & use NPM droplet 2025-03-31 09:32:09 +11:00
96a1199fff fix: update dockerfile to debian 12 2025-03-27 11:09:37 +11:00
2cfc2cee7c Merge pull request #29 from Huskydog9988/patch-1
nightly builds and fix drop repo
2025-03-27 09:22:31 +11:00
f5e52321b8 nightly builds and fix drop repo 2025-03-26 18:20:37 -04:00
58d558159d feat: github build 2025-03-27 09:16:22 +11:00
e4e1c66bca fix: public assets 2025-03-24 12:55:07 +11:00
1996b97e99 refactor: use hash directly in authmek and version field on authmek 2025-03-24 12:50:21 +11:00
cb4937b590 Merge pull request #28 from Huskydog9988/auth-overhaul
overhaul auth and futureproof it with argon2
2025-03-24 12:27:14 +11:00
57042892c4 move register to auth path 2025-03-23 21:08:08 -04:00
1f309606c9 address some issues 2025-03-23 21:07:12 -04:00
f9e6702d40 Merge branch 'develop' into auth-overhaul 2025-03-23 20:33:46 -04:00
690aa042df switch back to json 2025-03-23 20:29:50 -04:00
87f01a9984 Merge pull request #26 from Huskydog9988/perf-fixes
massive perf improvements
2025-03-23 10:56:35 +11:00
c1272dc7a7 use arktype for clientside validation 2025-03-22 19:37:28 -04:00
257cdacad4 make signup less error prone in db 2025-03-22 17:26:12 -04:00
2c9fdebf25 new accounts use argon2 2025-03-22 17:09:10 -04:00
2027c69c0e fix signout 2025-03-22 16:55:08 -04:00
e08a13f2c3 add sharp for ipx 2025-03-22 15:58:53 -04:00
6ed7e76b17 move auth pages into auth dir 2025-03-22 15:54:43 -04:00
573bf87cbb Merge pull request #27 from Huskydog9988/cleanup-platform-parse
fix: platformparse case sensitivity
2025-03-20 23:10:28 +00:00
e8afa274a7 fix: platformparse case sensitivity 2025-03-20 17:52:04 -04:00
d4d1eaf2e2 Merge branch 'develop' into perf-fixes 2025-03-16 12:16:47 -04:00
6918e78cf9 fix: macos and rate limiting 2025-03-15 15:04:52 +11:00
cd93ba2197 massive perf improvements
on the store page, brings LCP down to only 5.6s from 11s. total lighthouse score is now 78
2025-03-14 20:18:26 -04:00
c052511ff3 fix(library): Use $dropFetch util 2025-03-15 09:54:20 +10:30
19d1a9dd0e style(library): complete restyling to match client library 2025-03-15 09:46:15 +10:30
66400f4875 fix(webui): Fix forcably redirecting to https 2025-03-15 09:45:09 +10:30
88a5dc2a58 add migrations to store ca and session in db 2025-03-14 11:37:31 -04:00
cf0af15854 store sessions in db 2025-03-14 11:35:12 -04:00
61764e81b8 store certs in db 2025-03-14 10:53:37 -04:00
98c8258127 fix?: https redirection issues 2025-03-14 20:31:00 +11:00
3527f678e5 fix: remove @nuxt/content 2025-03-14 13:23:56 +11:00
fce084f95a Merge branch 'Huskydog9988-perf-improvements/fetch' into develop 2025-03-14 13:16:55 +11:00
1ad1ebb3fd merge: husky updates 2025-03-14 13:16:26 +11:00
1de9ebdfa5 feat: refactor news and migrate rest of useFetch to $dropFetch 2025-03-14 13:12:04 +11:00
bd1cb67cd0 feat: user page & $dropFetch util 2025-03-14 12:22:08 +11:00
3225f536ce feat: lazy init igdb 2025-03-13 16:18:44 +11:00
58f91f4ac4 Merge branch 'Huskydog9988-metadata-improvements' into develop 2025-03-13 15:55:09 +11:00
8fc37936dc Merge branch 'metadata-improvements' of https://github.com/Huskydog9988/drop into Huskydog9988-metadata-improvements 2025-03-13 15:54:51 +11:00
0ca9a3b2f7 feat: database level metadata provider init 2025-03-13 15:20:13 +11:00
f8ae5b70c0 automate twitch credential refresh 2025-03-12 19:06:52 -04:00
7a3b30b012 accidently commited secret from testing lol (revoked) 2025-03-12 19:06:52 -04:00
4e8cffd778 make pcgamig wiki types match api return 2025-03-12 19:06:52 -04:00
bf7eb5b986 fix issue in igdb where company isn't found 2025-03-12 19:06:34 -04:00
77d06df7d3 fix manual metadata fetching publishers +types 2025-03-12 19:06:34 -04:00
2755aa472b in progress igdb 2025-03-12 19:06:34 -04:00
2b7bc6965d add content security policy to allow showing remote images for game importing 2025-03-12 19:05:11 -04:00
08164cae68 add pcgamingwiki as metadata source 2025-03-12 19:05:11 -04:00
2ca96c34f7 note that you need to init submodules 2025-03-12 19:04:32 -04:00
4b4e067fac cleanup giantbomb provider 2025-03-12 19:04:32 -04:00
d2b485456a gracefully disable metadata provider when api key is missing 2025-03-12 19:04:32 -04:00
793b57a163 chore: Update changelog.md 2025-03-12 18:59:59 -04:00
d9218dea59 fix: Update README.md with discord link 2025-03-12 18:59:59 -04:00
789361ea73 feat: add mac as platform 2025-03-11 19:02:53 +11:00
ffc1537d7f feat: partial work on home admin page 2025-03-11 18:25:29 +11:00
9d07070ef6 feat: add news client routes 2025-03-11 18:24:50 +11:00
0f0874c10a feat: file uploads on news articles 2025-03-11 17:51:46 +11:00
137241fdbb fix: notifications and store styling 2025-03-11 17:08:31 +11:00
9515a21dc6 feat: move article creation into a modal 2025-03-11 12:20:56 +11:00
c3ee948682 fix: store page styling 2025-03-11 11:47:35 +11:00
9608cc9742 fix: re-enable metadata providers 2025-03-11 11:33:05 +11:00
88aaa2a71b fix: add aarch64 tag to runner 2025-03-11 09:06:41 +11:00
133503582a feat: client collection routes 2025-03-10 12:28:59 +11:00
1eede0f035 fix: news frontend 2025-03-10 12:05:10 +11:00
b6f52f660a fix: unmerged changes 2025-03-10 11:42:33 +11:00
a1f65b7e59 Merge branch 'AdenMGB-develop' into develop 2025-03-10 11:41:40 +11:00
1ce707788d fix: decduck's code review 2025-03-10 11:39:45 +11:00
31aaec74af feat: migrate to tailwind v4 and fix user token API 2025-03-10 10:35:03 +11:00
97792f0707 fix: home page now (temporarily) redirects to store 2025-03-10 10:34:57 +11:00
b6189d12e7 fix(droplet): add aarch64 optional packages 2025-03-10 10:34:32 +11:00
0877638fc4 feat(acls): refactor & acl descriptions 2025-03-10 10:34:32 +11:00
090d2e6586 feat(acls): added backend acls 2025-03-10 10:34:26 +11:00
a64a2479ba feat: migrate to tailwind v4 and fix user token API 2025-02-14 20:01:18 +11:00
d8d5b938f0 fix: home page now (temporarily) redirects to store 2025-02-08 11:41:16 +11:00
3afd36a821 fix(droplet): add aarch64 optional packages 2025-02-08 11:38:06 +11:00
ce8887528f feat(acls): refactor & acl descriptions 2025-02-07 17:26:23 +11:00
d4dd259b5f feat(acls): added backend acls 2025-02-04 13:15:52 +11:00
256fbd6afa fix(backend): Add forgotton migration for news storage 2025-02-03 16:50:10 +10:30
9344d94e4c feat(api): Added API for deleting news articles 2025-02-02 10:21:43 +10:30
1286248207 feat(api): Added API for retriving information about a spesific news article 2025-02-02 10:21:10 +10:30
2ef8f2f93c feat(api): Added API for fetching news articles 2025-02-02 10:20:26 +10:30
86053815f0 feat(api): Added API for creating articles 2025-02-02 10:19:31 +10:30
88453f1ec4 feat(backend): Added backend communction between API & Frontend 2025-02-02 10:18:27 +10:30
623ab7d786 feat(DB): Updated DB for news articles to be stored in the DB 2025-02-02 10:17:21 +10:30
1ed15902a3 feat(news): Updated user for authoring articles 2025-02-02 10:16:28 +10:30
3a55075532 feat(news): Created article full screen view 2025-02-02 10:15:09 +10:30
6c7866ad14 feat(news): Created article overview page 2025-02-02 10:10:16 +10:30
f78b29b7fd feat(news) Added news page/sidebar 2025-02-02 10:09:25 +10:30
d8e964e06b feat(news): Added backend for news 2025-02-02 10:08:34 +10:30
5d8f9d3813 Create useNews.ts 2025-02-02 10:07:24 +10:30
28bf070ce2 feat(news): Added ability to search and filter news articles 2025-02-02 10:06:38 +10:30
866c4d354e feat(news): Created ability to create news articles 2025-02-02 10:05:55 +10:30
e7837af0e7 feat(news): added ability to delete news articles 2025-02-02 10:05:13 +10:30
97b9b6dc11 Merge pull request #20 from AdenMGB/develop
Fresh animations & scaled Games Grid view properly
2025-01-30 19:30:21 +11:00
09fd01d9b5 style(store): Added reactive hover state for admin dashboard button 2025-01-28 19:25:00 +10:30
8fbe02a1b7 style(store): hover state for coursel button 2025-01-28 19:24:30 +10:30
dce116b66f style(collections): Hover state & scaling games grid properly 2025-01-28 19:23:24 +10:30
d167780562 fix(library): Fixed game banner as background, & added hover state 2025-01-28 19:22:29 +10:30
6e057afb6d style(collections): Added hover state & animation for addition icon 2025-01-28 19:20:33 +10:30
1967de72c8 style(GamePanel): New hover state styles 2025-01-28 19:17:12 +10:30
bfcc7519bf style(AddLibraryButton): New hover state animations 2025-01-28 19:15:49 +10:30
1a2aca9999 fix: collection overview trash icon 2025-01-28 17:23:06 +11:00
282e5bc2a6 fix: store page styling 2025-01-28 17:21:05 +11:00
f369462e7f fix: cascade delete for collection entries 2025-01-28 17:06:30 +11:00
6317ad2657 fix: collection creation & overview hover styles 2025-01-28 17:01:34 +11:00
42ebbf2922 feat: collection deleting 2025-01-28 16:50:57 +11:00
7c1dec9401 fix: store style & library game page 2025-01-28 16:39:13 +11:00
ecd26a42a8 feat: mobile ui and other design 2025-01-28 16:24:08 +11:00
cf0aa948fe feat: refactor & redesign parts of UI 2025-01-28 16:24:08 +11:00
934c176974 refactor(create collection modal): use ModalTemplate & v-model 2025-01-28 16:24:08 +11:00
eea8f82bf9 squash: AdenMGB collection design & backend work
Update index.post.ts to implement saving collections functionality

Update index.get.ts to verify if collection exists and if user can access it

Update index.delete.ts to ask questions and not be so nonchalant

Update entry.post.ts

Update entry.delete.ts to do it better

Update index.vue to add functionality to the add to library button + fidgit with image

Update index.vue to also add add to library functionality, but no fidget :(

Update entry.post.ts to infact not remove it

Update index.ts

Update index.vue to manage collections from store page

Update index.ts to restrut for ahhhh

Update index.vue too add collection control to carosel

Update index.vue fix minor issue

Update index.vue to fix dropdown modal bug

Create library.vue for page layout

Create index.vue for library game details pane

Create index.vue for viewing collections pane

Create DeleteCollectionModal.vue component

Create CreateCollectionModal.vue component

Update AddLibraryButton.vue with dropdown :D

Update index.vue to use new components

Update index.vue for more components :O

Update entry.post.ts to not not return success, it'll figure it out

Update entry.delete.ts to not return...
2025-01-28 16:24:02 +11:00
892f64fe3a Create signout.vue to sign out :shocked: 2025-01-28 16:19:41 +11:00
6bc3173d3a Merge branch 'backslash-fix' into develop 2025-01-28 15:20:30 +11:00
93a58c0d04 fix: import ui setup autocomplete 2025-01-28 15:20:06 +11:00
3298b5f3ee Edit .gitlab-ci.yml 2025-01-26 12:21:38 +00:00
6d03266ade Edit .gitlab-ci.yml 2025-01-26 12:17:19 +00:00
1b3cf498f4 Edit .gitlab-ci.yml 2025-01-26 12:14:22 +00:00
0bfe9803ac Edit .gitlab-ci.yml 2025-01-26 12:13:21 +00:00
617278281e Edit .gitlab-ci.yml 2025-01-26 12:12:54 +00:00
994db6c26a Edit .gitlab-ci.yml 2025-01-26 12:11:34 +00:00
328 changed files with 15829 additions and 7976 deletions

1
.gitattributes vendored
View File

@ -2,3 +2,4 @@
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated
* text=auto eol=lf

46
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: CI
on: [pull_request, push]
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --network-timeout 1000000
- name: Typecheck
run: yarn typecheck
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --network-timeout 1000000
- name: Lint
run: yarn lint

68
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,68 @@
name: Release Workflow
on:
workflow_dispatch: {}
release:
types: [published]
# This can be used to automatically publish nightlies at UTC nighttime
schedule:
- cron: "0 2 * * *" # run at 2 AM UTC
jobs:
web:
name: Push website Docker image to registry
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v4
with:
submodules: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-flags: --debug
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/drop-OSS/drop
tags: |
type=schedule,pattern=nightly
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=ref,event=branch,prefix=branch-
type=ref,event=pr
type=sha
# set latest tag for stable releases
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
- name: Build and push image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

5
.gitignore vendored
View File

@ -30,4 +30,7 @@ logs
# deploy template
deploy-template/*
!deploy-template/compose.yml
!deploy-template/compose.yml
# generated prisma client
/prisma/client

View File

@ -29,3 +29,26 @@ build:
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
docker push $PUBLISH_IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
fi
build-arm64:
stage: build
image: arm64v8/docker:latest
tags:
- aarch64
variables:
IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHORT_SHA-arm64
LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:latest-arm64
PUBLISH_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-arm64
PUBLISH_LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE:latest-arm64
script:
- docker build -t $IMAGE_NAME . --platform=linux/arm64
- docker image tag $IMAGE_NAME $LATEST_IMAGE_NAME
- docker push $IMAGE_NAME
- docker push $LATEST_IMAGE_NAME
- |
if [ $CI_COMMIT_TAG ]; then
docker image tag $IMAGE_NAME $PUBLISH_IMAGE_NAME
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
docker push $PUBLISH_IMAGE_NAME
docker push $PUBLISH_LATEST_IMAGE_NAME
fi

2
.gitmodules vendored
View File

@ -1,3 +1,3 @@
[submodule "drop-base"]
path = drop-base
url = https://github.com/Drop-OSS/drop-base.git
url = https://github.com/Drop-OSS/drop-base.git

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
drop-base/

35
.vscode/settings.json vendored
View File

@ -1,18 +1,21 @@
{
"spellchecker.ignoreWordsList": [
"mTLS",
"Wireguard"
],
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"driver": "PostgreSQL",
"name": "drop",
"database": "drop",
"username": "drop",
"password": "drop"
}
]
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"driver": "PostgreSQL",
"name": "drop",
"database": "drop",
"username": "drop",
"password": "drop"
}
],
// allow autocomplete for ArkType expressions like "string | num"
"editor.quickSuggestions": {
"strings": "on"
},
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
}

View File

@ -1 +0,0 @@
"@drop:registry" "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/"

View File

@ -41,7 +41,8 @@ TODO: Add Troubleshooting
If not, look at the [Troubleshooting](https://github.com/Drop-OSS/docs/Troubleshooting)
page for instructions on how to gather data to better debug your problem.
-->
If you cannot find an existing issue, you can go ahead and create an issue with as much
If you cannot find an existing issue, you can go ahead and create an issue with as much
detail as you can provide.
It should include the data gathered as indicated above, along with the following:
@ -69,7 +70,8 @@ maintainers) by mentioning their GitHub handle (starting with `@`) in your messa
### Getting started
You should be familiar with the basics of
[contributing on GitHub](https://help.github.com/articles/using-pull-requests)
[contributing on GitHub](https://help.github.com/articles/using-pull-requests)
<!--and have a fork
[properly set up](https://github.com/drop/docs/Contribution-Technical-Practices).
-->
@ -95,8 +97,8 @@ maintainers) by mentioning their GitHub handle (starting with `@`) in your messa
### You have an addition
We are absolutely accepting more contributions or features to drop, but please, make sure
that it is reasonable. Contributions that only cover a very small niche are likely to not
We are absolutely accepting more contributions or features to drop, but please, make sure
that it is reasonable. Contributions that only cover a very small niche are likely to not
be added.
Please be so kind as to [search](#use-the-search-luke) for any pending, merged or rejected Pull Requests
@ -109,7 +111,7 @@ maintainers) by mentioning their GitHub handle (starting with `@`) in your messa
For any extensive change, such as API changes, you will have to find testers to +1 your PR.
----
---
## Use the Search, Luke
@ -126,7 +128,7 @@ to be sure your contribution has not already come up.
If all fails, your thing has probably not been reported yet, so you can go ahead
and [create an issue](#reporting-issues) or [submit a PR](#submitting-pull-requests).
----
---
## Commit Guidelines
@ -161,11 +163,13 @@ type(scope)!: subject
Examples:
- Commit that changes the `git` plugin:
```
feat(git): add alias for `git commit`
```
- Commit that changes many plugins:
```
style: fix inline declaration of arrays
```
@ -203,6 +207,7 @@ type(scope)!: subject
Formatting tricks: the commit subject may contain:
- Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool:
```
feat(archlinux): add support for aura AUR helper (#9467)
```
@ -219,7 +224,7 @@ Try to keep the first commit line short. It's harder to do using this commit sty
concise, and if you need more space, you can use the commit body. Try to make sure that the commit
subject is clear and precise enough that users will know what changed by just looking at the changelog.
----
---
<!--
## Volunteer
@ -229,7 +234,9 @@ Very nice!! :)
Please have a look at the [Volunteer](https://github.com/ohmyzsh/ohmyzsh/wiki/Volunteers)
page for instructions on where to start and more.
-->
## Reference
This contributing guide is adapted from the
[oh-my-zsh contribution guide](https://github.com/ohmyzsh/ohmyzsh/blob/master/CONTRIBUTING.md).
If there are any issues with this, please email admin@deepcore.dev.
This contributing guide is adapted from the
[oh-my-zsh contribution guide](https://github.com/ohmyzsh/ohmyzsh/blob/master/CONTRIBUTING.md).
If there are any issues with this, please email admin@deepcore.dev.

View File

@ -1,28 +1,30 @@
# pull pre-configured and updated build environment
FROM registry.deepcore.dev/drop-oss/drop-server-build-environment/main:latest AS build-system
FROM debian:testing-20250317-slim AS build-system
# setup workdir
RUN mkdir /build
WORKDIR /build
# setup workdir - has to be the same filepath as app because fuckin' Prisma
WORKDIR /app
# install dependencies and build
RUN apt-get update -y
RUN apt-get install node-corepack -y
RUN corepack enable
COPY . .
RUN NUXT_TELEMETRY_DISABLED=1 yarn install
RUN NUXT_TELEMETRY_DISABLED=1 yarn install --network-timeout 1000000
RUN NUXT_TELEMETRY_DISABLED=1 yarn prisma generate
RUN NUXT_TELEMETRY_DISABLED=1 yarn build
# create run environment for Drop
FROM node:lts-slim AS run-system
RUN mkdir /app
WORKDIR /app
COPY --from=build-system /build/.output ./app
COPY --from=build-system /build/prisma ./prisma
COPY --from=build-system /build/build ./startup
COPY --from=build-system /app/.output ./app
COPY --from=build-system /app/prisma ./prisma
COPY --from=build-system /app/package.json ./
COPY --from=build-system /app/build ./startup
# OpenSSL as a dependency for Drop (TODO: seperate build environment)
RUN apt-get update -y && apt-get install -y openssl
RUN yarn global add prisma
RUN yarn global add prisma@6.7.0
CMD ["/app/startup/launch.sh"]

View File

@ -1,20 +1,15 @@
<div align="center">
<img src="https://raw.githubusercontent.com/Drop-OSS/media-sources/refs/heads/main/drop.svg" width="400rem"/>
<img src="https://raw.githubusercontent.com/Drop-OSS/media-sources/refs/heads/main/drop.svg" width="200rem"/>
</div>
<div align="center">
<a href="CONTRIBUTING.md">Contribution guide</a>&nbsp;&nbsp;&nbsp;
<a href="https://deepcore.dev">Our website</a>&nbsp;&nbsp;&nbsp;
</div>
<br>
<br>
[![GitHub License](https://img.shields.io/github/license/Drop-OSS/drop-app)](LICENSE)
[![Gitlab Pipeline Status](https://img.shields.io/gitlab/pipeline-status/drop-oss%2Fdrop?gitlab_url=https%3A%2F%2Flab.deepcore.dev)](https://lab.deepcore.dev/drop-oss/drop/-/pipelines)
[![Discord](https://img.shields.io/discord/1291622805124812871?label=discord)](https://discord.gg/ZVGggfXN)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
<br/>
# Drop
[![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org)
[![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9)
[![Open Collective](https://img.shields.io/badge/OpenCollective-1F87FF?style=for-the-badge&logo=OpenCollective&logoColor=white)](https://opencollective.com/drop-oss)
Drop is an open-source game distribution platform, like GameVault or Steam. It's designed to distribute and shared DRM-free game quickly, all while being incredibly flexible, beautiful and fast.
## Philosophy
@ -32,16 +27,18 @@ To just deploy Drop, we've set up a simple docker compose file in deploy-templat
3. Edit the compose.yml file (`nano compose.yml`) and copy your GiamtBomb API Key into the GIANT_BOMB_API_KEY environment variable
4. Run `docker compose up -d`
Your drop server should now be running. To register the admin user, navigate to http://your.drop.server.ip:3000/register?id=admin
Your drop server should now be running. To register the admin user, navigate to http://your.drop.server.ip:3000/register?id=admin
and fill in the required forms
### Adding a game
To add a game to the drop library, do as follows:
1. Ensure that the current user owns the library folder with `sudo chown -R $(id -u $(whoami)) library`
2. `cd library`
3. `mkdir <GAME_NAME>` with the name of the game which you would like to register
4. `cd <GAME_NAME>`
5. `mkdir <VERSION_NAME>` Upload files for the specific game version to this folder
5. `mkdir <VERSION_NAME>` Upload files for the specific game version to this folder
6. Navigate to http://your.drop.server.ip:3000/
7. Import game metadata (uses GiantBomb API Key) by selecting the game and specifying which entry to import
8. Navigate to http://your.drop.server.ip:3000/admin/library
@ -64,15 +61,16 @@ Drop uses a utility package called droplet that's written in Rust. It has builts
Steps:
1. Run `git submodule update --init --recursive` to setup submodules
1. Copy the `.env.example` to `.env` and add your GiantBomb metadata key (more metadata providers coming)
2. Create the `.data` directory with `mkdir .data`
3. Ensure that your user owns the `.data` directory with `sudo chown -R $(id -u $(whoami))`
4. Open up a terminal and navigate to `dev-tools`, and run `docker compose up`
5. Open up another terminal in the root directory of the project and run `yarn` and then `yarn dev` to start the dev server
1. Create the `.data` directory with `mkdir .data`
1. Ensure that your user owns the `.data` directory with `sudo chown -R $(id -u $(whoami))`
1. Open up a terminal and navigate to `dev-tools`, and run `docker compose up`
1. Open up another terminal in the root directory of the project and run `yarn` and then `yarn dev` to start the dev server
As part of the first-time bootstrap, Drop creates an invitation with the fixed id of 'admin'. So, to create an admin account, go to:
http://localhost:3000/register?id=admin
http://localhost:3000/auth/register?id=admin
## Contributing

View File

@ -1,4 +1,5 @@
# Security
To report a vulnerability, please DO NOT create an issue for it
as this may lead to the vulnerability being exploited before it
can be fixed. Instead, please email [security@deepcore.dev](mailto:security@deepcore.dev)

13
app.vue
View File

@ -1,4 +1,5 @@
<template>
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
@ -8,3 +9,15 @@
<script setup lang="ts">
await updateUser();
</script>
<style scoped>
/* You can customise the default animation here. */
::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
}
::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
}
</style>

View File

@ -1,7 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
$motiva: (
("MotivaSansThin.ttf", "ttf", 100, normal),
("MotivaSansLight.woff.ttf", "woff", 300, normal),
@ -66,4 +62,16 @@ $helvetica: (
}
.carousel__pagination-button--active:hover::after {
background-color: #d4d4d8;
}
}
.store-carousel > .carousel__viewport {
overflow: visible !important;
}
button {
cursor: pointer !important;
}
html {
background-color: oklch(0.21 0.006 285.885);
}

4
assets/tailwindcss.css Normal file
View File

@ -0,0 +1,4 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@config "../tailwind.config.js";

View File

@ -1,6 +1,8 @@
#!/bin/bash
# This file starts up the Drop server by running migrations and then starting the executable
echo "[Drop] performing migrations..."
ls ./prisma/migrations/
prisma migrate deploy
# Actually start the application

View File

@ -1,8 +1,199 @@
## Release 0.2.0-beta
### Fixes
- fix recursive dirs util #02d6346
- Fix username length requirement #0a5a649
- remove dynamic imports #0f10626
- fix for missing developers or publishers #25fc957
- split prisma schemas #2859005
- results are returned alphabetically #33d3770
- update prisma schemas #36776cc
- removed global flag #43e32b4
- properly disconnect websockets from task handler #5358f1f
- follow best practices #54c5d55
- future lenience #5c78b20
- fix width of token breaking things #61d88c3
- fixed websocket authentication #62ea9a1
- fix delta manifest generation #6df560c
- admin invitation w/ system user #8463e35
- properly import icons #8945196
- prisma create footprint #952ece8
- game panel now always shows 3 lines exactly #9c2249e
- remove unnecessary import #a361c38
- fix disconnect code #a8f2106
- fix types #b511b40
- add drop-base as git submodule #b75ebd1
- Update README.md with discord link #c6bb21d
- fix expires requirement in the admin endpoint #c7b675f
- fix always being created as admin #c7eb11a
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
- recurse submodules #db103de
- fix FATAL: "root"... message #dbb315a
- only show versions that are directories #ef8f3ae
### Features
- update prisma & delete games #089c3e0
- manual handshake #12e3125
- fetch game endpoint #1f4d075
- under the hood organisation and consolidation #26a31f6
- 'no images' slide on image carousel #28baabc
- improve feedback when metadata fails #2c19e13
- introduction of 'system user' #2c21a23
- change name, description and icon #2cfe75a
- 'manual' metadata provider #2f52a16
- add disabled state #38fc6b8
- overhauled version importing #39d7ce7
- automatically create library folder if it doesn't exist #39fe9d5
- smoother bar in admin task ui #4488ae2
- add noWrapper option #4f9b949
- add version metadata route #5393db3
- completed admin UI, with minor changes to backend #599da0e
- adjust gradient #5a1f841
- keep track of last connected #69e4c25
- added notification system w/ interwoven refactoring #6e6f09d
- content length header for chunk downloads #76bceb1
- add title to tab #7b0756c
- add button to open in admin panel #7b3b919
- client capability framework + peer API configuration #7d72a86
- customisable image carousel and new layout #937954f
- support more types #9b12d45
- generate a server certificate for mtls APIs #9c4b6f3
- new endpoints, ui and beginnings of main store page #9cbdcbc
- backend #a309651
- more subtle design improvements #a815542
- add aden's carousel pagination design #a86045c
- add header #a8a152e
- client side search #b50e27f
- new ws handler #bc0c47c
- user widget now redirects to actual page #bfafe02
- require lowercase usernames #d7160ab
- more ui improvements #e408ac5
- add modifying game descriptions #e505e58
- mobile nav #e5cf13f
- slightly improved game page #e796b46
- game carousel #ecc819e
- add enum dictionary type #f2e0182
- improved ux #f3ed0f6
- cleanup and raw accessors #f7d767d
- add support for overriding UMU id #fd4a7d1
- add .sh for linux #fe9373a
### Other Changes
- quexeky <git@quexeky.dev>
- fixed manifest generation #03a37f7
- manual ci/cd #03b0b0c
- ability to fetch client certs for p2p #0a715fe
- disable tls in build #0f80fcd
- Updated README.md #17971e0
- Merge pull request #18 from Drop-OSS/develop
- initial work on metadata system #196f87c
- more ui #1bd19ad
- remove log statements #1d5e1bd
- small fixes & SSR disabled #1f575b2
- update information and setup guide #2236622
- metadata engine #22ac7f6
- Update CONTRIBUTING.md #2309407
- slight bug fixes and clean up #24a0d11
- almst complete admin ui and initial store designs #27070b6
- handshakes #2b4382d
- user mobile header #2e44ef3
- more consistent naming for globals #305de9f
- replaced markdown-it with micromark #31e8359
- fixes to store page for mobile clients #328b9ba
- game version re-ordering #329c74d
- verbose yarn install #36568c3
- patch for no version check in manifest generation #395219d
- migrate bcrypt to bcryptjs #3a51c9c
- added download chunk endpoint #3dd6062
- Update README.md #425934d
- build only ci #4273a20
- object storage + full permission system + testing #435551c
- rename admin socket session map #44c6028
- bump droplet and add vue carousel #46551f9
- version importing #46c8f0c
- back to yarn, with nuxt telemetry force disabled #46d35ad
- finished object endpoints #486bce8
- update dependencies and add note about optional dependencies #4fa771a
- use configuration from docs for ci/cd #52315d0
- slight fixes to register logic #583301f
- removed yarn.lock #584bcf1
- Version bump #5f29c28
- immutable application settings framework #5fe2036
- fixed docker daemon location #62a111b
- copy autodevops configuration #6328c24
- Delete .gitlab-ci.yml #69f341b
- admin ui shell #6b5e48d
- bump @drop/droplet version for windows developers #6ba5cdd
- Add LICENSE #6e2dc89
- custom dind #716eac7
- task API #718f5ba
- use gitlab ci variable declaration #7194d35
- move icons into dedicated folder #74fa671
- another stage of client authentication #7523e53
- refactoring #7869043
- moved windows logo into logos dir #789d3ba
- updated text colours across app #7a88f4c
- starting docs infra #7d2a1c6
- more cleaning #7e17626
- slight patch to rename query to be more consistent #7f4db0c
- move to raw docker #803752e
- server side and user client side completed for registration #848a611
- beginnings of download implementation #8674ac7
- more consistent naming for object handler #87230fb
- use autodevops build stage #886beb6
- Updated tailwind config #88c95d6
- change name of store file #8999303
- split prisma schemas #9011cf5
- client initiate #909432a
- more client routes to support Drop app update #91b7e10
- additional polish and QoL features #93bc143
- upload images to games #9b7ee4e
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
- run yarn install in CI/CD non interactively #a208fbe
- completed game importing; partial work on version importing #a7c33e7
- remove canvas from dependencies #a8f58eb
- fix registry authentication #ad25d3e
- consolidate type utils #adb4b73
- Updated README.md #b0ef675
- add proper carousel to store page #b2ab827
- move to yarn v2 #b744671
- remove client API deadweight #b9ae26c
- add expires field #be6c30d
- ca groundwork #bfafd2a
- cleanup & polish #c355f6f
- remove bcrypt (debug) #c3914cc
- non rounded bottom #c4391d3
- failed gracefully on invalid chunk index #c4a3e4e
- update deploy template #c4a419f
- migrate to new droplet ca system #c4d8113
- docker based deployment #c5d00b4
- updated CONTRIBUTING.md #cd0d2bf
- update prisma version #ce0a9ab
- README update #ceacd84
- patch metadata handler #cf578bd
- Added SECURITY.md #d3d93b0
- finalised client APIs and authentication method #d4e2dc8
- Update README.md #db916bf
- object storage interface + utility functions #de388a9
- initial commit #e1a789f
- fixed task system #e1c1d7e
- Update file chunk.get.ts #e4339c3
- ui groundwork #e52f072
- Update changelog #eadcaa1
- check for no version in manifest generation #eb3f9f9
- break into single column store on lg devices #ecb381e
- better server side signin redirects #ef13b68
- patch signin #f3672f8
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
## Release 0.1.0-beta
### Fixes
- remove dynamic imports #0f10626
- fix for missing developers or publishers #25fc957
- split prisma schemas #2859005
@ -22,8 +213,8 @@
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
- only show versions that are directories #ef8f3ae
### Features
- update prisma & delete games #089c3e0
- fetch game endpoint #1f4d075
- under the hood organisation and consolidation #26a31f6
@ -53,9 +244,9 @@
- cleanup and raw accessors #f7d767d
- add support for overriding UMU id #fd4a7d1
### Other Changes
- quexeky <git@quexeky.dev>
- quexeky <git@quexeky.dev>
- fixed manifest generation #03a37f7
- manual ci/cd #03b0b0c
- ability to fetch client certs for p2p #0a715fe
@ -158,5 +349,4 @@
- better server side signin redirects #ef13b68
- patch signin #f3672f8
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)

View File

@ -0,0 +1,84 @@
<template>
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<UserIcon class="size-5" /> Account Settings
</span>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" class="-mx-2 space-y-1">
<li v-for="(item, itemIdx) in navigation" :key="item.route">
<NuxtLink
:href="item.route"
:class="[
itemIdx == currentPageIndex
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
]"
>
<component
:is="item.icon"
class="size-6 shrink-0"
aria-hidden="true"
/>
{{ item.label }}
<span
v-if="item.count !== undefined"
class="ml-auto w-9 min-w-max whitespace-nowrap rounded-full bg-zinc-900 px-2.5 py-0.5 text-center text-xs/5 font-medium text-white ring-1 ring-inset ring-zinc-700"
aria-hidden="true"
>{{ item.count }}</span
>
</NuxtLink>
</li>
</ul>
</li>
</ul>
</nav>
</div>
</template>
<script setup lang="ts">
import {
BellIcon,
HomeIcon,
LockClosedIcon,
DevicePhoneMobileIcon,
WrenchScrewdriverIcon,
} from "@heroicons/vue/24/outline";
import { UserIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue";
const notifications = useNotifications();
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
{ label: "Home", route: "/account", icon: HomeIcon, prefix: "/account" },
{
label: "Security",
route: "/account/security",
prefix: "/account/security",
icon: LockClosedIcon,
},
{
label: "Devices",
route: "/account/devices",
prefix: "/account/devices",
icon: DevicePhoneMobileIcon,
},
{
label: "Notifications",
route: "/account/notifications",
prefix: "/account/notifications",
icon: BellIcon,
count: notifications.value.length,
},
{
label: "Settings",
route: "/account/settings",
prefix: "/account/settings",
icon: WrenchScrewdriverIcon,
},
];
const currentPageIndex = useCurrentNavigationIndex(navigation);
</script>

View File

@ -1,24 +1,26 @@
<template>
<div class="inline-flex divide-x divide-zinc-900">
<button
type="button"
class="inline-flex justify-center items-center gap-x-2 rounded-l-md aspect-[7/2] px-3 py-2 bg-blue-600 grow text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
<div
class="inline-flex w-full group hover:scale-105 transition-all duration-200"
>
<LoadingButton
:loading="isLibraryLoading"
:style="'none'"
class="transition w-full inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
@click="() => toggleLibrary()"
>
Add to Library
<PlusIcon class="-mr-0.5 size-6" aria-hidden="true" />
</button>
<Menu as="div" class="relative inline-block text-left grow">
<div class="h-full">
<MenuButton
class="inline-flex h-full w-full justify-center items-center rounded-r-md bg-blue-600 p-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<ChevronDownIcon
class="size-5"
aria-hidden="true"
/>
</MenuButton>
</div>
{{ inLibrary ? "In Library" : "Add to Library" }}
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</LoadingButton>
<!-- Collections dropdown -->
<Menu as="div" class="relative">
<MenuButton
as="div"
class="transition cursor-pointer inline-flex items-center rounded-r-md h-full ml-[2px] bg-white/10 hover:bg-white/20 backdrop-blur py-3.5 px-2 justify-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/20"
>
<ChevronDownIcon class="h-5 w-5 text-white" aria-hidden="true" />
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
@ -28,68 +30,138 @@
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-none"
class="absolute right-0 z-50 mt-2 w-72 origin-top-right rounded-md bg-zinc-800/90 backdrop-blur shadow-lg focus:outline-none"
>
<div class="py-1">
<MenuItem v-slot="{ active }">
<a
href="#"
:class="[
active
? 'bg-gray-100 text-gray-900 outline-none'
: 'text-gray-700',
'block px-4 py-2 text-sm',
]"
>Account settings</a
<div class="p-2">
<div
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
>
Collections
</div>
<div
class="flex flex-col gap-y-2 py-1 max-h-[150px] overflow-y-auto"
>
<div
v-if="collections.length === 0"
class="px-3 py-2 text-sm text-zinc-500"
>
</MenuItem>
<MenuItem v-slot="{ active }">
<a
href="#"
:class="[
active
? 'bg-gray-100 text-gray-900 outline-none'
: 'text-gray-700',
'block px-4 py-2 text-sm',
]"
>Support</a
No collections
</div>
<MenuItem
v-for="(collection, collectionIdx) in collections"
:key="collection.id"
v-slot="{ active }"
>
</MenuItem>
<MenuItem v-slot="{ active }">
<a
href="#"
:class="[
active
? 'bg-gray-100 text-gray-900 outline-none'
: 'text-gray-700',
'block px-4 py-2 text-sm',
]"
>License</a
>
</MenuItem>
<form method="POST" action="#">
<MenuItem v-slot="{ active }">
<button
type="submit"
:class="[
active
? 'bg-gray-100 text-gray-900 outline-none'
: 'text-gray-700',
'block w-full px-4 py-2 text-left text-sm',
active ? 'bg-zinc-700/90' : '',
'group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-zinc-200',
]"
@click="() => toggleCollection(collection.id)"
>
Sign out
<span>{{ collection.name }}</span>
<CheckIcon
v-if="inCollections[collectionIdx]"
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</button>
</MenuItem>
</form>
</div>
<div class="border-t border-zinc-700 pt-1">
<LoadingButton
:loading="false"
class="w-full"
@click="createCollectionModal = true"
>
<PlusIcon class="mr-2 h-4 w-4" />
Add to new collection
</LoadingButton>
</div>
</div>
</MenuItems>
</transition>
</Menu>
</div>
<CreateCollectionModal
v-model="createCollectionModal"
:game-id="props.gameId"
/>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { ChevronDownIcon, PlusIcon } from "@heroicons/vue/20/solid";
import { PlusIcon, ChevronDownIcon, CheckIcon } from "@heroicons/vue/24/solid";
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
const props = defineProps<{
gameId: string;
}>();
const isLibraryLoading = ref(false);
const createCollectionModal = ref(false);
const collections = await useCollections();
const library = await useLibrary();
const inLibrary = computed(
() => library.value.entries.findIndex((e) => e.gameId == props.gameId) != -1,
);
const inCollections = computed(() =>
collections.value.map(
(e) => e.entries.findIndex((e) => e.gameId == props.gameId) != -1,
),
);
async function toggleLibrary() {
isLibraryLoading.value = true;
try {
await $dropFetch("/api/v1/collection/default/entry", {
method: inLibrary.value ? "DELETE" : "POST",
body: {
id: props.gameId,
},
});
await refreshLibrary();
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to add game to library",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
},
(_, c) => c(),
);
} finally {
isLibraryLoading.value = false;
}
}
async function toggleCollection(id: string) {
try {
const collection = collections.value.find((e) => e.id == id);
if (!collection) return;
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
await $dropFetch(`/api/v1/collection/${id}/entry`, {
method: index == -1 ? "POST" : "DELETE",
body: {
id: props.gameId,
},
});
await refreshCollection(id);
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to add game to library",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
},
(_, c) => c(),
);
}
}
</script>

View File

@ -0,0 +1,10 @@
<template>
<div class="flex">
<a
href="/auth/oidc"
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
>
Sign in with external provider &rarr;
</a>
</div>
</template>

124
components/Auth/Simple.vue Normal file
View File

@ -0,0 +1,124 @@
<template>
<form class="space-y-6" @submit.prevent="signin_wrapper">
<div>
<label
for="username"
class="block text-sm font-medium leading-6 text-zinc-300"
>Username</label
>
<div class="mt-2">
<input
id="username"
v-model="username"
name="username"
type="username"
autocomplete="username"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium leading-6 text-zinc-300"
>Password</label
>
<div class="mt-2">
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
v-model="rememberMe"
name="remember-me"
type="checkbox"
class="h-4 w-4 rounded bg-zinc-800 border-zinc-700 text-blue-600 focus:ring-blue-600"
/>
<label
for="remember-me"
class="ml-3 block text-sm leading-6 text-zinc-400"
>Remember me</label
>
</div>
<div class="text-sm leading-6">
<NuxtLink to="#" class="font-semibold text-blue-600 hover:text-blue-500"
>Forgot password?</NuxtLink
>
</div>
</div>
<div>
<LoadingButton class="w-full" :loading="loading"> Sign in</LoadingButton>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { User } from "~/prisma/client";
const username = ref("");
const password = ref("");
const rememberMe = ref(false);
const loading = ref(false);
const error = ref<string | undefined>();
const route = useRoute();
const router = useRouter();
function signin_wrapper() {
loading.value = true;
signin()
.then(() => {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => {
const message = response.statusMessage || "An unknown error occurred";
error.value = message;
})
.finally(() => {
loading.value = false;
});
}
async function signin() {
await $dropFetch("/api/v1/auth/signin/simple", {
method: "POST",
body: {
username: username.value,
password: password.value,
rememberMe: rememberMe.value,
},
});
const user = useUser();
user.value = await $dropFetch<User | null>("/api/v1/user");
}
</script>

View File

@ -2,9 +2,9 @@
<div class="flex flex-row flex-wrap gap-2 justify-center">
<button
v-for="(_, i) in amount"
@click="() => slideTo(i)"
:key="i"
:class="[
currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
'transition-all cursor-pointer h-2 rounded-full',
]"
/>
@ -12,16 +12,14 @@
</template>
<script setup lang="ts">
const maxSlide = inject("maxSlide", ref(1));
const minSlide = inject("minSlide", ref(1));
const currentSlide = inject("currentSlide", ref(1));
const nav: { slideTo?: (index: number) => any } = inject("nav", {});
import { injectCarousel } from "vue3-carousel";
const amount = computed(() => maxSlide.value - minSlide.value + 1);
const carousel = inject(injectCarousel)!;
function slideTo(index: number) {
if (!nav.slideTo) return console.warn(`error moving slide: nav not defined`);
const offsetIndex = index + minSlide.value;
nav.slideTo(offsetIndex);
}
const amount = carousel.maxSlide - carousel.minSlide + 1;
// function slideTo(index: number) {
// const offsetIndex = index + carousel.minSlide;
// carousel.nav.slideTo(offsetIndex);
// }
</script>

View File

@ -0,0 +1,113 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
Create collection
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
Collections can used to organise your games and find them more easily,
especially if you have a large library.
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => createCollection()">
<input
v-model="collectionName"
type="text"
placeholder="Collection name"
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="createCollectionLoading"
:disabled="!collectionName"
class="w-full sm:w-fit"
@click="() => createCollection()"
>
Create
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { CollectionEntry, Game } from "~/prisma/client";
import type { SerializeObject } from "nitropack";
const props = defineProps<{
gameId?: string;
}>();
const emit = defineEmits<{
created: [collectionId: string];
}>();
const open = defineModel<boolean>({ required: true });
const collectionName = ref("");
const createCollectionLoading = ref(false);
const collections = await useCollections();
async function createCollection() {
if (!collectionName.value || createCollectionLoading.value) return;
try {
createCollectionLoading.value = true;
// Create the collection
const response = await $dropFetch("/api/v1/collection", {
method: "POST",
body: { name: collectionName.value },
});
// Add the game if provided
if (props.gameId) {
const entry = await $dropFetch<
CollectionEntry & { game: SerializeObject<Game> }
>(`/api/v1/collection/${response.id}/entry`, {
method: "POST",
body: { id: props.gameId },
});
response.entries.push(entry);
}
collections.value.push(response);
// Reset and emit
collectionName.value = "";
open.value = false;
emit("created", response.id);
} catch (error) {
console.error("Failed to create collection:", error);
const err = error as { statusMessage?: string };
createModal(
ModalType.Notification,
{
title: "Failed to create collection",
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
},
(_, c) => c(),
);
} finally {
createCollectionLoading.value = false;
}
}
</script>

View File

@ -0,0 +1,75 @@
<template>
<ModalTemplate :model-value="!!collection">
<template #default>
<div>
<DialogTitle
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
Delete Collection
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
Are you sure you want to delete "{{ collection?.name }}"?
</p>
<p class="mt-2 text-sm font-bold text-red-500">
This action cannot be undone.
</p>
</div>
</template>
<template #buttons>
<LoadingButton
:loading="deleteLoading"
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteCollection()"
>
Delete
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (collection = undefined)"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { Collection } from "~/prisma/client";
import { DialogTitle } from "@headlessui/vue";
const collection = defineModel<Collection | undefined>();
const deleteLoading = ref(false);
const collections = await useCollections();
async function deleteCollection() {
try {
if (!collection.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/collection/${collection.value.id}`, {
// @ts-expect-error not documented
method: "DELETE",
});
const index = collections.value.findIndex(
(e) => e.id == collection.value?.id,
);
collections.value.splice(index, 1);
collection.value = undefined;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to add game to library",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
},
(_, c) => c(),
);
} finally {
deleteLoading.value = false;
}
}
</script>

View File

@ -0,0 +1,81 @@
<template>
<ModalTemplate :model-value="!!article">
<template #default>
<div>
<DialogTitle
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
Delete Article
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
Are you sure you want to delete "{{ article?.title }}"?
</p>
<p class="mt-2 text-sm font-bold text-red-500">
This action cannot be undone.
</p>
</div>
</template>
<template #buttons>
<LoadingButton
:loading="deleteLoading"
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteArticle()"
>
Delete
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (article = undefined)"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { DialogTitle } from "@headlessui/vue";
interface Article {
id: string;
title: string;
}
const article = defineModel<Article | undefined>();
const deleteLoading = ref(false);
const router = useRouter();
const news = useNews();
if (!news.value) {
news.value = await fetchNews();
}
async function deleteArticle() {
try {
if (!article.value || !news.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/admin/news/${article.value.id}`, {
method: "DELETE",
});
const index = news.value.findIndex((e) => e.id == article.value?.id);
news.value.splice(index, 1);
article.value = undefined;
router.push("/news");
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to delete article",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't delete this article: ${e?.statusMessage}`,
},
(_, c) => c(),
);
} finally {
deleteLoading.value = false;
}
}
</script>

View File

@ -1,50 +0,0 @@
<template>
<div
v-if="user"
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-900 px-6 pb-4 ring-1 ring-white/10"
>
<div class="flex h-16 shrink-0 items-center">
<Wordmark />
</div>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" class="-mx-2 space-y-1">
<DocsSidebarNavItem
v-for="item in unwrappedNavigation ?? navigation"
:key="item.name"
:nav="item"
/>
</ul>
</li>
<li class="mt-auto flex items-center">
<div class="inline-flex items-center w-full text-zinc-300">
<img
:src="useObject(user.profilePicture)"
class="w-5 h-5 rounded-sm"
/>
<span class="ml-3 text-sm font-bold">{{
user.displayName
}}</span>
</div>
<NuxtLink
href="/"
class="ml-auto rounded bg-blue-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
&larr;&nbsp;Home
</NuxtLink>
</li>
</ul>
</nav>
</div>
</template>
<script setup lang="ts">
import { fetchContentNavigation, useObject, useUser } from "#imports";
const user = useUser();
const navigation = await fetchContentNavigation();
const unwrappedNavigation = navigation[0]?.children;
</script>

View File

@ -1,30 +0,0 @@
<template>
<NuxtLink
:href="props.nav._path"
:class="[
current
? 'text-zinc-100'
: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-900',
' group flex gap-x-3 rounded-md px-2 text-sm font-semibold leading-6',
]"
>
{{ props.nav.title }}
</NuxtLink>
<ul class="pl-3 flex flex-col" v-if="children">
<li v-for="child in children" class="inline-flex items-center">
<ChevronDownIcon class="w-4 h-4 text-zinc-600 rotate-45" />
<DocsSidebarNavItem :nav="child" :key="child._path" />
</li>
</ul>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from "@heroicons/vue/24/solid";
type NavItem = { title: string; _path: string; children?: NavItem[] };
const props = defineProps<{ nav: NavItem }>();
const children = props.nav.children?.filter((e) => e._path != props.nav._path);
const route = useRoute();
const current = computed(() => route.path.trim() == props.nav._path.trim());
</script>

14
components/DropLogo.vue Normal file
View File

@ -0,0 +1,14 @@
<template>
<svg
class="text-blue-400"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="currentColor"
stroke-width="2"
/>
</svg>
</template>

View File

@ -0,0 +1,18 @@
<template>
<div class="inline-flex justify-center items-center gap-x-1 -mb-1 relative">
<svg
aria-hidden="true"
viewBox="0 0 418 42"
class="absolute inset-0 h-full w-full fill-blue-300/30 scale-75"
preserveAspectRatio="none"
>
<path
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z"
/>
</svg>
<DropLogo class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase"
>Drop</span
>
</div>
</template>

View File

@ -1,41 +1,70 @@
<template>
<ClientOnly>
<VueCarousel :itemsToShow="moveAmount" :itemsToScroll="moveAmount / 2">
<VueSlide
class="justify-start"
v-for="(game, gameIdx) in games"
:key="gameIdx"
>
<GamePanel :game="game" />
</VueSlide>
<div ref="currentComponent">
<ClientOnly fallback-tag="span">
<VueCarousel :items-to-show="singlePage" :items-to-scroll="singlePage">
<VueSlide
v-for="(game, gameIdx) in games"
:key="gameIdx"
class="justify-start"
>
<GamePanel :game="game" />
</VueSlide>
<template #addons>
<VueNavigation />
<template #addons>
<VueNavigation />
</template>
</VueCarousel>
<template #fallback>
<div
class="flex flex-nowrap flex-row overflow-hidden whitespace-nowrap"
>
<SkeletonCard
v-for="index in 10"
:key="index"
:loading="true"
class="mr-3 flex-none"
/>
</div>
</template>
</VueCarousel>
</ClientOnly>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { Game } from "@prisma/client";
import type { Game } from "~/prisma/client";
import type { SerializeObject } from "nitropack";
const props = defineProps<{
items: Array<SerializeObject<Game>>;
min?: number;
width?: number;
}>();
const currentComponent = ref<HTMLDivElement>();
const min = computed(() => Math.max(props.min ?? 8, props.items.length));
const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() =>
Array(min.value)
.fill(0)
.map((_, i) => props.items[i])
.map((_, i) => props.items[i]),
);
const moveAmount = ref(1);
const moveFactor = 1.8 / 400;
const singlePage = ref(2);
const sizeOfCard = 192 + 10;
const handleResize = () => {
singlePage.value =
(props.width ??
currentComponent.value?.parentElement?.clientWidth ??
window.innerWidth) / sizeOfCard;
};
onMounted(() => {
moveAmount.value = moveFactor * window.innerWidth;
handleResize();
window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
});
</script>

View File

@ -1,41 +1,61 @@
<template>
<NuxtLink
v-if="game"
:href="`/store/${game.id}`"
class="rounded overflow-hidden w-48 h-64 group relative transition-all duration-300 text-left"
:href="props.href ?? `/store/${game.id}`"
class="group relative w-48 h-64 rounded-lg overflow-hidden transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5"
@click="active = game.id"
>
<img :src="useObject(game.mCoverId)" class="w-full h-full object-cover" />
<div
class="absolute inset-0 bg-gradient-to-b from-transparent to-[100%] to-zinc-950/50"
/>
<div class="absolute bottom-0 left-0 px-2 py-1.5">
<h1 class="text-zinc-100 text-sm font-bold font-display">
class="absolute inset-0 transition-all duration-300 group-hover:scale-110"
>
<img
:src="useObject(game.mCoverObjectId)"
class="w-full h-full object-cover brightness-[90%]"
:class="{ active: active === game.id }"
:alt="game.mName"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent"
/>
</div>
<div class="absolute bottom-0 left-0 w-full p-3">
<h1
class="text-zinc-100 text-sm font-bold font-display group-hover:text-white transition-colors"
>
{{ game.mName }}
</h1>
<p class="text-zinc-400 text-xs line-clamp-2">
<p
class="text-zinc-400 text-xs line-clamp-2 group-hover:text-zinc-300 transition-colors"
>
{{ game.mShortDescription }}
</p>
</div>
</NuxtLink>
<div
v-else
class="rounded w-48 h-64 bg-zinc-800 flex items-center justify-center"
>
<p class="text-zinc-700 text-sm font-semibold font-display uppercase">
no game
</p>
</div>
<SkeletonCard v-else message="no game" />>
</template>
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
const props = defineProps<{
game?: SerializeObject<{
id: string;
mCoverId: string;
mName: string;
mShortDescription: string;
}>;
game:
| SerializeObject<{
id: string;
mCoverObjectId: string;
mName: string;
mShortDescription: string;
}>
| undefined;
href?: string;
}>();
const active = useState();
</script>
<style scoped>
img.active {
view-transition-name: selected-game;
contain: layout;
}
</style>

View File

@ -18,7 +18,7 @@
<script setup lang="ts">
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const props = defineProps<{
const { game } = defineProps<{
game: GameMetadataSearchResult & { sourceName?: string };
}>();
</script>

View File

@ -1,9 +1,9 @@
<template>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M20.992 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.050 0.005 0.109 0.005 0.168 0 1.523-1.191 2.768-2.693 2.854l-0.008 0zM11.026 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.048 0.005 0.104 0.005 0.161 0 1.525-1.19 2.771-2.692 2.862l-0.008 0zM26.393 6.465c-1.763-0.832-3.811-1.49-5.955-1.871l-0.149-0.022c-0.005-0.001-0.011-0.002-0.017-0.002-0.035 0-0.065 0.019-0.081 0.047l-0 0c-0.234 0.411-0.488 0.924-0.717 1.45l-0.043 0.111c-1.030-0.165-2.218-0.259-3.428-0.259s-2.398 0.094-3.557 0.275l0.129-0.017c-0.27-0.63-0.528-1.142-0.813-1.638l0.041 0.077c-0.017-0.029-0.048-0.047-0.083-0.047-0.005 0-0.011 0-0.016 0.001l0.001-0c-2.293 0.403-4.342 1.060-6.256 1.957l0.151-0.064c-0.017 0.007-0.031 0.019-0.040 0.034l-0 0c-2.854 4.041-4.562 9.069-4.562 14.496 0 0.907 0.048 1.802 0.141 2.684l-0.009-0.11c0.003 0.029 0.018 0.053 0.039 0.070l0 0c2.14 1.601 4.628 2.891 7.313 3.738l0.176 0.048c0.008 0.003 0.018 0.004 0.028 0.004 0.032 0 0.060-0.015 0.077-0.038l0-0c0.535-0.72 1.044-1.536 1.485-2.392l0.047-0.1c0.006-0.012 0.010-0.027 0.010-0.043 0-0.041-0.026-0.075-0.062-0.089l-0.001-0c-0.912-0.352-1.683-0.727-2.417-1.157l0.077 0.042c-0.029-0.017-0.048-0.048-0.048-0.083 0-0.031 0.015-0.059 0.038-0.076l0-0c0.157-0.118 0.315-0.24 0.465-0.364 0.016-0.013 0.037-0.021 0.059-0.021 0.014 0 0.027 0.003 0.038 0.008l-0.001-0c2.208 1.061 4.8 1.681 7.536 1.681s5.329-0.62 7.643-1.727l-0.107 0.046c0.012-0.006 0.025-0.009 0.040-0.009 0.022 0 0.043 0.008 0.059 0.021l-0-0c0.15 0.124 0.307 0.248 0.466 0.365 0.023 0.018 0.038 0.046 0.038 0.077 0 0.035-0.019 0.065-0.046 0.082l-0 0c-0.661 0.395-1.432 0.769-2.235 1.078l-0.105 0.036c-0.036 0.014-0.062 0.049-0.062 0.089 0 0.016 0.004 0.031 0.011 0.044l-0-0.001c0.501 0.96 1.009 1.775 1.571 2.548l-0.040-0.057c0.017 0.024 0.046 0.040 0.077 0.040 0.010 0 0.020-0.002 0.029-0.004l-0.001 0c2.865-0.892 5.358-2.182 7.566-3.832l-0.065 0.047c0.022-0.016 0.036-0.041 0.039-0.069l0-0c0.087-0.784 0.136-1.694 0.136-2.615 0-5.415-1.712-10.43-4.623-14.534l0.052 0.078c-0.008-0.016-0.022-0.029-0.038-0.036l-0-0z">
</path>
</svg>
</template>
<template>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M20.992 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.050 0.005 0.109 0.005 0.168 0 1.523-1.191 2.768-2.693 2.854l-0.008 0zM11.026 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.048 0.005 0.104 0.005 0.161 0 1.525-1.19 2.771-2.692 2.862l-0.008 0zM26.393 6.465c-1.763-0.832-3.811-1.49-5.955-1.871l-0.149-0.022c-0.005-0.001-0.011-0.002-0.017-0.002-0.035 0-0.065 0.019-0.081 0.047l-0 0c-0.234 0.411-0.488 0.924-0.717 1.45l-0.043 0.111c-1.030-0.165-2.218-0.259-3.428-0.259s-2.398 0.094-3.557 0.275l0.129-0.017c-0.27-0.63-0.528-1.142-0.813-1.638l0.041 0.077c-0.017-0.029-0.048-0.047-0.083-0.047-0.005 0-0.011 0-0.016 0.001l0.001-0c-2.293 0.403-4.342 1.060-6.256 1.957l0.151-0.064c-0.017 0.007-0.031 0.019-0.040 0.034l-0 0c-2.854 4.041-4.562 9.069-4.562 14.496 0 0.907 0.048 1.802 0.141 2.684l-0.009-0.11c0.003 0.029 0.018 0.053 0.039 0.070l0 0c2.14 1.601 4.628 2.891 7.313 3.738l0.176 0.048c0.008 0.003 0.018 0.004 0.028 0.004 0.032 0 0.060-0.015 0.077-0.038l0-0c0.535-0.72 1.044-1.536 1.485-2.392l0.047-0.1c0.006-0.012 0.010-0.027 0.010-0.043 0-0.041-0.026-0.075-0.062-0.089l-0.001-0c-0.912-0.352-1.683-0.727-2.417-1.157l0.077 0.042c-0.029-0.017-0.048-0.048-0.048-0.083 0-0.031 0.015-0.059 0.038-0.076l0-0c0.157-0.118 0.315-0.24 0.465-0.364 0.016-0.013 0.037-0.021 0.059-0.021 0.014 0 0.027 0.003 0.038 0.008l-0.001-0c2.208 1.061 4.8 1.681 7.536 1.681s5.329-0.62 7.643-1.727l-0.107 0.046c0.012-0.006 0.025-0.009 0.040-0.009 0.022 0 0.043 0.008 0.059 0.021l-0-0c0.15 0.124 0.307 0.248 0.466 0.365 0.023 0.018 0.038 0.046 0.038 0.077 0 0.035-0.019 0.065-0.046 0.082l-0 0c-0.661 0.395-1.432 0.769-2.235 1.078l-0.105 0.036c-0.036 0.014-0.062 0.049-0.062 0.089 0 0.016 0.004 0.031 0.011 0.044l-0-0.001c0.501 0.96 1.009 1.775 1.571 2.548l-0.040-0.057c0.017 0.024 0.046 0.040 0.077 0.040 0.010 0 0.020-0.002 0.029-0.004l-0.001 0c2.865-0.892 5.358-2.182 7.566-3.832l-0.065 0.047c0.022-0.016 0.036-0.041 0.039-0.069l0-0c0.087-0.784 0.136-1.694 0.136-2.615 0-5.415-1.712-10.43-4.623-14.534l0.052 0.078c-0.008-0.016-0.022-0.029-0.038-0.036l-0-0z"
/>
</svg>
</template>

View File

@ -1,16 +1,29 @@
<template>
<svg viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-140.000000, -7559.000000)" fill="currentColor">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path
d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399"
id="github-[#142]">
</path>
</g>
</g>
</g>
</svg>
</template>
<template>
<svg
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g
id="Page-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g
id="Dribbble-Light-Preview"
transform="translate(-140.000000, -7559.000000)"
fill="currentColor"
>
<g id="icons" transform="translate(56.000000, 160.000000)">
<path
id="github-[#142]"
d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399"
/>
</g>
</g>
</g>
</svg>
</template>

View File

@ -1,10 +1,10 @@
<template>
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.53918 2.40715C4.82145 1.0075 6.06066 0 7.49996 0C8.93926 0 10.1785 1.0075 10.4607 2.40715L10.798 4.07944C10.9743 4.9539 11.3217 5.78562 11.8205 6.52763L12.4009 7.39103C12.7631 7.92978 12.9999 8.5385 13.0979 9.17323C13.6747 9.22167 14.1803 9.58851 14.398 10.1283L14.8897 11.3474C15.1376 11.962 14.9583 12.665 14.4455 13.0887L12.5614 14.6458C12.0128 15.0992 11.2219 15.1193 10.6506 14.6944L9.89192 14.1301C9.88189 14.1227 9.87197 14.1151 9.86216 14.1074C9.48973 14.2075 9.09793 14.261 8.69355 14.261H6.30637C5.90201 14.261 5.51023 14.2076 5.13782 14.1074C5.12802 14.1151 5.11811 14.1227 5.10808 14.1301L4.34942 14.6944C3.77811 15.1193 2.98725 15.0992 2.43863 14.6458L0.55446 13.0887C0.0417175 12.665 -0.1376 11.962 0.110281 11.3474L0.602025 10.1283C0.819715 9.58854 1.32527 9.2217 1.90198 9.17324C2 8.5385 2.2368 7.92978 2.59897 7.39103L3.17938 6.52763C3.67818 5.78562 4.02557 4.9539 4.20193 4.07944L4.53918 2.40715ZM10.8445 9.47585C10.6345 9.63293 10.4642 9.84382 10.3561 10.0938L9.58799 11.8713C9.20026 12.0979 8.75209 12.2237 8.28465 12.2237H6.7153C6.24789 12.2237 5.79975 12.0979 5.41203 11.8714L4.64386 10.0938C4.53581 9.8438 4.36552 9.6329 4.15546 9.47582C4.18121 9.15355 4.2689 8.83503 4.41853 8.53826L5.67678 6.04259L5.68433 6.05007C6.68715 7.04458 8.31304 7.04458 9.31585 6.05007L9.32324 6.04274L10.5814 8.53825C10.7311 8.83504 10.8187 9.15357 10.8445 9.47585ZM9.04068 4.26906V3.05592H8.01353V3.85713C8.23151 3.90123 8.44506 3.97371 8.64848 4.07458L9.04068 4.26906ZM6.98638 3.85718V3.05592H5.95923V4.26919L6.3517 4.07458C6.55504 3.97375 6.7685 3.90129 6.98638 3.85718ZM2.03255 10.1864C1.82255 10.1864 1.6337 10.3132 1.55571 10.5066L1.06397 11.7257C0.981339 11.9306 1.04111 12.1649 1.21203 12.3062L3.0962 13.8633C3.27907 14.0144 3.54269 14.0211 3.73313 13.8795L4.49179 13.3152C4.6813 13.1743 4.74901 12.923 4.6557 12.7071L3.69976 10.4951C3.61884 10.3078 3.43316 10.1864 3.22771 10.1864H2.03255ZM13.4443 10.5066C13.3663 10.3132 13.1775 10.1864 12.9674 10.1864H11.7723C11.5668 10.1864 11.3812 10.3078 11.3002 10.4951L10.3443 12.7071C10.251 12.923 10.3187 13.1743 10.5082 13.3152L11.2669 13.8795C11.4573 14.0211 11.7209 14.0144 11.9038 13.8633L13.788 12.3062C13.9589 12.1649 14.0187 11.9306 13.936 11.7257L13.4443 10.5066ZM6.81106 4.98568C7.24481 4.7706 7.75537 4.7706 8.18912 4.98568L8.68739 5.23275L8.58955 5.32978C7.98786 5.92649 7.01232 5.92649 6.41063 5.32978L6.31279 5.23275L6.81106 4.98568Z"
fill="currentColor"
/>
</svg>
</template>
<template>
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.53918 2.40715C4.82145 1.0075 6.06066 0 7.49996 0C8.93926 0 10.1785 1.0075 10.4607 2.40715L10.798 4.07944C10.9743 4.9539 11.3217 5.78562 11.8205 6.52763L12.4009 7.39103C12.7631 7.92978 12.9999 8.5385 13.0979 9.17323C13.6747 9.22167 14.1803 9.58851 14.398 10.1283L14.8897 11.3474C15.1376 11.962 14.9583 12.665 14.4455 13.0887L12.5614 14.6458C12.0128 15.0992 11.2219 15.1193 10.6506 14.6944L9.89192 14.1301C9.88189 14.1227 9.87197 14.1151 9.86216 14.1074C9.48973 14.2075 9.09793 14.261 8.69355 14.261H6.30637C5.90201 14.261 5.51023 14.2076 5.13782 14.1074C5.12802 14.1151 5.11811 14.1227 5.10808 14.1301L4.34942 14.6944C3.77811 15.1193 2.98725 15.0992 2.43863 14.6458L0.55446 13.0887C0.0417175 12.665 -0.1376 11.962 0.110281 11.3474L0.602025 10.1283C0.819715 9.58854 1.32527 9.2217 1.90198 9.17324C2 8.5385 2.2368 7.92978 2.59897 7.39103L3.17938 6.52763C3.67818 5.78562 4.02557 4.9539 4.20193 4.07944L4.53918 2.40715ZM10.8445 9.47585C10.6345 9.63293 10.4642 9.84382 10.3561 10.0938L9.58799 11.8713C9.20026 12.0979 8.75209 12.2237 8.28465 12.2237H6.7153C6.24789 12.2237 5.79975 12.0979 5.41203 11.8714L4.64386 10.0938C4.53581 9.8438 4.36552 9.6329 4.15546 9.47582C4.18121 9.15355 4.2689 8.83503 4.41853 8.53826L5.67678 6.04259L5.68433 6.05007C6.68715 7.04458 8.31304 7.04458 9.31585 6.05007L9.32324 6.04274L10.5814 8.53825C10.7311 8.83504 10.8187 9.15357 10.8445 9.47585ZM9.04068 4.26906V3.05592H8.01353V3.85713C8.23151 3.90123 8.44506 3.97371 8.64848 4.07458L9.04068 4.26906ZM6.98638 3.85718V3.05592H5.95923V4.26919L6.3517 4.07458C6.55504 3.97375 6.7685 3.90129 6.98638 3.85718ZM2.03255 10.1864C1.82255 10.1864 1.6337 10.3132 1.55571 10.5066L1.06397 11.7257C0.981339 11.9306 1.04111 12.1649 1.21203 12.3062L3.0962 13.8633C3.27907 14.0144 3.54269 14.0211 3.73313 13.8795L4.49179 13.3152C4.6813 13.1743 4.74901 12.923 4.6557 12.7071L3.69976 10.4951C3.61884 10.3078 3.43316 10.1864 3.22771 10.1864H2.03255ZM13.4443 10.5066C13.3663 10.3132 13.1775 10.1864 12.9674 10.1864H11.7723C11.5668 10.1864 11.3812 10.3078 11.3002 10.4951L10.3443 12.7071C10.251 12.923 10.3187 13.1743 10.5082 13.3152L11.2669 13.8795C11.4573 14.0211 11.7209 14.0144 11.9038 13.8633L13.788 12.3062C13.9589 12.1649 14.0187 11.9306 13.936 11.7257L13.4443 10.5066ZM6.81106 4.98568C7.24481 4.7706 7.75537 4.7706 8.18912 4.98568L8.68739 5.23275L8.58955 5.32978C7.98786 5.92649 7.01232 5.92649 6.41063 5.32978L6.31279 5.23275L6.81106 4.98568Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 814 1000"
>
<path
stroke="currentColor"
fill="currentColor"
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"
/>
</svg>
</template>

View File

@ -0,0 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M9.41 20H6.5c-1.5 0-2.82-.5-3.89-1.57C1.54 17.38 1 16.09 1 14.58q0-1.95 1.17-3.48a5.25 5.25 0 0 1 3.08-1.95c.42-1.53 1.25-2.77 2.5-3.72C9 4.5 10.42 4 12 4c1.95 0 3.61.68 4.96 2.04C18.32 7.39 19 9.05 19 11c1.15.13 2.11.63 2.86 1.5c.64.73 1 1.56 1.1 2.5H18a5.01 5.01 0 0 0-4-2c-2.8 0-5 2.2-5 5c0 .72.15 1.39.41 2M23 17v2h-2v2h-2v-2h-2.2c-.4 1.2-1.5 2-2.8 2c-1.7 0-3-1.3-3-3s1.3-3 3-3c1.3 0 2.4.8 2.8 2zm-8 1c0-.5-.4-1-1-1s-1 .5-1 1s.4 1 1 1s1-.5 1-1"
/>
</svg>
</template>

View File

@ -1,11 +1,11 @@
<template>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15 9H15.01M15 15C18.3137 15 21 12.3137 21 9C21 5.68629 18.3137 3 15 3C11.6863 3 9 5.68629 9 9C9 9.27368 9.01832 9.54308 9.05381 9.80704C9.11218 10.2412 9.14136 10.4583 9.12172 10.5956C9.10125 10.7387 9.0752 10.8157 9.00469 10.9419C8.937 11.063 8.81771 11.1823 8.57913 11.4209L3.46863 16.5314C3.29568 16.7043 3.2092 16.7908 3.14736 16.8917C3.09253 16.9812 3.05213 17.0787 3.02763 17.1808C3 17.2959 3 17.4182 3 17.6627V19.4C3 19.9601 3 20.2401 3.10899 20.454C3.20487 20.6422 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H6.33726C6.58185 21 6.70414 21 6.81923 20.9724C6.92127 20.9479 7.01881 20.9075 7.10828 20.8526C7.2092 20.7908 7.29568 20.7043 7.46863 20.5314L12.5791 15.4209C12.8177 15.1823 12.937 15.063 13.0581 14.9953C13.1843 14.9248 13.2613 14.8987 13.4044 14.8783C13.5417 14.8586 13.7588 14.8878 14.193 14.9462C14.4569 14.9817 14.7263 15 15 15Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<template>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15 9H15.01M15 15C18.3137 15 21 12.3137 21 9C21 5.68629 18.3137 3 15 3C11.6863 3 9 5.68629 9 9C9 9.27368 9.01832 9.54308 9.05381 9.80704C9.11218 10.2412 9.14136 10.4583 9.12172 10.5956C9.10125 10.7387 9.0752 10.8157 9.00469 10.9419C8.937 11.063 8.81771 11.1823 8.57913 11.4209L3.46863 16.5314C3.29568 16.7043 3.2092 16.7908 3.14736 16.8917C3.09253 16.9812 3.05213 17.0787 3.02763 17.1808C3 17.2959 3 17.4182 3 17.6627V19.4C3 19.9601 3 20.2401 3.10899 20.454C3.20487 20.6422 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H6.33726C6.58185 21 6.70414 21 6.81923 20.9724C6.92127 20.9479 7.01881 20.9075 7.10828 20.8526C7.2092 20.7908 7.29568 20.7043 7.46863 20.5314L12.5791 15.4209C12.8177 15.1823 12.937 15.063 13.0581 14.9953C13.1843 14.9248 13.2613 14.8987 13.4044 14.8783C13.5417 14.8586 13.7588 14.8878 14.193 14.9462C14.4569 14.9817 14.7263 15 15 15Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@ -1,12 +1,12 @@
<template>
<svg
fill="currentColor"
viewBox="0 0 1920 1920"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1863.53 1016.437c31.171 0 56.47 25.299 56.47 56.47v790.589c0 16.376-7.115 31.849-19.313 42.465-10.39 9.149-23.605 14.005-37.158 14.005-2.484 0-5.082-.113-7.567-.452l-903.53-123.331c-28.008-3.84-48.903-27.784-48.903-56.02v-667.256c0-31.171 25.3-56.47 56.471-56.47Zm-1129.412 0c31.171 0 56.47 25.299 56.47 56.47v634.504c0 16.376-7.115 31.85-19.426 42.579-10.39 9.035-23.491 13.891-37.044 13.891-2.485 0-5.196-.113-7.68-.564L48.79 1669.35C20.78 1665.51 0 1641.68 0 1613.444v-540.537c0-31.171 25.299-56.47 56.47-56.47Zm-7.726-859.855c16.151-2.372 32.415 2.597 44.725 13.327 12.424 10.73 19.426 26.315 19.426 42.579V846.99c0 31.285-25.186 56.47-56.47 56.47H56.424c-31.171 0-56.47-25.185-56.47-56.47V306.455c0-28.123 20.781-52.066 48.79-55.906ZM1855.974.474c16.15-2.033 32.414 2.71 44.724 13.44 12.198 10.73 19.313 26.203 19.313 42.466v790.588c0 31.285-25.299 56.471-56.47 56.471H960.01c-31.171 0-56.47-25.186-56.47-56.47V179.711c0-28.235 20.78-52.066 48.903-55.906Z"
fill-rule="evenodd"
/>
</svg>
</template>
<template>
<svg
fill="currentColor"
viewBox="0 0 1920 1920"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1863.53 1016.437c31.171 0 56.47 25.299 56.47 56.47v790.589c0 16.376-7.115 31.849-19.313 42.465-10.39 9.149-23.605 14.005-37.158 14.005-2.484 0-5.082-.113-7.567-.452l-903.53-123.331c-28.008-3.84-48.903-27.784-48.903-56.02v-667.256c0-31.171 25.3-56.47 56.471-56.47Zm-1129.412 0c31.171 0 56.47 25.299 56.47 56.47v634.504c0 16.376-7.115 31.85-19.426 42.579-10.39 9.035-23.491 13.891-37.044 13.891-2.485 0-5.196-.113-7.68-.564L48.79 1669.35C20.78 1665.51 0 1641.68 0 1613.444v-540.537c0-31.171 25.299-56.47 56.47-56.47Zm-7.726-859.855c16.151-2.372 32.415 2.597 44.725 13.327 12.424 10.73 19.426 26.315 19.426 42.579V846.99c0 31.285-25.186 56.47-56.47 56.47H56.424c-31.171 0-56.47-25.185-56.47-56.47V306.455c0-28.123 20.781-52.066 48.79-55.906ZM1855.974.474c16.15-2.033 32.414 2.71 44.724 13.44 12.198 10.73 19.313 26.203 19.313 42.466v790.588c0 31.285-25.299 56.471-56.47 56.471H960.01c-31.171 0-56.47-25.186-56.47-56.47V179.711c0-28.235 20.78-52.066 48.903-55.906Z"
fill-rule="evenodd"
/>
</svg>
</template>

View File

@ -0,0 +1,75 @@
<template>
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<Bars3Icon class="size-6" /> Library
</span>
<!-- Search bar -->
<div class="mt-5 relative">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
autocomplete="off"
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
placeholder="Search library..."
/>
<MagnifyingGlassIcon
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
aria-hidden="true"
/>
</div>
<TransitionGroup
v-if="filteredLibrary.length > 0"
name="list"
tag="ul"
role="list"
class="mt-2 space-y-0.5"
>
<li v-for="game in filteredLibrary" :key="game.id" class="flex">
<NuxtLink
:to="`/library/game/${game.id}`"
class="flex flex-row items-center w-full p-1 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
>
<img
:src="useObject(game.mCoverObjectId)"
class="h-9 w-9 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
alt=""
/>
<div class="min-w-0 flex-1 pl-2.5">
<p
class="text-sm font-semibold text-display text-zinc-200 truncate text-left"
>
{{ game.mName }}
</p>
</div>
</NuxtLink>
</li>
</TransitionGroup>
<p
v-else
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
>
{{ !!searchQuery ? "No results" : "No games in library" }}
</p>
</div>
</template>
<script setup lang="ts">
import { Bars3Icon, MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
const library = await useLibrary();
const searchQuery = ref("");
const filteredLibrary = computed(() =>
library.value.entries
.map((e) => e.game)
.filter((e) =>
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase()),
),
);
</script>

View File

@ -1,7 +0,0 @@
<template>
<svg class="text-blue-400" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="currentColor" stroke-width="2" />
</svg>
</template>

View File

@ -0,0 +1,409 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="w-full">
<!-- Create article button - only show for admin users -->
<button
v-if="user?.admin"
class="transition inline-flex w-full items-center px-4 gap-x-2 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 font-semibold text-sm shadow-sm"
@click="modalOpen = !modalOpen"
>
<PlusIcon
class="h-5 w-5 transition-transform duration-200"
:class="{ 'rotate-90': modalOpen }"
/>
<span>New article</span>
</button>
<ModalTemplate v-model="modalOpen" size-class="sm:max-w-[80vw]">
<h3 class="text-lg font-semibold text-zinc-100 mb-4">
Create New Article
</h3>
<form class="space-y-4" @submit.prevent="() => createArticle()">
<div>
<label for="title" class="block text-sm font-medium text-zinc-400"
>Title</label
>
<input
id="title"
v-model="newArticle.title"
type="text"
autocomplete="off"
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
required
/>
</div>
<div>
<label for="excerpt" class="block text-sm font-medium text-zinc-400"
>Short description</label
>
<input
id="excerpt"
v-model="newArticle.description"
type="text"
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
required
/>
</div>
<div>
<label for="content" class="block text-sm font-medium text-zinc-400"
>Content (Markdown)</label
>
<div class="mt-1 flex flex-col gap-4">
<!-- Markdown shortcuts -->
<div class="flex flex-wrap gap-2">
<button
v-for="shortcut in markdownShortcuts"
:key="shortcut.label"
type="button"
class="px-2 py-1 text-sm rounded bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors"
@click="applyMarkdown(shortcut)"
>
{{ shortcut.label }}
</button>
</div>
<div
class="grid grid-rows-2 sm:grid-cols-2 sm:grid-rows-1 gap-4 h-[400px]"
>
<!-- Editor -->
<div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">Editor</span>
<textarea
id="content"
ref="contentEditor"
v-model="newArticle.content"
class="flex-1 rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500 font-mono resize-none"
required
@keydown="handleContentKeydown"
/>
</div>
<!-- Preview -->
<div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">Preview</span>
<div
class="flex-1 p-4 rounded-md bg-zinc-900 border border-zinc-700 overflow-y-auto"
>
<div
class="prose prose-invert prose-sm h-full overflow-y-auto"
v-html="markdownPreview"
/>
</div>
</div>
</div>
</div>
<p class="mt-2 text-sm text-zinc-500">
Use the shortcuts above or write Markdown directly. Supports
**bold**, *italic*, [links](url), and more.
</p>
</div>
<div>
<label
for="file-upload"
class="group cursor-pointer transition relative block w-full rounded-lg border-2 border-dashed border-zinc-600 p-12 text-center hover:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2"
>
<ArrowUpTrayIcon
class="transition mx-auto h-6 w-6 text-zinc-600 group-hover:text-zinc-700"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
/>
<span
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>Upload cover image</span
>
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
{{ currentFile.name }}
</p>
</label>
<input
id="file-upload"
accept="image/*"
class="hidden"
type="file"
@change="(e) => (file = (e.target as any)?.files)"
/>
</div>
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2"
>Tags</label
>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="tag in newArticle.tags"
:key="tag"
class="inline-flex items-center gap-x-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-600/80 text-white"
>
{{ tag }}
<button
type="button"
class="text-white hover:text-white/80"
@click="removeTag(tag)"
>
<XMarkIcon class="h-3 w-3" />
</button>
</span>
</div>
<div class="flex gap-x-2">
<input
v-model="newTagInput"
type="text"
placeholder="Add a tag..."
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
@keydown.enter.prevent="addTag"
/>
<button
type="button"
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
@click="addTag"
>
Add
</button>
</div>
</div>
<button type="submit" class="hidden" />
<div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
<template #buttons>
<LoadingButton
:loading="loading"
class="bg-blue-600 text-white hover:bg-blue-500"
@click="() => createArticle()"
>
Submit
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (modalOpen = !modalOpen)"
>
Cancel
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
import {
ArrowUpTrayIcon,
PlusIcon,
XCircleIcon,
XMarkIcon,
} from "@heroicons/vue/24/solid";
import { micromark } from "micromark";
const news = useNews();
if (!news.value) {
news.value = await fetchNews();
}
const user = useUser();
const modalOpen = ref(false);
const loading = ref(false);
const newTagInput = ref("");
const newArticle = ref({
title: "",
description: "",
content: "",
tags: [] as string[],
});
const markdownPreview = computed(() => {
// TODO: maybe?? add https://github.com/cure53/DOMPurify
// micromark says its safe, but this is straight html we are injecting
return micromark(newArticle.value.content);
});
const file = ref<FileList | undefined>();
const currentFile = computed(() => file.value?.item(0));
const error = ref<string | undefined>();
const contentEditor = ref<HTMLTextAreaElement>();
const markdownShortcuts = [
{ label: "Bold", prefix: "**", suffix: "**", placeholder: "bold text" },
{ label: "Italic", prefix: "_", suffix: "_", placeholder: "italic text" },
{ label: "Link", prefix: "[", suffix: "](url)", placeholder: "link text" },
{ label: "Code", prefix: "`", suffix: "`", placeholder: "code" },
{ label: "List Item", prefix: "- ", suffix: "", placeholder: "list item" },
{ label: "Heading", prefix: "## ", suffix: "", placeholder: "heading" },
];
function handleContentKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
const textarea = contentEditor.value;
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
const currentLine = text.slice(lineStart, start);
// Check if the current line starts with a list marker
const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/);
let insertion = "\n";
if (listMatch) {
// If the line is empty except for the list marker, end the list
if (currentLine.trim() === listMatch[0].trim()) {
const removeLength = currentLine.length;
newArticle.value.content =
text.slice(0, lineStart) + text.slice(lineStart + removeLength);
// Move cursor to new position after removing the list marker
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = lineStart;
});
return;
}
// Otherwise, continue the list
insertion = "\n" + listMatch[1] + listMatch[2] + " ";
}
newArticle.value.content =
text.slice(0, start) + insertion + text.slice(start);
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd =
start + insertion.length;
});
}
}
function addTag() {
const tag = newTagInput.value.trim();
if (tag && !newArticle.value.tags.includes(tag)) {
newArticle.value.tags.push(tag);
newTagInput.value = ""; // Clear the input
}
}
function removeTag(tagToRemove: string) {
newArticle.value.tags = newArticle.value.tags.filter(
(tag) => tag !== tagToRemove,
);
}
function applyMarkdown(shortcut: (typeof markdownShortcuts)[0]) {
const textarea = contentEditor.value;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
const replacement = selectedText || shortcut.placeholder;
const newText =
text.substring(0, start) +
shortcut.prefix +
replacement +
shortcut.suffix +
text.substring(end);
newArticle.value.content = newText;
nextTick(() => {
textarea.focus();
const newStart = start + shortcut.prefix.length;
const newEnd = newStart + replacement.length;
textarea.setSelectionRange(newStart, newEnd);
});
}
async function createArticle() {
if (!user.value) return;
loading.value = true;
try {
const formData = new FormData();
if (currentFile.value) {
formData.append("image", currentFile.value);
}
formData.append("title", newArticle.value.title);
formData.append("description", newArticle.value.description);
formData.append("content", newArticle.value.content);
formData.append("tags", JSON.stringify(newArticle.value.tags));
const createdArticle = await $dropFetch("/api/v1/admin/news", {
method: "POST",
body: formData,
});
news.value?.push(createdArticle);
// Reset form
newArticle.value = {
title: "",
description: "",
content: "",
tags: [],
};
modalOpen.value = false;
} catch (e) {
// @ts-expect-error attempt to get statusMessage on error
error.value = e?.statusMessage ?? "An unknown error occured.";
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.prose {
max-width: none;
}
.prose a {
color: #60a5fa;
text-decoration: none;
}
.prose a:hover {
text-decoration: underline;
}
.prose img {
border-radius: 0.5rem;
}
.prose code {
background: #27272a;
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-size: 0.875em;
}
.prose pre {
background: #18181b;
padding: 1em;
border-radius: 0.5rem;
}
</style>

View File

@ -0,0 +1,221 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-900 px-6 py-6 ring-1 ring-white/10"
>
<!-- Search and filters -->
<div class="space-y-6">
<div>
<label for="search" class="sr-only">Search articles</label>
<div class="relative">
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<MagnifyingGlassIcon
class="h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
</div>
<input
id="search"
v-model="searchQuery"
type="text"
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
placeholder="Search articles..."
/>
</div>
</div>
<div class="pt-2">
<label for="date" class="block text-sm font-medium text-zinc-400 mb-2"
>Date</label
>
<select
id="date"
v-model="dateFilter"
class="mt-1 block w-full rounded-md border-0 bg-zinc-800 py-2 pl-3 pr-10 text-zinc-100 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
>
<option value="all">All time</option>
<option value="today">Today</option>
<option value="week">This week</option>
<option value="month">This month</option>
<option value="year">This year</option>
</select>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2">Tags</label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in availableTags"
:key="tag"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors duration-200"
:class="[
selectedTags.includes(tag)
? 'bg-blue-600 text-white'
: 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700',
]"
@click="toggleTag(tag)"
>
{{ tag }}
</button>
</div>
</div>
</div>
<nav class="flex-1 space-y-2">
<NuxtLink
v-for="article in filteredArticles"
:key="article.id"
:to="`/news/${article.id}`"
class="group block rounded-lg hover-lift"
>
<div
class="relative flex flex-col gap-y-2 rounded-lg p-3 transition-all duration-200"
:class="[
route.params.id === article.id
? 'bg-zinc-800'
: 'hover:bg-zinc-800/50',
]"
>
<div
v-if="article.imageObjectId"
class="absolute inset-0 rounded-lg transition-all duration-200 overflow-hidden"
>
<img
:src="useObject(article.imageObjectId)"
class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110"
/>
<div
class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-800 transition-all duration-200"
/>
</div>
<h3 class="relative text-sm font-medium text-zinc-100">
{{ article.title }}
</h3>
<p
class="relative mt-1 text-xs text-zinc-400 line-clamp-2"
v-html="formatExcerpt(article.description)"
/>
<div
class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"
>
<time :datetime="article.publishedAt">{{
formatDate(article.publishedAt)
}}</time>
</div>
</div>
</NuxtLink>
</nav>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
import { micromark } from "micromark";
const news = useNews();
if (!news.value) {
news.value = await fetchNews();
}
const route = useRoute();
const searchQuery = ref("");
const dateFilter = ref("all");
const selectedTags = ref<string[]>([]);
// Get unique tags from all articles
const availableTags = computed(() => {
if (!news.value) return [];
const tags = new Set<string>();
news.value.forEach((article) => {
article.tags.forEach((tag) => tags.add(tag.name));
});
return Array.from(tags);
});
const toggleTag = (tag: string) => {
const index = selectedTags.value.indexOf(tag);
if (index === -1) {
selectedTags.value.push(tag);
} else {
selectedTags.value.splice(index, 1);
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const formatExcerpt = (excerpt: string) => {
// TODO: same as one in NewsArticleCreateButton
// Convert markdown to HTML
const html = micromark(excerpt);
// Strip HTML tags using regex
return html.replace(/<[^>]*>/g, "");
};
const filteredArticles = computed(() => {
if (!news.value) return [];
// filter articles based on search, date, and tags
return news.value.filter((article) => {
const matchesSearch =
article.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
article.description
.toLowerCase()
.includes(searchQuery.value.toLowerCase());
const articleDate = new Date(article.publishedAt);
const now = new Date();
let matchesDate = true;
switch (dateFilter.value.toLowerCase()) {
case "today": {
matchesDate = articleDate.toDateString() === now.toDateString();
break;
}
case "week": {
const weekAgo = new Date(now.setDate(now.getDate() - 7));
matchesDate = articleDate >= weekAgo;
break;
}
case "month": {
matchesDate =
articleDate.getMonth() === now.getMonth() &&
articleDate.getFullYear() === now.getFullYear();
break;
}
case "year": {
matchesDate = articleDate.getFullYear() === now.getFullYear();
break;
}
}
const matchesTags =
selectedTags.value.length === 0 ||
selectedTags.value.every((tag) =>
article.tags.find((e) => e.name == tag),
);
return matchesSearch && matchesDate && matchesTags;
});
});
</script>
<style scoped>
.hover-lift {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.hover-lift:hover {
transform: translateY(-2px) scale(1.02);
}
</style>

View File

@ -13,20 +13,25 @@
v-if="notification.actions.length > 0"
class="mt-3 flex space-x-7"
>
<button
<NuxtLink
v-for="[name, link] in notification.actions.map((e) =>
e.split('|'),
)"
:key="name"
type="button"
class="rounded-md bg-white text-sm font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
:href="link"
class="rounded-md text-sm font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Undo
</button>
{{ name }}
</NuxtLink>
<!-- todo -->
</div>
</div>
<div class="ml-4 flex shrink-0">
<button
@click="() => deleteMe()"
type="button"
class="inline-flex rounded-md text-zinc-400 hover:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="() => deleteMe()"
>
<span class="sr-only">Close</span>
<XMarkIcon class="size-5" aria-hidden="true" />
@ -39,17 +44,17 @@
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/solid";
import type { Notification } from "@prisma/client";
import type { Notification } from "~/prisma/client";
const props = defineProps<{ notification: Notification }>();
async function deleteMe() {
await $fetch(`/api/v1/notifications/${props.notification.id}`, {
await $dropFetch(`/api/v1/notifications/${props.notification.id}`, {
method: "DELETE",
});
const notifications = useNotifications();
const indexOfMe = notifications.value.findIndex(
(e) => e.id === props.notification.id
(e) => e.id === props.notification.id,
);
// Delete me
notifications.value.splice(indexOfMe, 1);

View File

@ -1,5 +1,5 @@
<template>
<div class="flex rounded px-2 py-2 bg-zinc-900 text-zinc-600">
<slot />
</div>
</template>
<div class="flex rounded px-2 py-2 bg-zinc-900 text-zinc-600">
<slot />
</div>
</template>

View File

@ -1,5 +1,5 @@
<template>
<Listbox as="div" v-model="model">
<Listbox v-model="typedModel" as="div">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
><slot
/></ListboxLabel>
@ -7,13 +7,13 @@
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6"
>
<span v-if="model && values[model]" class="flex items-center">
<span v-if="model" class="flex items-center">
<component
:is="values[model].icon"
:is="PLATFORM_ICONS[model]"
alt=""
class="h-5 w-5 flex-shrink-0 text-blue-600"
/>
<span class="ml-3 block truncate">{{ values[model].name }}</span>
<span class="ml-3 block truncate">{{ model }}</span>
</span>
<span v-else>Please select a platform...</span>
<span
@ -32,11 +32,11 @@
class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-950 ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="[value, options] in Object.entries(values)"
v-for="[name, value] in Object.entries(values)"
:key="value"
:value="value"
v-slot="{ active, selected }"
as="template"
:value="value"
>
<li
:class="[
@ -46,14 +46,14 @@
>
<div class="flex items-center">
<component
:is="options.icon"
:is="PLATFORM_ICONS[value]"
alt=""
:class="[
active ? 'text-zinc-100' : 'text-blue-600',
'h-5 w-5 flex-shrink-0',
]"
/>
<span class="ml-3 block truncate">{{ options.name }}</span>
<span class="ml-3 block truncate">{{ name }}</span>
</div>
<span
@ -74,7 +74,6 @@
</template>
<script setup lang="ts">
import { IconsLinuxLogo, IconsWindowsLogo } from "#components";
import {
Listbox,
ListboxButton,
@ -83,18 +82,18 @@ import {
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import type { Component } from "vue";
const model = defineModel<string>();
const model = defineModel<PlatformClient | undefined>();
const values: { [key: string]: { name: string; icon: Component } } = {
Linux: {
name: "Linux",
icon: IconsLinuxLogo,
const typedModel = computed<PlatformClient | null>({
get() {
return model.value || null;
},
Windows: {
name: "Windows",
icon: IconsWindowsLogo,
set(v) {
if (v === null) return (model.value = undefined);
model.value = v;
},
};
});
const values = Object.fromEntries(Object.entries(PlatformClient));
</script>

View File

@ -0,0 +1,19 @@
<template>
<div
:class="[
'rounded-lg w-48 h-64 bg-zinc-800/50 flex items-center justify-center transition-all duration-300 hover:bg-zinc-800',
props.loading && 'animate-pulse',
]"
>
<p class="text-zinc-700 text-sm font-semibold font-display uppercase">
{{ props.message }}
</p>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
message?: string;
loading?: boolean;
}>();
</script>

View File

@ -51,16 +51,16 @@
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>Upload file</span
>
<p class="mt-1 text-xs text-zinc-400" v-if="currentFile">
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
{{ currentFile.name }}
</p>
</label>
<input
id="file-upload"
:accept="props.accept"
@change="(e) => file = (e.target as any)?.files"
class="hidden"
type="file"
id="file-upload"
@change="(e) => (file = (e.target as any)?.files)"
/>
</div>
</div>
@ -70,18 +70,16 @@
:disabled="currentFile == undefined"
type="button"
:loading="uploadLoading"
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => uploadFile_wrapper()"
:class="[
'inline-flex w-full shadow-sm sm:ml-3 sm:w-auto',
]"
>
Upload
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="open = false"
ref="cancelButtonRef"
>
Cancel
</button>
@ -114,14 +112,15 @@ import { ref } from "vue";
import {
Dialog,
DialogPanel,
DialogTitle,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { ArrowUpTrayIcon } from "@heroicons/vue/20/solid";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const open = defineModel<boolean>();
const open = defineModel<boolean>({
required: true,
});
const file = ref<FileList | undefined>();
const currentFile = computed(() => file.value?.item(0));
@ -146,7 +145,10 @@ async function uploadFile() {
}
}
const result = await $fetch(props.endpoint, { method: "POST", body: form });
const result = await $dropFetch(props.endpoint, {
method: "POST",
body: form,
});
open.value = false;
file.value = undefined;
emit("upload", result);

View File

@ -1,103 +1,132 @@
<template>
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
<h2 id="footer-heading" class="sr-only">Footer</h2>
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 ">
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div class="space-y-8">
<Wordmark class="h-10" />
<p class="text-sm leading-6 text-zinc-300">An open-source game distribution platform built for
speed, flexibility and beauty.</p>
<div class="flex space-x-6">
<a v-for="item in navigation.social" :key="item.name" :href="item.href" target="_blank"
class="text-zinc-400 hover:text-zinc-400">
<span class="sr-only">{{ item.name }}</span>
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
</a>
</div>
</div>
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.games" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
item.name }}</a>
</li>
</ul>
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">Community</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.community" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
item.name }}</a>
</li>
</ul>
</div>
</div>
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">Documentation</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.documentation" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
item.name }}</a>
</li>
</ul>
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.about" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
item.name }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
<h2 id="footer-heading" class="sr-only">Footer</h2>
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div class="space-y-8">
<DropWordmark class="h-10" />
<p class="text-sm leading-6 text-zinc-300">
An open-source game distribution platform built for speed,
flexibility and beauty.
</p>
<div class="flex space-x-6">
<NuxtLink
v-for="item in navigation.social"
:key="item.name"
:to="item.href"
target="_blank"
class="text-zinc-400 hover:text-zinc-400"
>
<span class="sr-only">{{ item.name }}</span>
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
</NuxtLink>
</div>
</div>
</footer>
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.games" :key="item.name">
<NuxtLink
:to="item.href"
class="text-sm leading-6 text-zinc-300 hover:text-white"
>{{ item.name }}</NuxtLink
>
</li>
</ul>
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">
Community
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.community" :key="item.name">
<NuxtLink
:to="item.href"
class="text-sm leading-6 text-zinc-300 hover:text-white"
>{{ item.name }}</NuxtLink
>
</li>
</ul>
</div>
</div>
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">
Documentation
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.documentation" :key="item.name">
<NuxtLink
:to="item.href"
class="text-sm leading-6 text-zinc-300 hover:text-white"
>{{ item.name }}</NuxtLink
>
</li>
</ul>
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.about" :key="item.name">
<NuxtLink
:to="item.href"
class="text-sm leading-6 text-zinc-300 hover:text-white"
>{{ item.name }}</NuxtLink
>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import { IconsDiscordLogo, IconsGithubLogo } from '#components';
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
const navigation = {
games: [
{ name: 'Newly Added', href: '#' },
{ name: 'New Releases', href: '#' },
{ name: 'Top Sellers', href: '#' },
{ name: 'Find a Game', href: '#' },
],
community: [
{ name: 'Friends', href: '#' },
{ name: 'Groups', href: '#' },
{ name: 'Servers', href: '#' },
],
documentation: [
{ name: 'API', href: '#' },
{ name: 'Server Docs', href: '#' },
{ name: 'Client Docs', href: '#' },
],
about: [
{ name: 'About Drop', href: '#' },
{ name: 'Features', href: '#' },
{ name: 'FAQ', href: '#' },
],
social: [
{
name: 'GitHub',
href: 'https://github.com/Drop-OSS',
icon: IconsGithubLogo,
},
{
name: "Discord",
href: "https://discord.gg/NHx46XKJWA",
icon: IconsDiscordLogo
}
],
}
</script>
games: [
{ name: "Newly Added", href: "#" },
{ name: "New Releases", href: "#" },
{ name: "Top Sellers", href: "#" },
{ name: "Find a Game", href: "#" },
],
community: [
{ name: "Friends", href: "#" },
{ name: "Groups", href: "#" },
{ name: "Servers", href: "#" },
],
documentation: [
{ name: "API", href: "https://api.droposs.org/" },
{
name: "Server Docs",
href: "https://wiki.droposs.org/guides/quickstart.html",
},
{
name: "Client Docs",
href: "https://wiki.droposs.org/guides/client.html",
},
],
about: [
{ name: "About Drop", href: "https://droposs.org/" },
{ name: "Features", href: "https://droposs.org/features" },
{ name: "FAQ", href: "https://droposs.org/faq" },
],
social: [
{
name: "GitHub",
href: "https://github.com/Drop-OSS",
icon: IconsGithubLogo,
},
{
name: "Discord",
href: "https://discord.gg/NHx46XKJWA",
icon: IconsDiscordLogo,
},
],
};
</script>

View File

@ -1,13 +1,14 @@
<template>
<div class="hidden lg:flex bg-zinc-950 flex-row px-12 xl:px-48 py-5">
<div class="grow inline-flex items-center gap-x-20">
<NuxtLink to="/">
<Wordmark class="h-8" />
<NuxtLink :to="homepageURL">
<DropWordmark class="h-8" />
</NuxtLink>
<nav class="inline-flex items-center">
<ol class="inline-flex items-center gap-x-12">
<NuxtLink
v-for="(nav, navIdx) in navigation"
:key="navIdx"
:href="nav.route"
:class="[
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
@ -61,7 +62,10 @@
<div
class="sticky lg:hidden top-0 z-40 flex h-16 justify-between items-center gap-x-4 border-b border-zinc-700 bg-zinc-950 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
>
<Wordmark class="mb-0.5" />
<NuxtLink :to="homepageURL">
<DropWordmark class="mb-0.5" />
</NuxtLink>
<div class="flex gap-x-4 lg:gap-x-6">
<div class="flex items-center gap-x-3">
<!-- Profile dropdown -->
@ -131,8 +135,8 @@
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-950 px-6 pb-4"
>
<div class="flex shrink-0 h-16 items-center justify-between">
<NuxtLink to="/">
<Logo class="h-8 w-auto" />
<NuxtLink :to="homepageURL">
<DropLogo class="h-8 w-auto" />
</NuxtLink>
<UserHeaderUserWidget />
@ -141,6 +145,7 @@
<ol class="flex flex-col gap-y-3">
<NuxtLink
v-for="(nav, navIdx) in navigation"
:key="navIdx"
:href="nav.route"
:class="[
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
@ -194,6 +199,7 @@ import { XMarkIcon } from "@heroicons/vue/24/solid";
const router = useRouter();
const homepageURL = "/store";
const navigation: Array<NavigationItem> = [
{
prefix: "/store",
@ -221,7 +227,7 @@ const currentPageIndex = useCurrentNavigationIndex(navigation);
const notifications = useNotifications();
const unreadNotifications = computed(() =>
notifications.value.filter((e) => !e.read)
notifications.value.filter((e) => !e.read),
);
const sidebarOpen = ref(false);

View File

@ -22,8 +22,9 @@
</div>
<div class="flex flex-col gap-y-2 max-h-[300px] overflow-y-scroll">
<Notification
<NotificationItem
v-for="notification in props.notifications"
:key="notification.id"
:notification="notification"
/>
</div>
@ -37,7 +38,7 @@
</template>
<script setup lang="ts">
import type { Notification } from "@prisma/client";
import type { Notification } from "~/prisma/client";
const props = defineProps<{ notifications: Array<Notification> }>();
</script>

View File

@ -4,7 +4,7 @@
<UserHeaderWidget>
<div class="inline-flex items-center text-zinc-300 hover:text-white">
<img
:src="useObject(user.profilePicture)"
:src="useObject(user.profilePictureObjectId)"
class="w-5 h-5 rounded-sm"
/>
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
@ -31,7 +31,7 @@
>
<div class="inline-flex items-center text-zinc-300">
<img
:src="useObject(user.profilePicture)"
:src="useObject(user.profilePictureObjectId)"
class="w-5 h-5 rounded-sm"
/>
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
@ -39,16 +39,36 @@
</NuxtLink>
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
<div class="flex flex-col">
<MenuItem v-for="(nav, navIdx) in navigation" v-slot="{ active }">
<NuxtLink
<MenuItem
v-for="(nav, navIdx) in navigation"
:key="navIdx"
v-slot="{ active, close }"
hydrate-on-visible
as="div"
>
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
<button
:href="nav.route"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'transition block px-4 py-2 text-sm',
'w-full text-left transition block px-4 py-2 text-sm',
]"
@click="() => navigateTo(nav.route, close)"
>
{{ nav.label }}</NuxtLink
{{ nav.label }}
</button>
</MenuItem>
<MenuItem v-slot="{ active }" hydrate-on-visible as="div">
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
<a
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'w-full text-left transition block px-4 py-2 text-sm',
]"
href="/auth/signout"
>
Signout
</a>
</MenuItem>
</div>
</PanelWidget>
@ -78,10 +98,5 @@ const navigation: NavigationItem[] = [
route: "/account",
prefix: "",
},
{
label: "Sign out",
route: "/signout",
prefix: "",
},
].filter((e) => e !== undefined);
</script>

View File

@ -1,11 +0,0 @@
<template>
<div class="inline-flex justify-center items-center gap-x-1 -mb-1 relative">
<svg aria-hidden="true" viewBox="0 0 418 42" class="absolute inset-0 h-full w-full fill-blue-300/30 scale-75"
preserveAspectRatio="none">
<path
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
</svg>
<Logo class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase">Drop</span>
</div>
</template>

View File

@ -0,0 +1,44 @@
import type { Collection, CollectionEntry, Game } from "~/prisma/client";
import type { SerializeObject } from "nitropack";
type FullCollection = Collection & {
entries: Array<CollectionEntry & { game: SerializeObject<Game> }>;
};
export const useCollections = async () => {
// @ts-expect-error undefined is used to tell if value has been fetched or not
const state = useState<FullCollection[]>("collections", () => undefined);
if (state.value === undefined) {
state.value = await $dropFetch<FullCollection[]>("/api/v1/collection");
}
return state;
};
export async function refreshCollection(id: string) {
const state = useState<FullCollection[]>("collections");
const collection = await $dropFetch<FullCollection>(
`/api/v1/collection/${id}`,
);
const index = state.value.findIndex((e) => e.id == id);
if (index == -1) {
state.value.push(collection);
return;
}
state.value[index] = collection;
}
export const useLibrary = async () => {
// @ts-expect-error undefined is used to tell if value has been fetched or not
const state = useState<FullCollection>("library", () => undefined);
if (state.value === undefined) {
await refreshLibrary();
}
return state;
};
export async function refreshLibrary() {
const state = useState<FullCollection>("library");
state.value = await $dropFetch<FullCollection>("/api/v1/collection/default");
}

View File

@ -1,7 +1,9 @@
import type { RouteLocationNormalized } from "vue-router";
import type { NavigationItem } from "./types";
export const useCurrentNavigationIndex = (navigation: Array<NavigationItem>) => {
export const useCurrentNavigationIndex = (
navigation: Array<NavigationItem>,
) => {
const router = useRouter();
const route = useRoute();

View File

@ -1,6 +1,8 @@
import { IconsLinuxLogo, IconsWindowsLogo } from "#components";
import { IconsLinuxLogo, IconsWindowsLogo, IconsMacLogo } from "#components";
import { PlatformClient } from "./types";
export const PLATFORM_ICONS = {
[PlatformClient.Linux]: IconsLinuxLogo,
[PlatformClient.Windows]: IconsWindowsLogo,
[PlatformClient.macOS]: IconsMacLogo,
};

41
composables/news.ts Normal file
View File

@ -0,0 +1,41 @@
import type { Article } from "~/prisma/client";
import type { SerializeObject } from "nitropack";
export const useNews = () =>
useState<
| Array<
SerializeObject<
Article & {
tags: Array<{ id: string; name: string }>;
author: { displayName: string; id: string } | null;
}
>
>
| undefined
>("news", () => undefined);
export const fetchNews = async (options?: {
limit?: number;
skip?: number;
orderBy?: "asc" | "desc";
tags?: string[];
search?: string;
}) => {
const query = new URLSearchParams();
if (options?.limit) query.set("limit", options.limit.toString());
if (options?.skip) query.set("skip", options.skip.toString());
if (options?.orderBy) query.set("order", options.orderBy);
if (options?.tags?.length) query.set("tags", options.tags.join(","));
if (options?.search) query.set("search", options.search);
const news = useNews();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore forget why this ignor exists
const newValue = await $dropFetch(`/api/v1/news?${query.toString()}`);
news.value = newValue;
return newValue;
};

View File

@ -1,4 +1,4 @@
import type { Notification } from "@prisma/client";
import type { Notification } from "~/prisma/client";
const ws = new WebSocketHandler("/api/v1/notifications/ws");

55
composables/request.ts Normal file
View File

@ -0,0 +1,55 @@
import type {
ExtractedRouteMethod,
NitroFetchOptions,
NitroFetchRequest,
TypedInternalResponse,
} from "nitropack/types";
interface DropFetch<
DefaultT = unknown,
DefaultR extends NitroFetchRequest = NitroFetchRequest,
> {
<
T = DefaultT,
R extends NitroFetchRequest = DefaultR,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>(
request: R,
opts?: O,
): Promise<
// sometimes there is an error, other times there isn't
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
TypedInternalResponse<
R,
T,
NitroFetchOptions<R> extends O ? "get" : ExtractedRouteMethod<R, O>
>
>;
}
export const $dropFetch: DropFetch = async (request, opts) => {
if (!getCurrentInstance()?.proxy) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts);
}
const id = request.toString();
const state = useState(id);
if (state.value) {
// Deep copy
const object = JSON.parse(JSON.stringify(state.value));
// Never use again on client
state.value = undefined;
return object;
}
const headers = useRequestHeaders(["cookie", "authorization"]);
const data = await $fetch(request, {
...opts,
headers: { ...opts?.headers, ...headers },
});
if (import.meta.server) state.value = data;
return data;
};

View File

@ -2,11 +2,12 @@ import type { TaskMessage } from "~/server/internal/tasks";
import { WebSocketHandler } from "./ws";
const websocketHandler = new WebSocketHandler("/api/v1/task");
const taskStates: { [key: string]: Ref<TaskMessage | undefined> } = {};
// const taskStates: { [key: string]: } = {};
const taskStates = new Map<string, Ref<TaskMessage | undefined>>();
function handleUpdateMessage(msg: TaskMessage) {
const taskStates = useTaskStates();
const state = taskStates[msg.id];
const state = taskStates.get(msg.id);
if (!state) return;
if (!state.value || msg.reset) {
state.value = msg;
@ -29,18 +30,22 @@ websocketHandler.listen((message) => {
const [action, ...data] = message.split("/");
switch (action) {
case "connect":
case "connect": {
const taskReady = useTaskReady();
taskReady.value = true;
break;
case "disconnect":
}
case "disconnect": {
const disconnectTaskId = data[0];
delete taskStates[disconnectTaskId];
taskStates.delete(disconnectTaskId);
console.log(`disconnected from ${disconnectTaskId}`);
break;
case "error":
}
case "error": {
const [taskId, title, description] = data;
taskStates[taskId].value ??= {
const state = taskStates.get(taskId);
if (!state) break;
state.value ??= {
id: taskId,
name: "Unknown task",
success: false,
@ -48,8 +53,9 @@ websocketHandler.listen((message) => {
error: undefined,
log: [],
};
taskStates[taskId].value.error = { title, description };
state.value.error = { title, description };
break;
}
}
}
});
@ -61,15 +67,12 @@ export const useTaskReady = () => useState("taskready", () => false);
export const useTask = (taskId: string): Ref<TaskMessage | undefined> => {
if (import.meta.server) return ref(undefined);
const taskStates = useTaskStates();
if (
taskStates[taskId] &&
taskStates[taskId].value &&
!taskStates[taskId].value.error
)
return taskStates[taskId];
const task = taskStates.get(taskId);
if (task && task.value && !task.value.error) return task;
taskStates[taskId] = ref(undefined);
taskStates.set(taskId, ref(undefined));
console.log("connecting to " + taskId);
websocketHandler.send(`connect/${taskId}`);
return taskStates[taskId];
// TODO: this may have changed behavior
return taskStates.get(taskId) ?? ref(undefined);
};

View File

@ -15,4 +15,5 @@ export type QuickActionNav = {
export enum PlatformClient {
Windows = "Windows",
Linux = "Linux",
macOS = "macOS",
}

View File

@ -1,4 +1,4 @@
import type { User } from "@prisma/client";
import type { User } from "~/prisma/client";
// undefined = haven't check
// null = check, no user
@ -6,11 +6,8 @@ import type { User } from "@prisma/client";
export const useUser = () => useState<User | undefined | null>(undefined);
export const updateUser = async () => {
const headers = useRequestHeaders(["cookie"]);
const user = useUser();
if (user.value === null) return;
// SSR calls have to be after uses
user.value = await $fetch<User | null>("/api/v1/user", { headers });
user.value = await $dropFetch<User | null>("/api/v1/user");
};

View File

@ -30,7 +30,7 @@ export class WebSocketHandler {
this.ws.onmessage = (e) => {
const message = e.data;
switch (message) {
case "unauthenticated":
case "unauthenticated": {
const error = createError({
statusCode: 403,
statusMessage: "Unable to connect to websocket - unauthenticated",
@ -40,6 +40,7 @@ export class WebSocketHandler {
} else {
throw error;
}
}
}
if (this.listeners.length == 0) {
this.inQueue.push(message);

View File

@ -9,4 +9,4 @@ services:
environment:
- POSTGRES_PASSWORD=drop
- POSTGRES_USER=drop
- POSTGRES_DB=drop
- POSTGRES_DB=drop

View File

@ -1 +0,0 @@
These docs are automatically compiled in the Drop UI and are designed for admins and users to understand how Drop works.

View File

@ -1,3 +0,0 @@
# Home
This page is intentionally left blank, as it should be replaced with a custom home page.

View File

@ -1,23 +0,0 @@
# API
All Drop components communicate through HTTP-based APIs. However, due to the different use-cases they differ in how they are used.
## Frontend APIs
Frontend APIs run on the server, and are found under `/api/v1/`. They are used to render the web frontend, and are focused around user-based control of Drop systems.
For example, frontend APIs are responsible for uploading profile pictures, customizing your profile and adding friends.
The frontend, however, does not have access to some Drop features, namely downloading content. That feature is reserved for the client APIs, where it is actually used.
## Client APIs
Client APIs run on the server, and are found under `/api/v1/client/`. They are used by Drop clients (namely the desktop client) to manage, download and communicate with other Drop clients. They have a very specific feature set, and are limited in how they can change user profiles.
For example, client APIs have the ability to download content, setup P2P connections and report game activity. However, they do not have access to user profile management or administrator controls.
## P2P APIs
P2P APIs run on Drop clients, and are found at the root of the HTTP server. They are used by other Drop clients to download content and negotiate P2P features. They use mTLS authentication as a lightweight and efficient way to do peer to peer authentication.
For example, P2P APIs would be used to negotiate a Wireguard tunnel to do Remote LAN play.

View File

@ -1,11 +0,0 @@
# Clients
Drop clients connected to a given Drop server can access:
- Game content and files (to download)
- User data
- Game metadata and images
- Information about other clients connected to the same Drop server
**It is important that you trust the client that you grant access to your Drop account.**

View File

@ -1,5 +0,0 @@
# About
This section is about what Drop does, and how it works. For users interested in the inner workings of Drop, this will go through many of the design decisions, why they were made and more!
For users that don't care how Drop works, and want help using Drop, look under other sections. This section is purely technical and theoretical.

View File

@ -2,7 +2,10 @@
import type { NuxtError } from "#app";
const props = defineProps({
error: Object as () => NuxtError,
error: {
type: Object as () => NuxtError,
default: () => ({}),
},
});
const route = useRoute();
@ -16,7 +19,7 @@ const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
async function signIn() {
clearError({
redirect: `/signin?redirect=${encodeURIComponent(route.fullPath)}`,
redirect: `/auth/signin?redirect=${encodeURIComponent(route.fullPath)}`,
});
}
@ -24,7 +27,9 @@ useHead({
title: `${statusCode ?? message} | Drop`,
});
console.log(props.error);
if (import.meta.client) {
console.log(props.error);
}
</script>
<template>
@ -34,7 +39,7 @@ console.log(props.error);
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<Logo class="h-10 w-auto sm:h-12" />
<DropLogo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
@ -48,7 +53,10 @@ console.log(props.error);
>
Oh no!
</h1>
<p v-if="message" class="mt-3 font-bold text-base leading-7 text-red-500">
<p
v-if="message"
class="mt-3 font-bold text-base leading-7 text-red-500"
>
{{ message }}
</p>
<p class="mt-6 text-base leading-7 text-zinc-400">
@ -58,16 +66,16 @@ console.log(props.error);
</p>
<div class="mt-10">
<!-- full app reload to fix errors -->
<a
<NuxtLink
v-if="user && !showSignIn"
href="/"
to="/"
class="text-sm font-semibold leading-7 text-blue-600"
><span aria-hidden="true">&larr;</span> Back to home</a
><span aria-hidden="true">&larr;</span> Back to home</NuxtLink
>
<button
v-else
@click="signIn"
class="text-sm font-semibold leading-7 text-blue-600"
@click="signIn"
>
Sign in <span aria-hidden="true">&rarr;</span>
</button>
@ -87,9 +95,9 @@ console.log(props.error);
>
<circle cx="1" cy="1" r="1" />
</svg>
<a href="https://discord.gg/NHx46XKJWA" target="_blank"
>Support Discord</a
>
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
Support Discord
</NuxtLink>
</nav>
</div>
</footer>
@ -98,8 +106,8 @@ console.log(props.error);
>
<img
src="/wallpapers/error-wallpaper.jpg"
alt=""
class="absolute inset-0 h-full w-full object-cover"
alt=""
/>
</div>
</div>

5
eslint.config.mjs Normal file
View File

@ -0,0 +1,5 @@
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs";
import eslintConfigPrettier from "eslint-config-prettier/flat";
export default withNuxt([eslintConfigPrettier]);

View File

@ -2,43 +2,81 @@
<div>
<TransitionRoot as="template" :show="sidebarOpen">
<Dialog class="relative z-50 lg:hidden" @close="sidebarOpen = false">
<TransitionChild as="template" enter="transition-opacity ease-linear duration-300" enter-from="opacity-0"
enter-to="opacity-100" leave="transition-opacity ease-linear duration-300" leave-from="opacity-100"
leave-to="opacity-0">
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-zinc-900/80" />
</TransitionChild>
<div class="fixed inset-0 flex">
<TransitionChild as="template" enter="transition ease-in-out duration-300 transform"
enter-from="-translate-x-full" enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform" leave-from="translate-x-0"
leave-to="-translate-x-full">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<DialogPanel class="relative mr-16 flex w-full max-w-xs flex-1">
<TransitionChild as="template" enter="ease-in-out duration-300" enter-from="opacity-0"
enter-to="opacity-100" leave="ease-in-out duration-300" leave-from="opacity-100" leave-to="opacity-0">
<div class="absolute left-full top-0 flex w-16 justify-center pt-5">
<button type="button" class="-m-2.5 p-2.5" @click="sidebarOpen = false">
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="absolute left-full top-0 flex w-16 justify-center pt-5"
>
<button
type="button"
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">Close sidebar</span>
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
</button>
</div>
</TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<div class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-950 px-4 pb-4">
<div
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-950 px-4 pb-4"
>
<div class="inline-flex items-center py-4 px-4">
<Wordmark class="h-full w-auto" alt="Drop`" />
<DropWordmark class="h-full w-auto" alt="Drop`" />
</div>
<nav>
<ul role="list" class="grid grid-cols-2 items-stretch gap-4 px-5">
<ul
role="list"
class="grid grid-cols-2 items-stretch gap-4 px-5"
>
<li v-for="(item, itemIdx) in navigation" :key="item.route">
<NuxtLink :href="item.route" :class="[
itemIdx === currentNavigationIndex
? 'bg-zinc-900 text-white'
: 'text-zinc-400 hover:bg-zinc-900 hover:text-white',
'transition group flex flex-col items-center grow gap-x-3 rounded-md px-2 py-3 text-sm font-semibold leading-6',
]">
<component :is="item.icon" class="h-6 w-6 shrink-0" aria-hidden="true" />
<span class="text-xs text-center">{{ item.label }}</span>
<NuxtLink
:href="item.route"
:class="[
itemIdx === currentNavigationIndex
? 'bg-zinc-900 text-white'
: 'text-zinc-400 hover:bg-zinc-900 hover:text-white',
'transition group flex flex-col items-center grow gap-x-3 rounded-md px-2 py-3 text-sm font-semibold leading-6',
]"
>
<component
:is="item.icon"
class="h-6 w-6 shrink-0"
aria-hidden="true"
/>
<span class="text-xs text-center">{{
item.label
}}</span>
</NuxtLink>
</li>
</ul>
@ -52,21 +90,32 @@
<!-- Static sidebar for desktop -->
<div
class="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:z-50 lg:block lg:w-20 lg:overflow-y-auto lg:bg-zinc-950 lg:pb-4">
class="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:z-50 lg:block lg:w-20 lg:overflow-y-auto lg:bg-zinc-950 lg:pb-4"
>
<div class="flex flex-col h-24 shrink-0 items-center justify-center">
<Logo class="h-8 w-auto" />
<span class="mt-1 bg-blue-400 px-1 py-0.5 rounded-md text-xs font-bold font-display">Admin</span>
<DropLogo class="h-8 w-auto" />
<span
class="mt-1 bg-blue-400 px-1 py-0.5 rounded-md text-xs font-bold font-display"
>Admin</span
>
</div>
<nav class="mt-8">
<ul role="list" class="flex flex-col items-stretch space-y-4 mx-2">
<li v-for="(item, itemIdx) in navigation" :key="item.route">
<NuxtLink :href="item.route" :class="[
itemIdx === currentNavigationIndex
? 'bg-zinc-900 text-white'
: 'text-zinc-400 hover:bg-zinc-900 hover:text-white',
'transition group flex flex-col items-center grow gap-x-3 rounded-md px-2 py-3 text-sm font-semibold leading-6',
]">
<component :is="item.icon" class="h-6 w-6 shrink-0" aria-hidden="true" />
<NuxtLink
:href="item.route"
:class="[
itemIdx === currentNavigationIndex
? 'bg-zinc-900 text-white'
: 'text-zinc-400 hover:bg-zinc-900 hover:text-white',
'transition group flex flex-col items-center grow gap-x-3 rounded-md px-2 py-3 text-sm font-semibold leading-6',
]"
>
<component
:is="item.icon"
class="h-6 w-6 shrink-0"
aria-hidden="true"
/>
<span class="text-xs text-center">{{ item.label }}</span>
</NuxtLink>
</li>
@ -74,15 +123,23 @@
</nav>
</div>
<div class="sticky top-0 z-40 flex items-center gap-x-6 bg-zinc-900 px-4 py-4 shadow-sm sm:px-6 lg:hidden">
<button type="button" class="-m-2.5 p-2.5 text-zinc-400 lg:hidden" @click="sidebarOpen = true">
<div
class="sticky top-0 z-40 flex items-center gap-x-6 bg-zinc-900 px-4 py-4 shadow-sm sm:px-6 lg:hidden"
>
<button
type="button"
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">Open sidebar</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
<main class="lg:pl-20 min-h-screen bg-zinc-900 flex flex-col">
<div class="flex flex-col grow px-4 py-2 sm:py-10 sm:px-6 lg:px-8 lg:py-6">
<div
class="flex flex-col grow px-4 py-2 sm:py-10 sm:px-6 lg:px-8 lg:py-6"
>
<!-- Main area -->
<NuxtPage />
</div>
@ -95,20 +152,16 @@ import { ref, type Component } from "vue";
import {
Dialog,
DialogPanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue'
} from "@headlessui/vue";
import {
Bars3Icon,
ServerStackIcon,
HomeIcon,
LockClosedIcon,
Cog6ToothIcon,
FlagIcon,
DocumentIcon,
UserGroupIcon,
} from "@heroicons/vue/24/outline";
import type { NavigationItem } from "~/composables/types";
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
@ -124,16 +177,16 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
icon: ServerStackIcon,
},
{
label: "Auth",
route: "/admin/auth",
prefix: "/admin/auth",
icon: LockClosedIcon,
label: "Meta",
route: "/admin/metadata",
prefix: "/admin/metadata",
icon: DocumentIcon,
},
{
label: "Feature Flags",
route: "/admin/features",
prefix: "/admin/features",
icon: FlagIcon,
label: "Users",
route: "/admin/users",
prefix: "/admin/users",
icon: UserGroupIcon,
},
{
label: "Settings",
@ -145,19 +198,34 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
label: "Back",
route: "/",
prefix: ".",
icon: ArrowLeftIcon
}
icon: ArrowLeftIcon,
},
];
// const notifications = useNotifications();
// const unreadNotifications = computed(() =>
// notifications.value.filter((e) => !e.read)
// );
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
const sidebarOpen = ref(false);
const router = useRouter();
router.afterEach(() => {
sidebarOpen.value = false;
})
});
useHead({
htmlAttrs: {
lang: "en",
},
link: [
{
rel: "icon",
type: "image/png",
href: "/favicon.png",
},
],
titleTemplate(title) {
return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`;
},

View File

@ -1,12 +1,12 @@
<template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
<UserHeader class="z-50" />
<UserHeader class="z-50" hydrate-on-idle />
<div class="grow flex">
<NuxtPage />
</div>
<UserFooter class="z-50" />
<UserFooter class="z-50" hydrate-on-interaction />
</div>
<div class="flex w-full min-h-screen bg-zinc-900" v-else>
<div v-else class="flex w-full min-h-screen bg-zinc-900">
<NuxtPage />
</div>
</template>
@ -16,6 +16,16 @@ const route = useRoute();
const noWrapper = !!route.query.noWrapper;
useHead({
htmlAttrs: {
lang: "en",
},
link: [
{
rel: "icon",
type: "image/png",
href: "/favicon.png",
},
],
titleTemplate(title) {
if (title) return `${title} | Drop`;
return `Drop`;

View File

@ -1,124 +0,0 @@
<template>
<div class="bg-zinc-950 min-h-screen">
<TransitionRoot as="template" :show="sidebarOpen">
<Dialog
class="relative z-50 lg:hidden"
@close="sidebarOpen = false"
>
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-900/80" />
</TransitionChild>
<div class="fixed inset-0 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<DialogPanel
class="relative mr-16 flex w-full max-w-xs flex-1"
>
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="absolute left-full top-0 flex w-16 justify-center pt-5"
>
<button
type="button"
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only"
>Close sidebar</span
>
<XMarkIcon
class="h-6 w-6 text-white"
aria-hidden="true"
/>
</button>
</div>
</TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<DocsSidebar />
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<!-- Static sidebar for desktop -->
<div
class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col"
>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<DocsSidebar />
</div>
<div class="lg:pl-72">
<div
class="flex sticky top-0 z-40 lg:hidden h-16 shrink-0 items-center gap-x-4 border-b border-zinc-700 bg-zinc-950 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
>
<button
type="button"
class="-m-2.5 p-2.5 text-zinc-300 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">Open sidebar</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
</button>
<Wordmark class="mb-[0.5px]" />
<NuxtLink
href="/"
class="ml-auto rounded bg-blue-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Home &rarr;
</NuxtLink>
</div>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">
<slot />
</div>
</main>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/vue/20/solid";
const sidebarOpen = ref(false);
useHead({
titleTemplate: (title) =>
title ? `${title} | Drop Documentation` : "Drop Documentation",
});
</script>

View File

@ -1,7 +1,7 @@
const whitelistedPrefixes = ["/signin", "/register", "/api", "/setup"];
const whitelistedPrefixes = ["/auth", "/api", "/setup"];
const requireAdmin = ["/admin"];
export default defineNuxtRouteMiddleware(async (to, from) => {
export default defineNuxtRouteMiddleware(async (to, _from) => {
if (import.meta.server) return;
const error = useError();
if (error.value !== undefined) return;
@ -13,7 +13,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
await updateUser();
}
if (!user.value) {
return navigateTo({ path: "/signin", query: { redirect: to.fullPath } });
return navigateTo({
path: "/auth/signin",
query: { redirect: to.fullPath },
});
}
if (
requireAdmin.findIndex((e) => to.fullPath.startsWith(e)) != -1 &&

View File

@ -1,19 +1,44 @@
import path from "path";
import tailwindcss from "@tailwindcss/vite";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
extends: ["./drop-base"],
// Module config from here down
modules: [
"vue3-carousel-nuxt",
"nuxt-security",
// "@nuxt/image",
"@nuxt/fonts",
"@nuxt/eslint",
],
// Nuxt-only config
telemetry: false,
compatibilityDate: "2024-04-03",
devtools: { enabled: false },
css: ["~/assets/core.scss"],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
devtools: {
enabled: true,
telemetry: false,
timeline: {
// this seems to be the tracking issue, composables not registered
// https://github.com/nuxt/devtools/issues/662
enabled: false,
},
},
css: ["~/assets/tailwindcss.css", "~/assets/core.scss"],
experimental: {
buildCache: true,
viewTransition: true,
},
// future: {
// compatibilityVersion: 4,
// },
vite: {
plugins: [tailwindcss()],
},
app: {
head: {
@ -21,39 +46,71 @@ export default defineNuxtConfig({
},
},
routeRules: {
"/api/**": { cors: true },
},
nitro: {
minify: true,
experimental: {
websocket: true,
tasks: true,
},
scheduledTasks: {
"0 * * * *": ["cleanup:invitations"],
"0 * * * *": ["cleanup:invitations", "cleanup:sessions"],
},
compressPublicAssets: true,
storage: {
appCache: {
driver: "lru-cache",
},
},
devStorage: {
appCache: {
// store cache on fs to handle dev server restarts
driver: "fs",
base: "./.data/appCache",
},
},
},
extends: ['./drop-base'],
typescript: {
typeCheck: true,
// Module config from here down
modules: ["@nuxt/content", "vue3-carousel-nuxt"],
tsConfig: {
compilerOptions: {
verbatimModuleSyntax: false,
strictNullChecks: true,
exactOptionalPropertyTypes: true,
},
},
},
carousel: {
prefix: "Vue",
},
content: {
api: {
baseURL: "/api/v1/_content",
},
markdown: {
anchorLinks: false,
},
sources: {
content: {
driver: "fs",
prefix: "/docs",
base: path.resolve(__dirname, "docs"),
security: {
headers: {
contentSecurityPolicy: {
"upgrade-insecure-requests": false,
"img-src": [
"'self'",
"data:",
"https://www.giantbomb.com",
"https://images.pcgamingwiki.com",
"https://images.igdb.com",
],
},
strictTransportSecurity: false,
},
rateLimiter: false,
xssValidator: false,
},
});

View File

@ -8,51 +8,73 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare && prisma generate",
"typecheck": "nuxt typecheck",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:eslint": "eslint .",
"lint:prettier": "prettier . --check",
"lint:fix": "eslint . --fix && prettier --write --list-different ."
},
"dependencies": {
"@drop/droplet": "^0.7.0",
"@drop-oss/droplet": "^0.7.2",
"@drop-oss/headscalez": "0.0.4",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@nuxt/content": "^2.13.4",
"@nuxtjs/tailwindcss": "^6.12.2",
"@prisma/client": "^6.1.0",
"@nuxt/fonts": "^0.11.0",
"@nuxt/image": "^1.10.0",
"@prisma/client": "^6.7.0",
"@tailwindcss/vite": "^4.0.6",
"argon2": "^0.41.1",
"arktype": "^2.1.10",
"axios": "^1.7.7",
"bcryptjs": "^2.4.3",
"cookie-es": "^1.2.2",
"bcryptjs": "^3.0.2",
"cookie-es": "^2.0.0",
"fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0",
"luxon": "^3.6.1",
"micromark": "^4.0.1",
"moment": "^2.30.1",
"nuxt": "^3.13.2",
"prisma": "^6.1.0",
"sanitize-filename": "^1.6.3",
"stream": "^0.0.3",
"nuxt": "^3.16.2",
"nuxt-security": "2.2.0",
"prisma": "^6.7.0",
"sharp": "^0.33.5",
"stream-mime-type": "^2.0.0",
"turndown": "^7.2.0",
"uuid": "^10.0.0",
"unstorage": "^1.15.0",
"vue": "latest",
"vue-router": "latest",
"vue3-carousel-nuxt": "^1.1.3",
"vue3-carousel": "^0.15.0",
"vue3-carousel-nuxt": "^1.1.5",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.3.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/bcryptjs": "^2.4.6",
"@types/bcryptjs": "^3.0.0",
"@types/luxon": "^3.6.2",
"@types/node": "^22.13.16",
"@types/turndown": "^5.0.5",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20",
"h3": "^1.13.0",
"nitropack": "^2.9.7",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.0",
"h3": "^1.15.1",
"ofetch": "^1.4.1",
"postcss": "^8.4.47",
"prettier": "^3.5.3",
"sass": "^1.79.4",
"tailwindcss": "^3.4.15"
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3",
"vue-tsc": "^2.2.8"
},
"optionalDependencies": {
"@drop/droplet-linux-x64-gnu": "^0.7.0",
"@drop/droplet-win32-x64-msvc": "^0.7.0"
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"overrides": {
"vue3-carousel-nuxt": {
"vue3-carousel": "^0.15.0"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"prisma": {
"schema": "./prisma"
}
}

112
pages/account.vue Normal file
View File

@ -0,0 +1,112 @@
<template>
<div class="flex flex-col lg:flex-row grow w-screen">
<TransitionRoot as="template" :show="sidebarOpen">
<Dialog class="relative z-50 lg:hidden" @close="sidebarOpen = false">
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-zinc-900/80" />
</TransitionChild>
<div class="fixed inset-0 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<DialogPanel class="relative mr-16 flex w-full max-w-xs flex-1">
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="absolute top-0 left-full flex w-16 justify-center pt-5"
>
<button
type="button"
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">Close sidebar</span>
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
</button>
</div>
</TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<div class="bg-zinc-900 w-full">
<AccountSidebar />
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<!-- Static sidebar for desktop -->
<div
class="hidden lg:flex lg:inset-y-0 lg:z-50 lg:shrink-0 lg:basis-[18rem] lg:flex-col lg:border-r-2 lg:border-zinc-800"
>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<AccountSidebar />
</div>
<div
class="block flex items-center gap-x-2 bg-zinc-950 px-2 py-1 shadow-xs sm:px-4 lg:hidden border-b border-zinc-700"
>
<button
type="button"
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">Open sidebar</span>
<Bars3Icon class="size-6" aria-hidden="true" />
</button>
<div
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
>
Account
</div>
</div>
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6 w-full">
<NuxtPage />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
const router = useRouter();
const sidebarOpen = ref(false);
router.afterEach(() => {
sidebarOpen.value = false;
});
useHead({
title: "Account",
});
</script>

122
pages/account/devices.vue Normal file
View File

@ -0,0 +1,122 @@
<template>
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">Devices</h1>
<p class="mt-2 text-sm text-zinc-400">
All the devices authorized to access your Drop account.
</p>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Platform
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Can Access
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Last Connected
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="client in clients"
:key="client.id"
class="even:bg-zinc-800"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ client.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ client.platform }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<ul class="flex flex-col gap-y-2">
<li
v-for="capability in client.capabilities"
:key="capability"
class="inline-flex items-center gap-x-0.5"
>
<CheckIcon class="size-4" /> {{ capability }}
</li>
</ul>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ DateTime.fromISO(client.lastConnected).toRelative() }}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-3"
>
<button
class="text-red-600 hover:text-red-900"
@click="() => revokeClientWrapper(client.id)"
>
Revoke<span class="sr-only">, {{ client.name }}</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from "@heroicons/vue/24/outline";
import { DateTime } from "luxon";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore pending https://github.com/nitrojs/nitro/issues/2758
const clients = ref(await $dropFetch("/api/v1/user/client"));
async function revokeClient(id: string) {
await $dropFetch(`/api/v1/user/client/${id}`, { method: "DELETE" });
}
function revokeClientWrapper(id: string) {
revokeClient(id)
.then(() => {
const index = clients.value.findIndex((e) => e.id == id);
clients.value.splice(index, 1);
})
.catch((e) => {
createModal(
ModalType.Notification,
{
title: "Failed to revoke client",
description: `Failed to revoke client: ${e}`,
},
(_, c) => c(),
);
});
}
</script>

1
pages/account/index.vue Normal file
View File

@ -0,0 +1 @@
<template><div></div></template>

View File

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

View File

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

View File

@ -0,0 +1 @@
<template><div></div></template>

View File

@ -1,6 +1,173 @@
<template></template>
<template>
<div v-if="false" class="grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<div class="relative lg:row-span-2">
<div
class="absolute inset-px rounded-lg bg-zinc-950 lg:rounded-l-[2rem]"
/>
<div
class="relative flex h-full flex-col overflow-hidden rounded-[calc(var(--radius-lg)+1px)] lg:rounded-l-[calc(2rem+1px)]"
>
<div class="px-8 pt-8 pb-3 sm:px-10 sm:py-10">
<p
class="mt-2 text-lg font-medium tracking-tight text-zinc-100 max-lg:text-center"
>
Library
</p>
<p class="mt-2 max-w-lg text-sm/6 text-zinc-400 max-lg:text-center">
Manage your Drop library, and import new games. Your library is the
list of all games currently configured on this instance.
</p>
<p class="mt-3 text-sm">
<NuxtLink
href="/admin/library"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
Check it out
<span aria-hidden="true"> &rarr;</span>
</NuxtLink>
</p>
<div
v-if="libraryState.unimportedGames.length > 0"
class="mt-2 rounded-md bg-blue-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
Drop has detected you have new games to import.
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
href="/admin/library/import"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
Import
<span aria-hidden="true"> &rarr;</span>
</NuxtLink>
</p>
</div>
</div>
</div>
</div>
</div>
<div
class="pointer-events-none absolute inset-px rounded-lg ring-1 shadow-sm ring-black/5 lg:rounded-l-[2rem]"
/>
</div>
<div class="relative max-lg:row-start-1">
<div
class="absolute inset-px rounded-lg bg-zinc-950 max-lg:rounded-t-[2rem]"
/>
<div
class="relative flex h-full flex-col overflow-hidden rounded-[calc(var(--radius-lg)+1px)] max-lg:rounded-t-[calc(2rem+1px)]"
>
<div class="px-8 py-8 sm:px-10 sm:py-10">
<p
class="mt-2 text-lg font-medium tracking-tight text-zinc-100 max-lg:text-center"
>
Users
</p>
<p class="mt-2 max-w-lg text-sm/6 text-zinc-400 max-lg:text-center">
Your users are people who can access your Drop instance, download
games from it, and configure API keys for it.
</p>
<p class="mt-3 text-sm">
<NuxtLink
href="/admin/users"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
Check it out
<span aria-hidden="true"> &rarr;</span>
</NuxtLink>
</p>
</div>
</div>
<div
class="pointer-events-none absolute inset-px rounded-lg ring-1 shadow-sm ring-black/5 max-lg:rounded-t-[2rem]"
/>
</div>
<div class="relative max-lg:row-start-3 lg:col-start-2 lg:row-start-2">
<div class="absolute inset-px rounded-lg bg-white" />
<div
class="relative flex h-full flex-col overflow-hidden rounded-[calc(var(--radius-lg)+1px)]"
>
<div class="px-8 pt-8 sm:px-10 sm:pt-10">
<p
class="mt-2 text-lg font-medium tracking-tight text-gray-950 max-lg:text-center"
>
Security
</p>
<p class="mt-2 max-w-lg text-sm/6 text-gray-600 max-lg:text-center">
Morbi viverra dui mi arcu sed. Tellus semper adipiscing suspendisse
semper morbi.
</p>
</div>
<div class="@container flex flex-1 items-center max-lg:py-6 lg:pb-2">
<img
class="h-[min(152px,40cqw)] object-cover"
src="https://tailwindcss.com/plus-assets/img/component-images/bento-03-security.png"
alt=""
/>
</div>
</div>
<div
class="pointer-events-none absolute inset-px rounded-lg ring-1 shadow-sm ring-black/5"
/>
</div>
<div class="relative lg:row-span-2">
<div
class="absolute inset-px rounded-lg bg-white max-lg:rounded-b-[2rem] lg:rounded-r-[2rem]"
/>
<div
class="relative flex h-full flex-col overflow-hidden rounded-[calc(var(--radius-lg)+1px)] max-lg:rounded-b-[calc(2rem+1px)] lg:rounded-r-[calc(2rem+1px)]"
>
<div class="px-8 pt-8 pb-3 sm:px-10 sm:pt-10 sm:pb-0">
<p
class="mt-2 text-lg font-medium tracking-tight text-gray-950 max-lg:text-center"
>
Powerful APIs
</p>
<p class="mt-2 max-w-lg text-sm/6 text-gray-600 max-lg:text-center">
Sit quis amet rutrum tellus ullamcorper ultricies libero dolor eget
sem sodales gravida.
</p>
</div>
<div class="relative min-h-[30rem] w-full grow">
<div
class="absolute top-10 right-0 bottom-0 left-10 overflow-hidden rounded-tl-xl bg-gray-900 shadow-2xl"
>
<div class="flex bg-gray-800/40 ring-1 ring-white/5">
<div class="-mb-px flex text-sm/6 font-medium text-gray-400">
<div
class="border-r border-b border-r-white/10 border-b-white/20 bg-white/5 px-4 py-2 text-white"
>
NotificationSetting.jsx
</div>
<div class="border-r border-gray-600/10 px-4 py-2">App.jsx</div>
</div>
</div>
<div class="px-6 pt-6 pb-14">
<!-- Your code example -->
</div>
</div>
</div>
</div>
<div
class="pointer-events-none absolute inset-px rounded-lg ring-1 shadow-sm ring-black/5 max-lg:rounded-b-[2rem] lg:rounded-r-[2rem]"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { InformationCircleIcon } from "@heroicons/vue/24/solid";
definePageMeta({
layout: "admin",
});
@ -8,4 +175,6 @@ definePageMeta({
useHead({
title: "Home",
});
const libraryState = await $dropFetch("/api/v1/admin/library");
</script>

View File

@ -2,8 +2,8 @@
<div class="flex flex-col gap-y-4 max-w-lg">
<Listbox
as="div"
v-on:update:model-value="(value) => updateCurrentlySelectedVersion(value)"
:model-value="currentlySelectedVersion"
@update:model-value="(value) => updateCurrentlySelectedVersion(value)"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select version to import</ListboxLabel
@ -37,11 +37,11 @@
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="(version, versionIdx) in versions"
:key="version"
:value="versionIdx"
v-slot="{ active, selected }"
as="template"
:value="versionIdx"
>
<li
:class="[
@ -73,7 +73,7 @@
</div>
</Listbox>
<div class="flex flex-col gap-8" v-if="versionGuesses">
<div v-if="versionGuesses" class="flex flex-col gap-8">
<!-- setup executable -->
<div>
<label
@ -93,18 +93,18 @@
<Combobox
as="div"
:value="versionSettings.setup"
@update:model-value="(v) => updateSetupCommand(v)"
nullable
@update:model-value="(v) => updateSetupCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="'setup.exe'"
@change="setupProcessQuery = $event.target.value"
@blur="setupProcessQuery = ''"
:placeholder="'setup.exe'"
/>
<ComboboxButton
v-if="launchFilteredVersionGuesses?.length ?? 0 > 0"
v-if="setupFilteredVersionGuesses?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon
@ -119,9 +119,9 @@
<ComboboxOption
v-for="guess in setupFilteredVersionGuesses"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
v-slot="{ active, selected }"
>
<li
:class="[
@ -156,22 +156,22 @@
</li>
</ComboboxOption>
<ComboboxOption
:value="launchProcessQuery"
v-if="launchProcessQuery"
v-if="setupProcessQuery"
v-slot="{ active, selected }"
:value="setupProcessQuery"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-gray-900',
: 'text-zinc-100',
]"
>
<span
:class="['block truncate', selected && 'font-semibold']"
>
"{{ launchProcessQuery }}"
"{{ setupProcessQuery }}"
</span>
<span
@ -189,10 +189,10 @@
</div>
</Combobox>
<input
type="text"
name="startup"
id="startup"
v-model="versionSettings.setupArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--setup"
/>
@ -249,15 +249,15 @@
<Combobox
as="div"
:value="versionSettings.launch"
@update:model-value="(v) => updateLaunchCommand(v)"
nullable
@update:model-value="(v) => updateLaunchCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="'game.exe'"
@change="launchProcessQuery = $event.target.value"
@blur="launchProcessQuery = ''"
:placeholder="'game.exe'"
/>
<ComboboxButton
v-if="launchFilteredVersionGuesses?.length ?? 0 > 0"
@ -275,9 +275,9 @@
<ComboboxOption
v-for="guess in launchFilteredVersionGuesses"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
v-slot="{ active, selected }"
>
<li
:class="[
@ -312,16 +312,16 @@
</li>
</ComboboxOption>
<ComboboxOption
:value="launchProcessQuery"
v-if="launchProcessQuery"
v-slot="{ active, selected }"
:value="launchProcessQuery"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-gray-900',
: 'text-zinc-100',
]"
>
<span
@ -345,18 +345,18 @@
</div>
</Combobox>
<input
type="text"
name="startup"
id="startup"
v-model="versionSettings.launchArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--launch"
/>
</div>
</div>
<div
class="absolute inset-0 bg-zinc-900/50"
v-if="versionSettings.onlySetup"
class="absolute inset-0 bg-zinc-900/50"
/>
</div>
@ -393,7 +393,7 @@
/>
</Switch>
</SwitchGroup>
<Disclosure as="div" class="py-2" v-slot="{ open }">
<Disclosure v-slot="{ open }" as="div" class="py-2">
<dt>
<DisclosureButton
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
@ -453,12 +453,12 @@
<div class="mt-2">
<input
id="umu-id"
v-model="umuId"
name="umu-id"
type="text"
autocomplete="umu-id"
required
:disabled="!umuIdEnabled"
v-model="umuId"
placeholder="umu-starcitizen"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
@ -473,9 +473,9 @@
</Disclosure>
<LoadingButton
@click="startImport_wrapper"
class="w-fit"
:loading="importLoading"
@click="startImport_wrapper"
>
Import
</LoadingButton>
@ -536,7 +536,6 @@ import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
@ -551,17 +550,13 @@ definePageMeta({
const router = useRouter();
const route = useRoute();
const headers = useRequestHeaders(["cookie"]);
const gameId = route.params.id.toString();
const versions = await $fetch(
const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
{
headers,
}
);
const currentlySelectedVersion = ref(-1);
const versionSettings = ref<{
platform: string;
platform: PlatformClient | undefined;
onlySetup: boolean;
launch: string;
@ -572,7 +567,7 @@ const versionSettings = ref<{
delta: boolean;
umuId: string;
}>({
platform: "",
platform: undefined,
launch: "",
launchArgs: "",
setup: "",
@ -582,19 +577,20 @@ const versionSettings = ref<{
umuId: "",
});
const versionGuesses = ref<Array<{ platform: string; filename: string }>>();
const versionGuesses =
ref<Array<{ platform: PlatformClient; filename: string }>>();
const launchProcessQuery = ref("");
const setupProcessQuery = ref("");
const launchFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase())
)
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
),
);
const setupFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase())
)
e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase()),
),
);
function updateLaunchCommand(value: string) {
@ -611,7 +607,7 @@ function autosetPlatform(value: string) {
if (!versionGuesses.value) return;
if (versionSettings.value.platform) return;
const guessIndex = versionGuesses.value.findIndex(
(e) => e.filename === value
(e) => e.filename === value,
);
if (guessIndex == -1) return;
versionSettings.value.platform = versionGuesses.value[guessIndex].platform;
@ -637,17 +633,20 @@ async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value];
const results = await $fetch(
const results = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId
)}&version=${encodeURIComponent(version)}`
gameId,
)}&version=${encodeURIComponent(version)}`,
);
versionGuesses.value = results;
versionGuesses.value = results.map((e) => ({
...e,
platform: e.platform as PlatformClient,
}));
}
async function startImport() {
if (!versionSettings.value) return;
const taskId = await $fetch("/api/v1/admin/import/version", {
const taskId = await $dropFetch("/api/v1/admin/import/version", {
method: "POST",
body: {
id: gameId,

View File

@ -1,762 +1,125 @@
<template>
<div
v-if="game && unimportedVersions !== undefined"
class="grow flex flex-col gap-y-8"
>
<div class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col">
<div
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
>
<div class="inline-flex items-center gap-4">
<img :src="useObject(game.mIconId)" class="size-20" />
<div>
<h1 class="text-5xl font-bold font-display text-zinc-100">
{{ game.mName }}
</h1>
<p class="mt-1 text-lg text-zinc-400">
{{ game.mShortDescription }}
</p>
</div>
</div>
<button
@click="() => (showEditCoreMetadata = true)"
type="button"
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Edit <PencilIcon class="size-4" />
</button>
</div>
<!-- image carousel pick -->
<div class="border-b border-zinc-700">
<div class="border-b border-zinc-700 py-4">
<div
class="-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<div class="ml-4 mt-4">
<h3 class="text-base font-semibold text-zinc-100">
Image Carousel
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
Customise what images and what order are shown on the store
page.
</p>
</div>
<div class="ml-4 mt-4 shrink-0">
<button
@click="() => (showAddCarouselModal = true)"
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Add from image library
</button>
</div>
</div>
</div>
<div
class="text-zinc-400 text-center py-8"
v-if="game.mImageCarousel.length == 0"
>
No images added to the carousel yet.
</div>
<draggable
v-else
@update="() => updateImageCarousel()"
:list="game.mImageCarousel"
class="w-full flex flex-row gap-x-4 overflow-x-auto my-2 py-4"
>
<template #item="{ element }: { element: string }">
<div class="relative group min-w-fit">
<img :src="useObject(element)" class="h-48 w-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
@click="() => removeImageFromCarousel(element)"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Remove image
</button>
</div>
</div>
</template>
</draggable>
</div>
<!-- description editor -->
<div
class="mt-4 grow flex flex-col w-full space-y-4 border border-zinc-800 rounded overflow-hidden p-2"
>
<!-- toolbar -->
<div
class="h-8 bg-zinc-800 rounded inline-flex gap-x-4 items-center justify-start p-2"
>
<div>
<CheckIcon
v-if="descriptionSaving == 0"
class="size-5 text-zinc-100"
/>
<div v-else-if="descriptionSaving == 1">
<PencilIcon class="animate-pulse size-5 text-zinc-100" />
</div>
<div v-else-if="descriptionSaving == 2" role="status">
<svg
aria-hidden="true"
class="w-5 h-5 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
<button @click="() => (showAddImageDescriptionModal = true)">
<PhotoIcon
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
</button>
<button
@click="
() => (mobileShowFinalDescription = !mobileShowFinalDescription)
"
class="block lg:hidden"
>
<DocumentIcon
v-if="!mobileShowFinalDescription"
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
<PencilIcon
v-else
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
</button>
</div>
<!-- edit area -->
<div class="grid lg:grid-cols-2 lg:gap-x-8 grow">
<!-- editing box -->
<div
:class="[
mobileShowFinalDescription ? 'hidden' : 'block',
'lg:block',
]"
>
<textarea
ref="descriptionEditor"
v-model="game.mDescription"
class="grow h-full w-full bg-zinc-950/30 text-zinc-100 border-zinc-900 rounded"
/>
</div>
<!-- result box -->
<div
:class="[
mobileShowFinalDescription ? 'block' : 'hidden',
'lg:block prose prose-invert prose-blue bg-zinc-950/30 rounded px-4 py-3',
]"
v-html="descriptionHTML"
></div>
</div>
</div>
</div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:fixed lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col lg:right-0 gap-y-8 px-6 py-4"
<div>
<!-- import games button -->
<NuxtLink
v-if="unimportedVersions !== undefined"
:href="
unimportedVersions.length > 0 ? `/admin/library/${game.id}/import` : ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
<!-- toolbar -->
<div class="inline-flex justify-end items-stretch gap-x-4">
<!-- import games button -->
<NuxtLink
:href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
unimportedVersions.length > 0
? "Import version"
: "No versions to import"
}}
</NuxtLink>
<!-- open in store button -->
<NuxtLink
:href="`/store/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Store
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
{{
unimportedVersions.length > 0
? "Import version"
: "No versions to import"
}}
</NuxtLink>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div class="flex flex-wrap items-center justify-between sm:flex-nowrap">
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
Version priority
</h3>
</div>
</div>
<!-- image library -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div class="mt-4 text-center w-full text-sm text-zinc-600">lowest</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
>
<template #item="{ element: item }: { element: GameVersion }">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap gap-4"
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
<div>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
Image library
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
Please note all images uploaded are accessible to all users
through browser dev-tools.
</p>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="flex-shrink-0">
<button
@click="() => (showUploadModal = true)"
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Upload
<div class="text-zinc-400">
{{ item.delta ? "Upgrade mode" : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon class="cursor-move w-6 h-6 text-zinc-400 handle" />
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-2 grid-flow-dense gap-8">
<div
v-for="(image, imageIdx) in game.mImageLibrary"
:key="image"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
v-if="image !== game.mBannerId"
@click="() => updateBannerImage(image)"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Set as banner
</button>
<button
v-if="image !== game.mCoverId"
@click="() => updateCoverImage(image)"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Set as cover
</button>
<button
@click="() => deleteImage(image)"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
>
Delete image
</button>
</div>
<div
v-if="image === game.mBannerId || image === game.mCoverId"
class="absolute bottom-0 left-0 bg-zinc-950/75 text-zinc-100 text-sm font-semibold px-2 py-1 rounded-tr"
>
current
{{
[
image === game.mBannerId ? "banner" : undefined,
image === game.mCoverId ? "cover" : undefined,
]
.filter((e) => e)
.join(" & ")
}}
</div>
</div>
</div>
</div>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
Version priority
</h3>
</div>
</div>
<div class="mt-4 text-center w-full text-sm text-zinc-600">lowest</div>
<draggable
@update="() => updateVersionOrder()"
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
>
<template #item="{ element: item }: { element: GameVersion }">
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? "Upgrade mode" : "" }}
</div>
<div class="inline-flex gap-x-2">
<Bars3Icon class="cursor-move w-6 h-6 text-zinc-400 handle" />
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
class="text-center font-bold text-zinc-400 my-3"
v-if="game.versions.length == 0"
>
no versions added
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">highest</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
no versions added
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">highest</div>
</div>
</div>
<UploadFileDialog
v-model="showUploadModal"
:options="{ id: game.id }"
accept="image/*"
endpoint="/api/v1/admin/game/image"
@upload="(result) => uploadAfterImageUpload(result)"
/>
<ModalTemplate v-model="showAddCarouselModal">
<template #default>
<div class="grid grid-cols-2 grid-flow-dense gap-4">
<div
v-for="(image, imageIdx) in validAddCarouselImages"
:key="image"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
@click="() => addImageToCarousel(image)"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Add
</button>
</div>
</div>
<div
v-if="validAddCarouselImages.length == 0"
class="text-zinc-400 col-span-2"
>
No images to add.
</div>
</div>
</template>
<template #buttons>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
<ModalTemplate v-model="showAddImageDescriptionModal">
<template #default>
<div class="grid grid-cols-2 grid-flow-dense gap-4">
<div
v-for="(image, imageIdx) in game.mImageLibrary"
:key="image"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
@click="() => insertImageAtCursor(image)"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Insert
</button>
</div>
</div>
<div
v-if="game.mImageLibrary.length == 0"
class="text-zinc-400 col-span-2"
>
No images to add.
</div>
</div>
</template>
<template #buttons>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
<ModalTemplate v-model="showEditCoreMetadata">
<template #default>
<div class="flex flex-col lg:flex-row gap-6">
<!-- icon upload div -->
<div class="flex flex-col items-center gap-4">
<img :src="coreMetadataIconUrl" class="size-24 aspect-square" />
<label for="file-upload">
<span
type="button"
class="cursor-pointer relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Upload
</span>
<input
accept="image/*"
@change="(e) => coreMetadataUploadFiles(e as any)"
class="hidden"
type="file"
id="file-upload"
/>
</label>
</div>
<!-- edit title -->
<div class="flex flex-col gap-y-4 grow">
<div>
<label for="name" class="block text-sm/6 font-medium text-zinc-100"
>Game Name</label
>
<div class="mt-2">
<input
type="text"
name="name"
id="name"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
v-model="coreMetadataName"
/>
</div>
</div>
<div>
<label
for="description"
class="block text-sm/6 font-medium text-zinc-100"
>Game Description</label
>
<div class="mt-2">
<input
type="text"
name="description"
id="description"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
v-model="coreMetadataDescription"
/>
</div>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
type="button"
:loading="coreMetadataLoading"
@click="() => coreMetadataUpdate_wrapper()"
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
>
Save
</LoadingButton>
<button
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showEditCoreMetadata = false"
ref="cancelButtonRef"
>
Cancel
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { Bars3Icon, TrashIcon } from "@heroicons/vue/16/solid";
import type { Game, GameVersion } from "@prisma/client";
import { micromark } from "micromark";
import {
ArrowTopRightOnSquareIcon,
CheckIcon,
DocumentIcon,
PencilIcon,
PhotoIcon,
} from "@heroicons/vue/24/solid";
import type { GameVersion } from "~/prisma/client";
definePageMeta({
layout: "admin",
});
const showUploadModal = ref(false);
const showAddCarouselModal = ref(false);
const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true);
// TODO implement UI for this
const route = useRoute();
const gameId = route.params.id.toString();
const headers = useRequestHeaders(["cookie"]);
const { game: rawGame, unimportedVersions } = await $fetch(
const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`,
{
headers,
}
);
const game = ref(rawGame);
const coreMetadataName = ref(game.value.mName);
const coreMetadataDescription = ref(game.value.mShortDescription);
const coreMetadataIconUrl = ref(useObject(game.value.mIconId));
const coreMetadataIconFileUpload = ref<FileList | undefined>();
const coreMetadataLoading = ref(false);
function coreMetadataUploadFiles(e: InputEvent) {
if (coreMetadataIconUrl.value.startsWith("blob")) {
console.log("freed object URL");
URL.revokeObjectURL(coreMetadataIconUrl.value);
}
coreMetadataIconFileUpload.value = (e.target as any)?.files;
const file = coreMetadataIconFileUpload.value?.item(0);
if (!file) {
createModal(
ModalType.Notification,
{
title: "Failed to upload file",
description: "Drop couldn't upload this file.",
buttonText: "Close",
},
(e, c) => c()
);
return;
}
const objectUrl = URL.createObjectURL(file);
coreMetadataIconUrl.value = objectUrl;
}
async function coreMetadataUpdate() {
const formData = new FormData();
const newIcon = coreMetadataIconFileUpload.value?.item(0);
if (newIcon) {
formData.append("icon", newIcon);
}
formData.append("id", game.value.id);
formData.append("name", coreMetadataName.value);
formData.append("description", coreMetadataDescription.value);
const result = await $fetch(`/api/v1/admin/game/metadata`, {
method: "POST",
body: formData,
});
return result;
}
function coreMetadataUpdate_wrapper() {
coreMetadataLoading.value = true;
coreMetadataUpdate()
.catch((e) => {
createModal(
ModalType.Notification,
{
title: "Failed to update metadata",
description: `Drop failed to update the game's metadata: ${
e?.statusMessage || "An unknown error occurred. "
}`,
buttonText: "Close",
},
(e, c) => c()
);
})
.then((newGame) => {
if (!newGame) return;
Object.assign(game.value, newGame);
})
.finally(() => {
coreMetadataLoading.value = false;
showEditCoreMetadata.value = false;
});
}
const descriptionHTML = computed(() =>
micromark(game.value?.mDescription ?? "")
);
const descriptionEditor = ref<HTMLTextAreaElement | undefined>();
// 0 is not loading
// 1 is waiting for stop
// 2 is loading
const descriptionSaving = ref<number>(0);
let savingTimeout: undefined | NodeJS.Timeout;
watch(descriptionHTML, (v) => {
console.log(game.value.mDescription);
descriptionSaving.value = 1;
if (savingTimeout) clearTimeout(savingTimeout);
savingTimeout = setTimeout(async () => {
try {
descriptionSaving.value = 2;
await $fetch("/api/v1/admin/game", {
method: "PATCH",
body: {
id: gameId,
mDescription: game.value.mDescription,
},
});
descriptionSaving.value = 0;
} catch (e: any) {
createModal(
ModalType.Notification,
{
title: "Failed to update game description",
description: `Drop failed to update the game description: ${
e?.statusMessage || "An unknown error occurred."
}`,
buttonText: "Close",
},
(e, c) => c()
);
}
}, 1500);
});
const validAddCarouselImages = computed(() =>
game.value.mImageLibrary.filter((e) => !game.value.mImageCarousel.includes(e))
);
function insertImageAtCursor(id: string) {
showAddImageDescriptionModal.value = false;
if (!descriptionEditor.value || !game.value) return;
const insertPosition = descriptionEditor.value.selectionStart;
const text = `![](/api/v1/object/${id})`;
game.value.mDescription = `${game.value.mDescription.slice(
0,
insertPosition
)}${text}${game.value.mDescription.slice(insertPosition)}`;
}
async function updateBannerImage(id: string) {
async function updateVersionOrder() {
try {
if (game.value.mBannerId == id) return;
const { mBannerId } = await $fetch("/api/v1/admin/game", {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: gameId,
mBannerId: id,
versions: game.value.versions.map((e) => e.versionName),
},
});
game.value.mBannerId = mBannerId;
} catch (e: any) {
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "There an error while updating the banner image",
description: `Drop encountered an error while updating the banner image: ${
e?.statusMessage || "An unknown error occurred"
title: "There an error while updating the version order",
description: `Drop encountered an error while updating the version: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c()
(e, c) => c(),
);
}
}
async function updateCoverImage(id: string) {
try {
if (game.value.mCoverId == id) return;
const { mCoverId } = await $fetch("/api/v1/admin/game", {
method: "PATCH",
body: {
id: gameId,
mCoverId: id,
},
});
game.value.mCoverId = mCoverId;
} catch (e: any) {
createModal(
ModalType.Notification,
{
title: "There an error while updating the cover image",
description: `Drop encountered an error while updating the cover image: ${
e?.statusMessage || "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c()
);
}
}
async function deleteImage(id: string) {
try {
const { mBannerId, mImageLibrary } = await $fetch(
"/api/v1/admin/game/image",
{
method: "DELETE",
body: {
gameId: game.value.id,
imageId: id,
},
}
);
game.value.mImageLibrary = mImageLibrary;
game.value.mBannerId = mBannerId;
} catch (e: any) {
createModal(
ModalType.Notification,
{
title: "There an error while deleting the image",
description: `Drop encountered an error while deleting the image: ${
e?.statusMessage || "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c()
);
}
}
async function uploadAfterImageUpload(result: Game) {
if (!game.value) return;
game.value.mImageLibrary = result.mImageLibrary;
}
async function deleteVersion(versionName: string) {
try {
await $fetch("/api/v1/admin/game/version", {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: gameId,
@ -765,80 +128,20 @@ async function deleteVersion(versionName: string) {
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName),
1
1,
);
} catch (e: any) {
} catch (e) {
createModal(
ModalType.Notification,
{
title: "There an error while deleting the version",
description: `Drop encountered an error while deleting the version: ${
e?.statusMessage || "An unknown error occurred"
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c()
);
}
}
async function updateVersionOrder() {
try {
const newVersions = await $fetch("/api/v1/admin/game/version", {
method: "POST",
body: {
id: gameId,
versions: game.value.versions.map((e) => e.versionName),
},
});
game.value.versions = newVersions;
} catch (e: any) {
createModal(
ModalType.Notification,
{
title: "There an error while updating the version order",
description: `Drop encountered an error while updating the version: ${
e?.statusMessage || "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c()
);
}
}
function addImageToCarousel(id: string) {
showAddCarouselModal.value = false;
game.value.mImageCarousel.push(id);
updateImageCarousel();
}
function removeImageFromCarousel(id: string) {
const imageIndex = game.value.mImageCarousel.findIndex((e) => e == id);
game.value.mImageCarousel.splice(imageIndex, 1);
updateImageCarousel();
}
async function updateImageCarousel() {
try {
await $fetch("/api/v1/admin/game", {
method: "PATCH",
body: {
id: gameId,
mImageCarousel: game.value.mImageCarousel,
},
});
} catch (e: any) {
createModal(
ModalType.Notification,
{
title: "There an error while updating the image carousel",
description: `Drop encountered an error while updating image carousel: ${
e?.statusMessage || "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c()
(e, c) => c(),
);
}
}

View File

@ -1,39 +1,69 @@
<template>
<div class="flex flex-col gap-y-6 w-full max-w-md">
<Listbox as="div" v-on:update:model-value="(value) => updateSelectedGame_wrapper(value)"
:model="currentlySelectedGame">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">Select game to import</ListboxLabel>
<Listbox
as="div"
:model="currentlySelectedGame"
@update:model-value="(value) => updateSelectedGame_wrapper(value)"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select game to import</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6">
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span v-if="currentlySelectedGame != -1" class="block truncate">{{
games.unimportedGames[currentlySelectedGame]
}}</span>
<span v-else class="block truncate text-zinc-400">Please select a directory...</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
<span v-else class="block truncate text-zinc-400"
>Please select a directory...</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100"
leave-to-class="opacity-0">
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm">
<ListboxOption as="template" v-for="(game, gameIdx) in games.unimportedGames" :key="game" :value="gameIdx"
v-slot="{ active, selected }">
<li :class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]">
<span :class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]">{{ game }}</span>
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="(game, gameIdx) in games.unimportedGames"
:key="game"
v-slot="{ active, selected }"
as="template"
:value="gameIdx"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ game }}</span
>
<span v-if="selected" :class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]">
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
@ -46,43 +76,109 @@
<div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4">
<!-- without metadata option -->
<div>
<LoadingButton @click="() => importGame_wrapper(false)" class="w-fit" :loading="importLoading">Import without
metadata
<LoadingButton
class="w-fit"
:loading="importLoading"
@click="() => importGame_wrapper(false)"
>Import without metadata
</LoadingButton>
</div>
<!-- divider -->
<div class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold">
<div class="h-[1px] grow bg-zinc-800" />OR
<div
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold"
>
<div class="h-[1px] grow bg-zinc-800" />
OR
<div class="h-[1px] grow bg-zinc-800" />
</div>
<!-- with metadata option -->
<div class="flex flex-col gap-y-4">
<Listbox as="div" v-if="metadataResults && metadataResults.length > 0" v-model="currentlySelectedMetadata">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">Select game</ListboxLabel>
<form @submit.prevent="() => searchGame()">
<label
for="searchTerm"
class="block text-sm/6 font-medium text-zinc-100"
>Search</label
>
<div class="mt-2 flex">
<div class="-mr-px grid grow grid-cols-1 focus-within:relative">
<input
id="searchTerm"
v-model="gameSearchTerm"
type="text"
name="searchTerm"
class="col-start-1 row-start-1 block w-full rounded-l-md bg-zinc-950 py-1.5 px-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
placeholder="John Smith"
/>
</div>
<LoadingButton
:loading="gameSearchLoading"
:style="'none'"
type="submit"
class="w-24 flex shrink-0 items-center justify-center gap-x-1.5 rounded-r-md bg-zinc-950 px-3 py-2 text-sm font-semibold text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 hover:bg-zinc-900 focus:relative focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
>
<MagnifyingGlassIcon
class="-ml-0.5 size-4 text-gray-400"
aria-hidden="true"
/>
Search
</LoadingButton>
</div>
</form>
<Listbox
v-if="metadataResults && metadataResults.length > 0"
v-model="currentlySelectedMetadata"
as="div"
>
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>Select game</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6">
<GameSearchResultWidget v-if="currentlySelectedMetadata != -1"
:game="metadataResults[currentlySelectedMetadata]" />
<span v-else class="block truncate text-zinc-600">Please select a game...</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<GameSearchResultWidget
v-if="currentlySelectedMetadata != -1"
:game="metadataResults[currentlySelectedMetadata]"
/>
<span v-else class="block truncate text-zinc-600"
>Please select a game...</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100"
leave-to-class="opacity-0">
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
<ListboxOption as="template" v-for="(result, resultIdx) in metadataResults" :key="result.id"
:value="resultIdx" v-slot="{ active, selected }">
<li :class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]">
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="(result, resultIdx) in metadataResults"
:key="result.id"
v-slot="{ active }"
as="template"
:value="resultIdx"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<GameSearchResultWidget :game="result" />
</li>
</ListboxOption>
@ -90,22 +186,35 @@
</transition>
</div>
</Listbox>
<div v-else-if="gameSearchResultsLoading" role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4">
<div
v-else-if="gameSearchResultsLoading"
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
Loading game results...
<svg aria-hidden="true" class="w-6 h-6 text-transparent animate-spin fill-white" viewBox="0 0 100 101"
fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor" />
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill" />
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
<div v-if="gameSearchResultsError" class="w-fit rounded-md bg-red-600/10 p-4">
<div
v-if="gameSearchResultsError"
class="w-fit rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
@ -119,11 +228,18 @@
</div>
<div>
<LoadingButton @click="() => importGame_wrapper()" class="w-fit" :loading="importLoading"
:disabled="currentlySelectedMetadata === -1">Import
<LoadingButton
class="w-fit"
:loading="importLoading"
:disabled="currentlySelectedMetadata === -1"
@click="() => importGame_wrapper()"
>Import
</LoadingButton>
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
<div
v-if="importError"
class="mt-4 w-fit rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
@ -151,18 +267,20 @@ import {
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
definePageMeta({
layout: "admin",
});
const headers = useRequestHeaders(["cookie"]);
const games = await $fetch("/api/v1/admin/import/game", { headers });
const games = await $dropFetch("/api/v1/admin/import/game");
const currentlySelectedGame = ref(-1);
const gameSearchResultsLoading = ref(false);
const gameSearchResultsError = ref<string | undefined>();
const gameSearchTerm = ref("");
const gameSearchLoading = ref(false);
async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value) return;
@ -173,20 +291,30 @@ async function updateSelectedGame(value: number) {
metadataResults.value = undefined;
currentlySelectedMetadata.value = -1;
gameSearchTerm.value = game;
const results = await $fetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(game)}`
await searchGame();
}
async function searchGame() {
gameSearchLoading.value = true;
const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
);
metadataResults.value = results;
gameSearchLoading.value = false;
}
function updateSelectedGame_wrapper(value: number) {
gameSearchResultsLoading.value = true;
updateSelectedGame(value).catch((error) => {
gameSearchResultsError.value = error.statusMessage || "An unknown error occurred";
}).finally(() => {
gameSearchResultsLoading.value = false;
})
updateSelectedGame(value)
.catch((error) => {
gameSearchResultsError.value =
error.statusMessage || "An unknown error occurred";
})
.finally(() => {
gameSearchResultsLoading.value = false;
});
}
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
@ -197,13 +325,16 @@ const router = useRouter();
const importLoading = ref(false);
const importError = ref<string | undefined>();
async function importGame(metadata: boolean) {
if (!metadataResults.value) return;
if (!metadataResults.value && metadata) return;
const game = await $fetch("/api/v1/admin/import/game", {
const game = await $dropFetch("/api/v1/admin/import/game", {
method: "POST",
body: {
path: games.unimportedGames[currentlySelectedGame.value],
metadata: metadata ? metadataResults.value[currentlySelectedMetadata.value] : undefined,
metadata:
metadata && metadataResults.value
? metadataResults.value[currentlySelectedMetadata.value]
: undefined,
},
});

View File

@ -43,12 +43,12 @@
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
id="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
placeholder="Search library..."
v-model="searchQuery"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
@ -67,7 +67,7 @@
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-16 w-16 flex-shrink-0 rounded-md"
:src="useObject(game.mIconId)"
:src="useObject(game.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
@ -85,19 +85,25 @@
</dd>
<dt class="sr-only">Metadata provider</dt>
</dl>
<div class="inline-flex gap-x-2 items-center">
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/${game.id}`"
class="mt-2 w-fit rounded-md bg-blue-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Edit &rarr;
</NuxtLink>
<button
@click="() => deleteGame(game.id)"
class="mt-2 w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
>
Delete
</button>
:href="`/admin/library/${game.id}`"
class="w-fit rounded-md bg-blue-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open with Library &rarr;
</NuxtLink>
<NuxtLink
:href="`/admin/metadata/games/${game.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open with Metadata &rarr;
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteGame(game.id)"
>
Delete
</button>
</div>
</div>
</div>
@ -150,14 +156,14 @@
</div>
</li>
<p
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
No results
</p>
<p
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
No games imported
</p>
@ -179,8 +185,7 @@ useHead({
const searchQuery = ref("");
const headers = useRequestHeaders(["cookie"]);
const libraryState = await $fetch("/api/v1/admin/library", { headers });
const libraryState = await $dropFetch("/api/v1/admin/library");
const libraryGames = ref(
libraryState.games.map((e) => {
const noVersions = e.status.noVersions;
@ -194,11 +199,12 @@ const libraryGames = ref(
},
hasNotifications: noVersions || toImport,
};
})
}),
);
const filteredLibraryGames = computed(() =>
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore excessively deep ts
libraryGames.value.filter((e) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
@ -206,11 +212,11 @@ const filteredLibraryGames = computed(() =>
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
return true;
return false;
})
}),
);
async function deleteGame(id: string) {
await $fetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" });
await $dropFetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" });
const index = libraryGames.value.findIndex((e) => e.id === id);
libraryGames.value.splice(index, 1);
}

View File

@ -0,0 +1,751 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div>
<div
v-if="game && unimportedVersions !== undefined"
class="grow flex flex-col gap-y-8"
>
<div class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col">
<div
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
>
<div class="inline-flex items-center gap-4">
<img :src="useObject(game.mIconObjectId)" class="size-20" />
<div>
<h1 class="text-5xl font-bold font-display text-zinc-100">
{{ game.mName }}
</h1>
<p class="mt-1 text-lg text-zinc-400">
{{ game.mShortDescription }}
</p>
</div>
</div>
<button
type="button"
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showEditCoreMetadata = true)"
>
Edit <PencilIcon class="size-4" />
</button>
</div>
<!-- image carousel pick -->
<div class="border-b border-zinc-700">
<div class="border-b border-zinc-700 py-4">
<div
class="-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<div class="ml-4 mt-4">
<h3 class="text-base font-semibold text-zinc-100">
Image Carousel
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
Customise what images and what order are shown on the store
page.
</p>
</div>
<div class="ml-4 mt-4 shrink-0">
<button
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showAddCarouselModal = true)"
>
Add from image library
</button>
</div>
</div>
</div>
<div
v-if="game.mImageCarouselObjectIds.length == 0"
class="text-zinc-400 text-center py-8"
>
No images added to the carousel yet.
</div>
<draggable
v-else
:list="game.mImageCarouselObjectIds"
class="w-full flex flex-row gap-x-4 overflow-x-auto my-2 py-4"
@update="() => updateImageCarousel()"
>
<template #item="{ element }: { element: string }">
<div class="relative group min-w-fit">
<img :src="useObject(element)" class="h-48 w-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => removeImageFromCarousel(element)"
>
Remove image
</button>
</div>
</div>
</template>
</draggable>
</div>
<!-- description editor -->
<div
class="mt-4 grow flex flex-col w-full space-y-4 border border-zinc-800 rounded overflow-hidden p-2"
>
<!-- toolbar -->
<div
class="h-8 bg-zinc-800 rounded inline-flex gap-x-4 items-center justify-start p-2"
>
<div>
<CheckIcon
v-if="descriptionSaving == 0"
class="size-5 text-zinc-100"
/>
<div v-else-if="descriptionSaving == 1">
<PencilIcon class="animate-pulse size-5 text-zinc-100" />
</div>
<div v-else-if="descriptionSaving == 2" role="status">
<svg
aria-hidden="true"
class="w-5 h-5 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
<button @click="() => (showAddImageDescriptionModal = true)">
<PhotoIcon
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
</button>
<button
class="block lg:hidden"
@click="
() => (mobileShowFinalDescription = !mobileShowFinalDescription)
"
>
<DocumentIcon
v-if="!mobileShowFinalDescription"
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
<PencilIcon
v-else
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
</button>
</div>
<!-- edit area -->
<div class="grid lg:grid-cols-2 lg:gap-x-8 grow">
<!-- editing box -->
<div
:class="[
mobileShowFinalDescription ? 'hidden' : 'block',
'lg:block',
]"
>
<textarea
ref="descriptionEditor"
v-model="game.mDescription"
class="grow h-full w-full bg-zinc-950/30 text-zinc-100 border-zinc-900 rounded"
/>
</div>
<!-- result box -->
<div
:class="[
mobileShowFinalDescription ? 'block' : 'hidden',
'lg:block prose prose-invert prose-blue bg-zinc-950/30 rounded px-4 py-3',
]"
v-html="descriptionHTML"
/>
</div>
</div>
</div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:fixed lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col lg:right-0 gap-y-8 px-6 py-4"
>
<!-- toolbar -->
<div class="inline-flex justify-end items-stretch gap-x-4">
<!-- open in library button -->
<NuxtLink
:href="`/admin/library/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Library &rarr;
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
<!-- open in store button -->
<NuxtLink
:href="`/store/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Store
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
</div>
<!-- image library -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap gap-4"
>
<div>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
Image library
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
Please note all images uploaded are accessible to all users
through browser dev-tools.
</p>
</div>
<div class="flex-shrink-0">
<button
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showUploadModal = true)"
>
Upload
</button>
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-2 grid-flow-dense gap-8">
<div
v-for="(image, imageIdx) in game.mImageLibraryObjectIds"
:key="imageIdx"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
v-if="image !== game.mBannerObjectId"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => updateBannerImage(image)"
>
Set as banner
</button>
<button
v-if="image !== game.mCoverObjectId"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => updateCoverImage(image)"
>
Set as cover
</button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteImage(image)"
>
Delete image
</button>
</div>
<div
v-if="
image === game.mBannerObjectId ||
image === game.mCoverObjectId
"
class="absolute bottom-0 left-0 bg-zinc-950/75 text-zinc-100 text-sm font-semibold px-2 py-1 rounded-tr"
>
current
{{
[
image === game.mBannerObjectId ? "banner" : undefined,
image === game.mCoverObjectId ? "cover" : undefined,
]
.filter((e) => e)
.join(" & ")
}}
</div>
</div>
</div>
</div>
</div>
</div>
<UploadFileDialog
v-model="showUploadModal"
:options="{ id: game.id }"
accept="image/*"
endpoint="/api/v1/admin/game/image"
@upload="(result: Game) => uploadAfterImageUpload(result)"
/>
<ModalTemplate v-model="showAddCarouselModal">
<template #default>
<div class="grid grid-cols-2 grid-flow-dense gap-4">
<div
v-for="(image, imageIdx) in validAddCarouselImages"
:key="imageIdx"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => addImageToCarousel(image)"
>
Add
</button>
</div>
</div>
<div
v-if="validAddCarouselImages.length == 0"
class="text-zinc-400 col-span-2"
>
No images to add.
</div>
</div>
</template>
<template #buttons>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false"
>
Cancel
</button>
</template>
</ModalTemplate>
<ModalTemplate v-model="showAddImageDescriptionModal">
<template #default>
<div class="grid grid-cols-2 grid-flow-dense gap-4">
<div
v-for="(image, imageIdx) in game.mImageLibraryObjectIds"
:key="imageIdx"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => insertImageAtCursor(image)"
>
Insert
</button>
</div>
</div>
<div
v-if="game.mImageLibraryObjectIds.length == 0"
class="text-zinc-400 col-span-2"
>
No images to add.
</div>
</div>
</template>
<template #buttons>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false"
>
Cancel
</button>
</template>
</ModalTemplate>
<ModalTemplate v-model="showEditCoreMetadata">
<template #default>
<div class="flex flex-col lg:flex-row gap-6">
<!-- icon upload div -->
<div class="flex flex-col items-center gap-4">
<img :src="coreMetadataIconUrl" class="size-24 aspect-square" />
<label for="file-upload">
<span
type="button"
class="cursor-pointer relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Upload
</span>
<input
id="file-upload"
accept="image/*"
class="hidden"
type="file"
@change="(e) => coreMetadataUploadFiles(e as any)"
/>
</label>
</div>
<!-- edit title -->
<div class="flex flex-col gap-y-4 grow">
<div>
<label
for="name"
class="block text-sm/6 font-medium text-zinc-100"
>Game Name</label
>
<div class="mt-2">
<input
id="name"
v-model="coreMetadataName"
type="text"
name="name"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<div>
<label
for="description"
class="block text-sm/6 font-medium text-zinc-100"
>Game Description</label
>
<div class="mt-2">
<input
id="description"
v-model="coreMetadataDescription"
type="text"
name="description"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
type="button"
:loading="coreMetadataLoading"
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => coreMetadataUpdate_wrapper()"
>
Save
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showEditCoreMetadata = false"
>
Cancel
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
import type { Game } from "~/prisma/client";
import { micromark } from "micromark";
import {
ArrowTopRightOnSquareIcon,
CheckIcon,
DocumentIcon,
PencilIcon,
PhotoIcon,
} from "@heroicons/vue/24/solid";
definePageMeta({
layout: "admin",
});
const showUploadModal = ref(false);
const showAddCarouselModal = ref(false);
const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true);
const route = useRoute();
const gameId = route.params.id.toString();
const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`,
);
const game = ref(rawGame);
const coreMetadataName = ref(game.value.mName);
const coreMetadataDescription = ref(game.value.mShortDescription);
const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId));
const coreMetadataIconFileUpload = ref<FileList | undefined>();
const coreMetadataLoading = ref(false);
function coreMetadataUploadFiles(e: InputEvent) {
if (coreMetadataIconUrl.value.startsWith("blob")) {
console.log("freed object URL");
URL.revokeObjectURL(coreMetadataIconUrl.value);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
coreMetadataIconFileUpload.value = (e.target as any)?.files;
const file = coreMetadataIconFileUpload.value?.item(0);
if (!file) {
createModal(
ModalType.Notification,
{
title: "Failed to upload file",
description: "Drop couldn't upload this file.",
buttonText: "Close",
},
(e, c) => c(),
);
return;
}
const objectUrl = URL.createObjectURL(file);
coreMetadataIconUrl.value = objectUrl;
}
async function coreMetadataUpdate() {
const formData = new FormData();
const newIcon = coreMetadataIconFileUpload.value?.item(0);
if (newIcon) {
formData.append("icon", newIcon);
}
formData.append("id", game.value.id);
formData.append("name", coreMetadataName.value);
formData.append("description", coreMetadataDescription.value);
const result = await $dropFetch(`/api/v1/admin/game/metadata`, {
method: "POST",
body: formData,
});
return result;
}
function coreMetadataUpdate_wrapper() {
coreMetadataLoading.value = true;
coreMetadataUpdate()
.catch((e) => {
createModal(
ModalType.Notification,
{
title: "Failed to update metadata",
description: `Drop failed to update the game's metadata: ${
e?.statusMessage || "An unknown error occurred. "
}`,
buttonText: "Close",
},
(e, c) => c(),
);
})
.then((newGame) => {
if (!newGame) return;
Object.assign(game.value, newGame);
})
.finally(() => {
coreMetadataLoading.value = false;
showEditCoreMetadata.value = false;
});
}
const descriptionHTML = computed(() =>
micromark(game.value?.mDescription ?? ""),
);
const descriptionEditor = ref<HTMLTextAreaElement | undefined>();
// 0 is not loading
// 1 is waiting for stop
// 2 is loading
const descriptionSaving = ref<number>(0);
let savingTimeout: undefined | NodeJS.Timeout;
watch(descriptionHTML, (_v) => {
console.log(game.value.mDescription);
descriptionSaving.value = 1;
if (savingTimeout) clearTimeout(savingTimeout);
savingTimeout = setTimeout(async () => {
try {
descriptionSaving.value = 2;
await $dropFetch("/api/v1/admin/game", {
method: "PATCH",
body: {
id: gameId,
mDescription: game.value.mDescription,
},
});
descriptionSaving.value = 0;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to update game description",
description: `Drop failed to update the game description: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred."
}`,
buttonText: "Close",
},
(e, c) => c(),
);
}
}, 1500);
});
const validAddCarouselImages = computed(() =>
game.value.mImageLibraryObjectIds.filter(
(e) => !game.value.mImageCarouselObjectIds.includes(e),
),
);
function insertImageAtCursor(id: string) {
showAddImageDescriptionModal.value = false;
if (!descriptionEditor.value || !game.value) return;
const insertPosition = descriptionEditor.value.selectionStart;
const text = `![](/api/v1/object/${id})`;
game.value.mDescription = `${game.value.mDescription.slice(
0,
insertPosition,
)}${text}${game.value.mDescription.slice(insertPosition)}`;
}
async function updateBannerImage(id: string) {
try {
if (game.value.mBannerObjectId == id) return;
const { mBannerObjectId } = await $dropFetch("/api/v1/admin/game", {
method: "PATCH",
body: {
id: gameId,
mBannerId: id,
},
});
game.value.mBannerObjectId = mBannerObjectId;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "There an error while updating the banner image",
description: `Drop encountered an error while updating the banner image: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
}
}
async function updateCoverImage(id: string) {
try {
if (game.value.mCoverObjectId == id) return;
const { mCoverObjectId } = await $dropFetch("/api/v1/admin/game", {
method: "PATCH",
body: {
id: gameId,
mCoverId: id,
},
});
game.value.mCoverObjectId = mCoverObjectId;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "There an error while updating the cover image",
description: `Drop encountered an error while updating the cover image: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
}
}
async function deleteImage(id: string) {
try {
const { mBannerObjectId, mImageLibraryObjectIds } = await $dropFetch(
"/api/v1/admin/game/image",
{
method: "DELETE",
body: {
gameId: game.value.id,
imageId: id,
},
},
);
game.value.mImageLibraryObjectIds = mImageLibraryObjectIds;
game.value.mBannerObjectId = mBannerObjectId;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "There an error while deleting the image",
description: `Drop encountered an error while deleting the image: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
}
}
async function uploadAfterImageUpload(result: Game) {
if (!game.value) return;
game.value.mImageLibraryObjectIds = result.mImageLibraryObjectIds;
}
function addImageToCarousel(id: string) {
showAddCarouselModal.value = false;
game.value.mImageCarouselObjectIds.push(id);
updateImageCarousel();
}
function removeImageFromCarousel(id: string) {
const imageIndex = game.value.mImageCarouselObjectIds.findIndex(
(e) => e == id,
);
game.value.mImageCarouselObjectIds.splice(imageIndex, 1);
updateImageCarousel();
}
async function updateImageCarousel() {
try {
await $dropFetch("/api/v1/admin/game", {
method: "PATCH",
body: {
id: gameId,
mImageCarousel: game.value.mImageCarouselObjectIds,
},
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: "There an error while updating the image carousel",
description: `Drop encountered an error while updating image carousel: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
}
}
</script>

View File

@ -0,0 +1,122 @@
<template>
<div class="space-y-4">
<div class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
Metadata Library
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-gray-500 sm:text-md/8"
>
<span class="text-zinc-100 font-bold"
>To import or delete games, visit the Library tab.</span
>
Here, you can edit and update your game's metadata.
</p>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
placeholder="Search library..."
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="game in filteredLibraryGames"
:key="game.id"
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-700 rounded-lg bg-zinc-950/20 text-left shadow"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-16 w-16 flex-shrink-0 rounded-md"
:src="useObject(game.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3 class="text-sm font-medium text-zinc-100 font-display">
{{ game.mName }}
<span
class="ml-2 inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ game.metadataSource }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">Short Description</dt>
<dd class="text-sm text-zinc-400">
{{ game.mShortDescription }}
</dd>
<dt class="sr-only">Metadata provider</dt>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/metadata/games/${game.id}`"
class="w-fit rounded-md bg-blue-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open with Metadata &rarr;
</NuxtLink>
<NuxtLink
:href="`/admin/library/${game.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open with Library &rarr;
</NuxtLink>
</div>
</div>
</div>
</li>
<p
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
No results
</p>
<p
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
No games imported
</p>
</ul>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
definePageMeta({
layout: "admin",
});
useHead({
title: "Game Library | Metadata",
});
const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library");
const libraryGames = ref(libraryState.games.map((e) => e.game));
const filteredLibraryGames = computed(() =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore excessively deep ts
libraryGames.value.filter((e) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
return true;
return false;
}),
);
</script>

View File

@ -0,0 +1,47 @@
<template>
<div class="space-y-4">
<div class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
Metadata
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-gray-500 sm:text-md/8"
>
Manage the metadata of your library, and update relationships between
them. Users will be able to search through this metadata to find the
games they want.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-5 gap-8">
<NuxtLink
to="/admin/metadata/games"
class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow"
>
<span class="transition-all text-4xl font-bold text-zinc-300 group-hover:text-zinc-100 uppercase tracking-widest"
>GAMES</span
>
</NuxtLink>
<NuxtLink
to="/admin/metadata/companies"
class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow"
>
<span class="transition-all text-4xl font-bold text-zinc-300 group-hover:text-zinc-100 uppercase tracking-widest"
>Companies</span
>
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
useHead({
title: "Metadata",
});
</script>

View File

@ -1,7 +1,7 @@
<template>
<div
class="grow w-full flex items-center justify-center"
v-if="task && task.success"
class="grow w-full flex items-center justify-center"
>
<div class="flex flex-col items-center">
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
@ -18,8 +18,8 @@
</div>
</div>
<div
class="grow w-full flex items-center justify-center"
v-else-if="task && task.error"
class="grow w-full flex items-center justify-center"
>
<div class="flex flex-col items-center">
<ExclamationCircleIcon
@ -50,34 +50,37 @@
</div>
<div class="bg-zinc-950/50 rounded-md p-2 text-zinc-100">
<pre v-for="line in task.log">{{ line }}</pre>
<pre v-for="(line, idx) in task.log" :key="idx">{{ line }}</pre>
</div>
</div>
<div v-else role="status" class="w-full h-screen flex items-center justify-center">
<svg
aria-hidden="true"
class="size-8 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
<div
v-else
role="status"
class="w-full h-screen flex items-center justify-center"
>
<svg
aria-hidden="true"
class="size-8 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from "@heroicons/vue/16/solid";
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import { ExclamationCircleIcon } from "@heroicons/vue/24/solid";
const route = useRoute();
const taskId = route.params.id.toString();

View File

@ -23,9 +23,7 @@
:key="authMech.name"
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900"
>
<div
class="flex items-center gap-x-4 border-b border-zinc-800 p-6"
>
<div class="flex items-center gap-x-4 border-b border-zinc-800 p-6">
<component
:is="authMech.icon"
:alt="`${authMech.name} icon`"
@ -34,7 +32,7 @@
<div class="text-sm/6 font-medium text-zinc-100">
{{ authMech.name }}
</div>
<Menu as="div" class="relative ml-auto">
<Menu v-if="authMech.route" as="div" class="relative ml-auto">
<MenuButton
class="-m-2.5 block p-2.5 text-gray-400 hover:text-gray-500"
>
@ -82,10 +80,11 @@
<div v-if="authMech.settings">
<div
v-for="[key, value] in Object.entries(authMech.settings)"
class="flex justify-between gap-x-4 py-2"
:key="key"
class="flex flex-nowrap justify-between gap-x-4 py-2"
>
<dt class="text-zinc-400">{{ key }}</dt>
<dd class="text-gray-500">
<dd class="text-gray-500 truncate">
{{ value }}
</dd>
</div>
@ -97,27 +96,13 @@
</template>
<script setup lang="ts">
import { IconsSimpleAuthenticationLogo } from "#components";
import { IconsSimpleAuthenticationLogo, IconsSSOLogo } from "#components";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid";
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import type { AuthMec } from "~/prisma/client";
import type { Component } from "vue";
const authenticationMechanisms: Array<{
name: string;
enabled: boolean;
icon: Component;
route: string;
settings?: { [key: string]: string };
}> = [
{
name: "Simple (username/password)",
enabled: true,
icon: IconsSimpleAuthenticationLogo,
route: "/admin/auth/simple",
},
];
useHead({
title: "Authentication",
});
@ -125,4 +110,32 @@ useHead({
definePageMeta({
layout: "admin",
});
const enabledMechanisms = await $dropFetch("/api/v1/admin/auth");
const authenticationMechanisms: Array<{
name: string;
mec: AuthMec;
icon: Component;
route?: string;
enabled: boolean;
settings?: { [key: string]: string | undefined } | undefined | boolean;
}> = [
{
name: "Simple (username/password)",
mec: "Simple" as AuthMec,
icon: IconsSimpleAuthenticationLogo,
route: "/admin/users/auth/simple",
},
{
name: "OpenID Connect",
mec: "OpenID" as AuthMec,
icon: IconsSSOLogo,
},
].map((e) => ({
...e,
enabled: !!enabledMechanisms[e.mec],
settings:
typeof enabledMechanisms[e.mec] === "object" && enabledMechanisms[e.mec],
}));
</script>

View File

@ -26,9 +26,9 @@
</div>
<div class="ml-4 mt-2 shrink-0">
<button
@click="() => (createModalOpen = true)"
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createModalOpen = true)"
>
Create invitation
</button>
@ -84,7 +84,7 @@
</li>
</ul>
<div class="py-4 text-zinc-400 text-sm" v-if="invitations.length == 0">
<div v-if="invitations.length == 0" class="py-4 text-zinc-400 text-sm">
No invitations.
</div>
</div>
@ -119,8 +119,8 @@
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<form
@submit.prevent="() => invite_wrapper()"
class="relative transform rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg"
@submit.prevent="() => invite_wrapper()"
>
<div class="px-4 pb-4 pt-5 space-y-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
@ -158,10 +158,10 @@
<div class="mt-2">
<input
id="username"
v-model="username"
name="invite-username"
type="text"
autocomplete="username"
v-model="username"
placeholder="myUsername"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
@ -185,10 +185,10 @@
<div class="mt-2">
<input
id="email"
v-model="email"
name="invite-email"
type="email"
autocomplete="email"
v-model="email"
placeholder="me@example.com"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
@ -233,7 +233,7 @@
</div>
<div>
<Listbox as="div" v-model="expiryKey">
<Listbox v-model="expiryKey" as="div">
<ListboxLabel
class="block text-sm/6 font-medium text-zinc-100"
>Expires in</ListboxLabel
@ -262,11 +262,11 @@
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
as="template"
v-for="[label, _] in Object.entries(expiry)"
v-for="[label] in Object.entries(expiry)"
:key="label"
:value="label"
v-slot="{ active, selected }"
as="template"
:value="label"
>
<li
:class="[
@ -334,10 +334,10 @@
Invite
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="createModalOpen = false"
ref="cancelButtonRef"
>
Cancel
</button>
@ -352,10 +352,8 @@
</template>
<script setup lang="ts">
import { ClientOnly } from "#build/components";
import {
Dialog,
DialogPanel,
DialogTitle,
TransitionChild,
TransitionRoot,
@ -369,19 +367,12 @@ import {
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import {
ChevronRightIcon,
CheckIcon,
ChevronUpDownIcon,
} from "@heroicons/vue/20/solid";
import {
CalendarDateRangeIcon,
TrashIcon,
XCircleIcon,
} from "@heroicons/vue/24/solid";
import type { Invitation } from "@prisma/client";
import moment from "moment";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { TrashIcon, XCircleIcon } from "@heroicons/vue/24/solid";
import type { Invitation } from "~/prisma/client";
import type { SerializeObject } from "nitropack";
import type { DurationLike } from "luxon";
import { DateTime } from "luxon";
definePageMeta({
layout: "admin",
@ -391,19 +382,17 @@ useHead({
title: "Simple authentication",
});
const headers = useRequestHeaders(["cookie"]);
const { data } = await useFetch<Array<SerializeObject<Invitation>>>(
const data = await $dropFetch<Array<SerializeObject<Invitation>>>(
"/api/v1/admin/auth/invitation",
{ headers }
);
const invitations = ref(data.value ?? []);
const invitations = ref(data ?? []);
const generateInvitationUrl = (id: string) =>
`${window.location.protocol}//${window.location.host}/register?id=${id}`;
`${window.location.protocol}//${window.location.host}/auth/register?id=${id}`;
const invitationUrls = ref<undefined | Array<string>>();
onMounted(() => {
invitationUrls.value = invitations.value.map((invitation) =>
generateInvitationUrl(invitation.id)
generateInvitationUrl(invitation.id),
);
});
@ -419,7 +408,7 @@ const username = computed({
},
});
const validUsername = computed(() =>
_username.value === undefined ? true : _username.value.length >= 5
_username.value === undefined ? true : _username.value.length >= 5,
);
// Same as above
@ -435,31 +424,41 @@ const email = computed({
});
const mailRegex = /^\S+@\S+\.\S+$/;
const validEmail = computed(() =>
_email.value === undefined ? true : mailRegex.test(email.value as string)
_email.value === undefined ? true : mailRegex.test(email.value as string),
);
const isAdmin = ref(false);
// Label to parameters to moment.js .add()
const expiry = {
"3 days": [3, "days"],
"7 days": [7, "days"],
"1 month": [1, "month"],
"6 months": [6, "month"],
"1 year": [1, "year"],
Never: [3000, "year"], // Never is relative, right?
const expiry: Record<string, DurationLike> = {
"3 days": {
days: 3,
},
"7 days": {
days: 7,
},
"1 month": {
month: 1,
},
"6 months": {
months: 6,
},
"1 year": {
year: 1,
},
Never: {
year: 3000,
}, // Never is relative, right?
};
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0] as any); // Cast to any because we just know it's okay
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0]); // Cast to any because we just know it's okay
const loading = ref(false);
const error = ref<undefined | string>();
async function invite() {
const expiryDate = moment()
.add(...expiry[expiryKey.value])
.toJSON();
const expiryDate = DateTime.now().plus(expiry[expiryKey.value]).toJSON();
const newInvitation = await $fetch("/api/v1/admin/auth/invitation", {
const newInvitation = await $dropFetch("/api/v1/admin/auth/invitation", {
method: "POST",
body: {
username: username.value,
@ -473,7 +472,7 @@ async function invite() {
email.value = "";
username.value = "";
isAdmin.value = false;
expiryKey.value = Object.keys(expiry)[0] as any; // Same reason as above
expiryKey.value = Object.keys(expiry)[0]; // Same reason as above
return newInvitation;
}
@ -495,7 +494,7 @@ function invite_wrapper() {
}
async function deleteInvitation(id: string) {
await $fetch("/api/v1/admin/auth/invitation", {
await $dropFetch("/api/v1/admin/auth/invitation", {
method: "DELETE",
body: {
id: id,

110
pages/admin/users/index.vue Normal file
View File

@ -0,0 +1,110 @@
<template>
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">Users</h1>
<p class="mt-2 text-sm text-zinc-400">
Manage the users on your Drop instance, and configure your
authentication methods.
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/users/auth"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Authentication &rarr;
</NuxtLink>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-zinc-700">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
Display Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Username
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Email
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Admin?
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Auth Options
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id" class="even:bg-zinc-800">
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ user.displayName }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ user.username }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ user.email }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ user.admin ? "Admin User" : "Normal user" }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ user.authMecs.map((e) => e.mec).join(", ") }}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-3"
>
<!--
<NuxtLink to="#" class="text-blue-600 hover:text-blue-500"
>Edit<span class="sr-only"
>, {{ user.displayName }}</span
></NuxtLink
>
-->
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
useHead({
title: "Users",
});
definePageMeta({
layout: "admin",
});
const users = await $dropFetch("/api/v1/admin/users");
</script>

View File

@ -3,7 +3,7 @@
class="flex min-h-screen bg-zinc-950 flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<Logo class="mx-auto h-10 w-auto" />
<DropLogo class="mx-auto h-10 w-auto" />
<h2
class="mt-6 text-center text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
>
@ -23,11 +23,11 @@
<div class="mt-2">
<input
id="display-name"
v-model="displayName"
name="display-name"
type="text"
autocomplete="display-name"
required
v-model="displayName"
placeholder="AwesomeDropGamer771"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
@ -51,12 +51,12 @@
<div class="mt-2">
<input
id="email"
v-model="email"
name="email"
type="email"
autocomplete="email"
required
:disabled="!!invitation.data.value?.email"
v-model="email"
:disabled="!!invitation?.email"
placeholder="me@example.com"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
@ -82,12 +82,12 @@
<div class="mt-2">
<input
id="username"
v-model="username"
name="username"
type="text"
autocomplete="username"
required
:disabled="!!invitation.data.value?.username"
v-model="username"
:disabled="!!invitation?.username"
placeholder="myUsername"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
@ -113,11 +113,11 @@
<div class="mt-2">
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="password"
required
v-model="password"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
@ -140,11 +140,11 @@
<div class="mt-2">
<input
id="confirm-password"
v-model="confirmPassword"
name="confirm-password"
type="password"
autocomplete="confirm-password"
required
v-model="confirmPassword"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
@ -175,11 +175,11 @@
<p v-if="false" class="mt-10 text-center text-sm text-zinc-400">
What's Drop?
{{ " " }}
<a
href="https://github.com/Drop-OSS/drop"
<NuxtLink
to="https://github.com/Drop-OSS/drop"
target="_blank"
class="font-semibold leading-6 text-blue-600 hover:text-blue-500"
>Check us out here &rarr;</a
>Check us out here &rarr;</NuxtLink
>
</p>
</div>
@ -188,6 +188,7 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { type } from "arktype";
const route = useRoute();
const router = useRouter();
@ -198,33 +199,39 @@ if (!invitationId)
statusMessage: "Invitation required to sign up.",
});
const invitation = await useFetch(
`/api/v1/auth/signup/simple?id=${encodeURIComponent(invitationId)}`
const invitation = await $dropFetch(
`/api/v1/auth/signup/simple?id=${encodeURIComponent(invitationId)}`,
);
const email = ref(invitation.data.value?.email);
const email = ref(invitation?.email);
const displayName = ref("");
const username = ref(invitation.data.value?.username);
const username = ref(invitation?.username);
const password = ref("");
const confirmPassword = ref(undefined);
const mailRegex = /^\S+@\S+\.\S+$/;
const validEmail = computed(() => mailRegex.test(email.value ?? ""));
const validUsername = computed(
() =>
(username.value?.length ?? 0) >= 5 &&
username.value?.toLowerCase() == username.value
const emailValidator = type("string.email");
const validEmail = computed(
() => !(emailValidator(email.value) instanceof type.errors),
);
const usernameValidator = type("string.alphanumeric >= 5").to("string.lower");
const validUsername = computed(
() => !(usernameValidator(username.value) instanceof type.errors),
);
const passwordValidator = type("string >= 14");
const validPassword = computed(
() => !(passwordValidator(password.value) instanceof type.errors),
);
const validPassword = computed(() => (password.value?.length ?? 0) >= 14);
const validConfirmPassword = computed(
() => password.value == confirmPassword.value
() => password.value == confirmPassword.value,
);
const loading = ref(false);
const error = ref<string | undefined>(undefined);
async function register() {
await $fetch("/api/v1/auth/signup/simple", {
await $dropFetch("/api/v1/auth/signup/simple", {
method: "POST",
body: {
invitation: invitationId,
@ -248,7 +255,7 @@ function register_wrapper() {
loading.value = true;
register()
.then(() => {
router.push("/signin");
router.push("/auth/signin");
})
.catch((response) => {
const message = response.statusMessage || "An unknown error occurred";

58
pages/auth/signin.vue Normal file
View File

@ -0,0 +1,58 @@
<template>
<div class="flex min-h-screen flex-1 bg-zinc-900">
<div
class="flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24"
>
<div class="mx-auto w-full max-w-sm lg:w-96">
<div>
<DropLogo class="h-10 w-auto" />
<h2
class="mt-8 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
>
Sign in to your account
</h2>
<p class="mt-2 text-sm leading-6 text-zinc-400">
Don't have an account? Ask an admin to create one for you.
</p>
</div>
<div class="mt-10">
<div>
<AuthSimple v-if="enabledAuths.includes('Simple' as AuthMec)" />
<div
v-if="enabledAuths.length > 1"
class="py-4 flex flex-row items-center justify-center gap-x-4 font-bold text-sm text-zinc-600"
>
<span class="h-[1px] grow bg-zinc-600" />
OR
<span class="h-[1px] grow bg-zinc-600" />
</div>
<AuthOpenID v-if="enabledAuths.includes('OpenID' as AuthMec)" />
</div>
</div>
</div>
</div>
<div class="relative hidden w-0 flex-1 lg:block">
<img
src="/wallpapers/signin.jpg"
class="absolute inset-0 h-full w-full object-cover"
alt=""
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { AuthMec } from "~/prisma/client";
import DropLogo from "~/components/DropLogo.vue";
const enabledAuths = await $dropFetch("/api/v1/auth");
definePageMeta({
layout: false,
});
useHead({
title: "Sign in to Drop",
});
</script>

View File

@ -1,7 +1,7 @@
<template>
<div
class="min-h-full w-full flex items-center justify-center"
v-if="completed"
class="min-h-full w-full flex items-center justify-center"
>
<div class="flex flex-col items-center">
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
@ -15,7 +15,7 @@
window.
</p>
<Disclosure as="div" class="mt-8" v-slot="{ open }">
<Disclosure v-slot="{ open }" as="div" class="mt-8">
<dt>
<DisclosureButton
class="pb-2 flex w-full items-start justify-between text-left text-zinc-400"
@ -47,7 +47,7 @@
</div>
</div>
<main
v-else-if="clientData.data.value"
v-else-if="clientData"
class="mx-auto grid lg:grid-cols-2 max-w-md lg:max-w-none min-h-full place-items-center w-full gap-4 px-6 py-12 sm:py-32 lg:px-8"
>
<div>
@ -58,8 +58,7 @@
Authorize client?
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
"{{ clientData.data.value.name }}" has requested access to your Drop
account.
"{{ clientData.name }}" has requested access to your Drop account.
</p>
<div
action="/api/v1/client/callback"
@ -68,8 +67,8 @@
>
<input type="text" class="hidden" name="id" :value="clientId" />
<button
@click="() => authorize_wrapper()"
class="rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => authorize_wrapper()"
>
Authorize
</button>
@ -94,8 +93,9 @@
<p
class="mt-6 font-semibold font-display text-lg leading-8 text-zinc-100"
>
Accepting this request will allow "{{ clientData.data.value.name }}"
on "{{ clientData.data.value.platform }}" to:
Accepting this request will allow "{{ clientData.name }}" on "{{
clientData.platform
}}" to:
</p>
</div>
<div class="mt-8 max-w-2xl sm:mt-12 lg:mt-14">
@ -132,22 +132,6 @@
</div>
</div>
</main>
<main
v-else-if="clientData.error.value != undefined"
class="grid min-h-full w-full place-items-center px-6 py-24 sm:py-32 lg:px-8"
>
<div class="text-center">
<p class="text-base font-semibold text-blue-600">400</p>
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Invalid or expired request
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
Unfortunately, we couldn't load the authorization request.
</p>
</div>
</main>
</template>
<script setup lang="ts">
@ -164,10 +148,8 @@ import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
const route = useRoute();
const clientId = route.params.id;
const headers = useRequestHeaders(["cookie"]);
const clientData = await useFetch(
const clientData = await $dropFetch(
`/api/v1/client/auth/callback?id=${clientId}`,
{ headers }
);
const completed = ref(false);
@ -175,7 +157,7 @@ const error = ref();
const authToken = ref<string | undefined>();
async function authorize() {
const { redirect, token } = await $fetch("/api/v1/client/auth/callback", {
const { redirect, token } = await $dropFetch("/api/v1/client/auth/callback", {
method: "POST",
body: { id: clientId },
});

Some files were not shown because too many files have changed in this diff Show More