94 Commits

Author SHA1 Message Date
5c1b0e6c1e feat: uninstall commands, new R UI 2025-09-07 17:30:24 +10:00
d84c70a05f fix: remove platform 2025-09-06 19:23:02 +10:00
bfd5c8e761 fix: redelete platform 2025-09-06 18:33:27 +10:00
3311aa7274 Merge branch 'develop' into redistributable 2025-09-06 18:32:09 +10:00
fcfc30e5df feat: import of custom platforms & file extensions 2025-09-06 18:29:04 +10:00
7266d0485b fix: update drop-base commit 2025-09-06 15:20:31 +10:00
a520d52ad3 Preallocate download streams (#229)
* feat: pre-allocate streams for high-latency downloads

* fix: update drop-base

* fix: remove debug latency

* fix: lint
2025-08-31 14:50:56 +10:00
aa1de921ee Switch to pnpm (#162)
* fix: metadata provider log

* feat: fully switch to pnpm

* ci: prettier ignore pnpm lock

* chore: dedupe lockfile

* chore: update pnpm
2025-08-31 09:53:55 +10:00
bfeacbbdfe Delete yarn.lock 2025-08-31 09:45:04 +10:00
cf3a458bdf feat: add server side redist patching 2025-08-28 11:14:38 +10:00
ca7a89bbcf feat: beginnings of platform & redist management 2025-08-27 19:52:36 +10:00
d323816b9e fix: sanitize svg uploads
... copilot suggested this

I feel dirty.
2025-08-27 12:21:17 +10:00
367d349a68 feat: add user platform filters to store view 2025-08-27 12:16:27 +10:00
8efddc07bc feat: partial user platform support + statusMessage -> message 2025-08-27 11:25:23 +10:00
afce9f159a Update drop-base commit 2025-08-26 09:19:22 +10:00
3af00e085e fix: giantbomb logging bug 2025-08-25 16:22:53 +10:00
b7d685814b Merge branch 'develop' into redistributable 2025-08-25 16:19:48 +10:00
fd828d5b50 Update droplet & other small features, and bump version for v0.3.3 (#212)
* fix: bump version and fix context timeout issues

* fix: bump droplet

* feat: add appimage auto-detection (#209)
2025-08-25 13:23:46 +10:00
b33e27e446 API tokens (#201)
* fix: small fixes to request util and version update endpoint

* feat: api token creation and management

* fix: lint

* fix: remove unneeded sidebar component
2025-08-23 13:58:52 +10:00
c97a56eb42 Init Prisma in Dockerfile (#204) 2025-08-23 07:55:37 +10:00
5e5519ece7 chore(deps): bump vite-plugin-static-copy from 3.1.1 to 3.1.2 (#199)
Bumps [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy) from 3.1.1 to 3.1.2.
- [Release notes](https://github.com/sapphi-red/vite-plugin-static-copy/releases)
- [Changelog](https://github.com/sapphi-red/vite-plugin-static-copy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sapphi-red/vite-plugin-static-copy/compare/vite-plugin-static-copy@3.1.1...vite-plugin-static-copy@3.1.2)

---
updated-dependencies:
- dependency-name: vite-plugin-static-copy
  dependency-version: 3.1.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 13:49:31 +10:00
f1957a418c feat: import redists 2025-08-22 13:48:47 +10:00
322af0b4ca feat: rearchitecture of database schemas, migration reset, and #180 2025-08-20 20:35:50 +10:00
6853383e86 feat: database redist support 2025-08-20 11:50:59 +10:00
6d89b7e510 Admin task UI update & QoL (#194)
* feat: revise library source names & update droplet

* feat: add internal name hint to library sources

* feat: update library source table with new name + icons

* fix: admin invitation localisation issue

* feat: #164

* feat: overhaul task UIs, #163

* fix: remove debug task

* fix: lint
2025-08-19 15:03:20 +10:00
6baddc10e9 Fix non-unicode characters in game path (#193)
* replace btoa with a Buffer implementation, as btoa does not support non-unicode characters.

* replace btoa with a Buffer implementation, as btoa does not support non-unicode characters.

* fix linting

* fix linting

* replace buffer implementation with a md5 hash. This also adds the ts-md5 library.

* Revert "replace buffer implementation with a md5 hash. This also adds the ts-md5 library."

This reverts commit f98b811ab9.

* replace buffer implementation with md5 hash from node:crypto

* fix linting.. again

---------

Co-authored-by: FurbyOnSteroids <codeberg@your-moms-bellybutton.hair>
2025-08-16 22:23:57 +10:00
a2ea0060cb Merge pull request #191 from Drop-OSS/weblate
Translations update from Weblate
2025-08-16 12:06:53 +10:00
6aaab30439 Merge remote-tracking branch 'origin/develop' into develop 2025-08-16 02:05:27 +00:00
ea5d108a10 Translated using Weblate (French)
Currently translated at 98.2% (450 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-16 12:02:48 +10:00
f0b127789f Translated using Weblate (English (en_PIRATE))
Currently translated at 83.8% (384 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-16 12:02:48 +10:00
4c8be2bfd1 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/
2025-08-16 12:02:47 +10:00
7e371adeb0 Translated using Weblate (French)
Currently translated at 97.3% (446 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-16 12:02:47 +10:00
6d7b491adb Translated using Weblate (French)
Currently translated at 97.8% (448 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-16 12:02:36 +10:00
abec952e39 Various fixes (#186)
* fix: #181

* fix: use taskHandler as source of truth for imports

* fix: task formatting

* fix: zip downloads

* feat: re-enable import version button on delete + lint
2025-08-15 22:57:56 +10:00
9ff541059d chore(deps): bump tmp from 0.2.3 to 0.2.4 (#179)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.3 to 0.2.4.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.3...v0.2.4)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-13 08:28:06 +10:00
b84d1f20b5 v2 download API and Admin UI fixes (#177)
* fix: small ui fixes

* feat: #171

* fix: improvements to library scanning on admin UI

* feat: v2 download API

* fix: add download context cleanup

* fix: lint
2025-08-09 15:45:39 +10:00
ecc806dc07 Translated using Weblate (French)
Currently translated at 98.2% (450 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-06 21:06:35 +00:00
45c94cfcbf Translated using Weblate (English (en_PIRATE))
Currently translated at 83.8% (384 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-06 21:06:35 +00:00
f6f972c2d6 Translations update from Weblate (#172)
* Translated using Weblate (English)

Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 80.7% (370 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

* Translated using Weblate (English)

Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 83.4% (382 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

* Added translation using Weblate (Russian)

* Translated using Weblate (French)

Currently translated at 49.1% (225 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/

* Translated using Weblate (German)

Currently translated at 54.3% (249 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/

* Translated using Weblate (German)

Currently translated at 54.3% (249 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/

* Translated using Weblate (Russian)

Currently translated at 6.1% (28 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/ru/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 84.0% (385 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

* Translated using Weblate (French)

Currently translated at 97.8% (448 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/

* Translated using Weblate (French)

Currently translated at 97.8% (448 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/

* Translated using Weblate (German)

Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/

* Translated using Weblate (German)

Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/

* Translated using Weblate (German)

Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/

* Translated using Weblate (English)

Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/

* Translated using Weblate (French)

Currently translated at 97.3% (446 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/

* Update translation files

Updated by "Remove blank strings" add-on in Weblate.

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/

---------

Co-authored-by: Husky <husky@disroot.org>
Co-authored-by: Ribemont Francois <ribemont.francois+weblate@gmail.com>
Co-authored-by: Hicks <hicksgaming99+weblate@gmail.com>
Co-authored-by: Kuschiniko <nico.kusch@outlook.de>
Co-authored-by: Dmitrii <nossster@gmail.com>
Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
2025-08-06 17:49:07 +10:00
e1dc26f676 README fixes (#174) 2025-08-06 17:48:25 +10:00
2fec40c5a6 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/
2025-08-06 02:57:46 +00:00
8f572e1259 Translated using Weblate (French)
Currently translated at 97.3% (446 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-06 02:57:46 +00:00
43aa15d45c Translated using Weblate (English)
Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/
2025-08-06 02:57:46 +00:00
59a5540248 Translated using Weblate (German)
Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-06 00:05:00 +00:00
5bfb3e0f68 Translated using Weblate (German)
Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-06 00:05:00 +00:00
c04f6cbf80 Translated using Weblate (German)
Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-06 00:05:00 +00:00
d2863fa95b Translated using Weblate (French)
Currently translated at 97.8% (448 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-06 00:05:00 +00:00
821fd2cf2d Translated using Weblate (French)
Currently translated at 97.8% (448 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-06 00:05:00 +00:00
6f84ad42fc Translated using Weblate (English (en_PIRATE))
Currently translated at 84.0% (385 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-06 00:04:59 +00:00
1d1157a902 Translated using Weblate (Russian)
Currently translated at 6.1% (28 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/ru/
2025-08-05 19:50:38 +00:00
6ca9e34c7e Translated using Weblate (German)
Currently translated at 54.3% (249 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-05 19:50:38 +00:00
bc29c468d8 Translated using Weblate (German)
Currently translated at 54.3% (249 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-05 19:50:38 +00:00
925ea1a414 Translated using Weblate (French)
Currently translated at 49.1% (225 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-05 19:50:38 +00:00
c9addd407e Added translation using Weblate (Russian) 2025-08-05 01:47:18 +00:00
242ae09857 Translated using Weblate (English (en_PIRATE))
Currently translated at 83.4% (382 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-04 17:18:11 +00:00
ba28c52912 Translated using Weblate (English)
Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/
2025-08-04 17:18:11 +00:00
a98c95e695 Translated using Weblate (English (en_PIRATE))
Currently translated at 80.7% (370 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-04 17:18:11 +00:00
26615ccad0 Translated using Weblate (English)
Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/
2025-08-04 17:18:11 +00:00
0b0972b48d Small IO tweaks, robots.txt, and README improvements (#173)
* feat: add link to drop version in footer

* feat: add drop logo aria label

* feat: disable all crawling by bots

for now i think this is a good default as all of drop is currently behind auth

* feat: hide logo when inside wordmark for aria

* docs: update readme and contributing

* feat: default page in setup wizzard is img

* ci: remove redundant perm in release ci

* docs: update translation links and add progress image

* fix: lang selector using wrong weblate link
2025-08-04 16:30:22 +10:00
a435ead916 Fix various typos (#156)
Found via `codespell -q 3 -S "./yarn.lock" -L pris`
2025-08-01 21:53:31 +10:00
545a6b154a Fix #119 (#153) 2025-08-01 16:26:27 +10:00
442f940cc4 Translated using Weblate (English) (#151)
Currently translated at 100.0% (456 of 456 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/
2025-08-01 14:31:50 +10:00
7d9525084d feat: bump version (#150) 2025-08-01 14:02:44 +10:00
72c972a2a7 Fix for undeleted games from library sources (#148)
* fix: casade delete for games and library sources

* fix: add bug workaround

* fix: lint
2025-08-01 14:00:10 +10:00
b72e1ef7a4 Code-based authorization for Drop clients (#145)
* feat: code-based authorization

* fix: final touches

* fix: require session on code fetch endpoint

* feat: better error handling

* refactor: move auth send to client handler

* fix: lint
2025-08-01 13:11:56 +10:00
786ad0ff82 Translations update from Weblate (#107)
* Translated using Weblate (English (Australia))

Currently translated at 1.6% (6 of 375 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_AU/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 99.4% (373 of 375 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 99.4% (373 of 375 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 100.0% (375 of 375 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

---------

Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org>
2025-08-01 10:38:53 +10:00
00224bdd2f Fix for incorrect description on company page 2025-07-31 21:49:15 +10:00
371e069e20 Fix for invalid route in setup wizard 2025-07-31 21:44:25 +10:00
91bb7c7dd4 Fix for Release CI 2025-07-31 21:39:01 +10:00
4e901164fb Fix Github Release CI (#144)
* fix: attempt fix from https://github.com/reproducible-containers/buildkit-cache-dance

* fix: lint

* fix: migrate to pnpm to see if it builds

* fix: comment out unified deps builder

* fix: remove dependency on deps

* fix: shamefully hoist deps
2025-07-31 21:36:01 +10:00
244f20b5f4 v0.3.0 2025-07-31 20:41:42 +10:00
e4c8d42cc8 Setup wizard & 0.3.0 release (#146)
* fix: small merge fixes

* feat: initial setup wizard

* fix: last few localization items

* fix: lint

* fix: bump version
2025-07-31 20:41:02 +10:00
ed99e020df Merge branch 'main' into develop 2025-07-31 18:03:19 +10:00
8363de2eed Store overhaul (#142)
* feat: small library tweaks + company page

* feat: new store view

* fix: ci merge error

* feat: add genres to store page

* feat: sorting

* feat: lock game/version imports while their tasks are running

* feat: feature games

* feat: tag based filtering

* fix: make tags alphabetical

* refactor: move a bunch of i18n to common

* feat: add localizations for everything

* fix: title description on panel

* fix: feature carousel text

* fix: i18n footer strings

* feat: add tag page

* fix: develop merge

* feat: offline games support (don't error out if provider throws)

* feat: tag management

* feat: show library next to game import + small fixes

* feat: most of the company and tag managers

* feat: company text field editing

* fix: small fixes + tsgo experiemental

* feat: upload icon and banner

* feat: store infinite scrolling and bulk import mode

* fix: lint

* fix: add drop-base to prettier ignore
2025-07-30 13:40:49 +10:00
1ae051f066 Update Prisma to 6.11 (#133)
* chore: update prisma to 6.11

more prisma future proofing due to experimental features

* chore: update dependencies

twemoji - new unicode update
argon2 - bux fixes
vue3-carousel - improve mobile experiance
vue-tsc - more stable

* fix: incorrect prisma version in docker

Also remove default value for BUILD_DROP_VERSION, that is now handled in nuxt config

* fix: no logging in prod

* chore: optimize docker builds even more

* fix: revert adoption of prisma driverAdapters

see: https://github.com/prisma/prisma/issues/27486

* chore: optimize dockerignore some more

* Fix `pino-pretty` not being included in build (#135)

* Remove `pino` from frontend

* Fix for downloads and removing of library source (#136)

* fix: downloads and removing library source

* fix: linting

* Fix max file size of 4GB (update droplet) (#137)

* Fix manual metadata import (#138)

* chore(deps): bump vue-i18n from 10.0.7 to 10.0.8 (#140)

Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 10.0.7 to 10.0.8.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.8/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-version: 10.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump @intlify/core from 10.0.7 to 10.0.8 (#139)

---
updated-dependencies:
- dependency-name: "@intlify/core"
  dependency-version: 10.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Small fixes (#141)

* fix: save task as Json rather than string

* fix: pull objects before creating game in database

* fix: strips relative dirs from version information

* fix: #132

* fix: lint

* fix: news object ids and small tweaks

* fix: notification styling errors

* fix: lint

* fix: build issues by regenerating lockfile

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-25 21:28:00 +10:00
45848d175e Small fixes (#141)
* fix: save task as Json rather than string

* fix: pull objects before creating game in database

* fix: strips relative dirs from version information

* fix: #132

* fix: lint

* fix: news object ids and small tweaks

* fix: notification styling errors

* fix: lint
2025-07-20 14:56:15 +10:00
661dcf86a8 chore(deps): bump @intlify/core from 10.0.7 to 10.0.8 (#139)
---
updated-dependencies:
- dependency-name: "@intlify/core"
  dependency-version: 10.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-20 14:29:49 +10:00
a7b9bbc78a chore(deps): bump vue-i18n from 10.0.7 to 10.0.8 (#140)
Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 10.0.7 to 10.0.8.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.8/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-version: 10.0.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-20 14:29:37 +10:00
75842fbfb6 Fix manual metadata import (#138) 2025-07-14 17:26:00 +10:00
4ef2ab2587 Fix max file size of 4GB (update droplet) (#137) 2025-07-14 16:43:42 +10:00
935ff48b15 Fix for downloads and removing of library source (#136)
* fix: downloads and removing library source

* fix: linting
2025-07-14 15:34:10 +10:00
51390e115f Remove pino from frontend 2025-07-14 14:20:17 +10:00
7bfc441d1d Fix pino-pretty not being included in build (#135) 2025-07-14 12:11:17 +10:00
2b70cea4e0 Logging (#131)
* ci: pull version from package.json on build

* fix: implicit any type

* feat: inital support for logger

* style: fix lint

* feat: move more logging over to pino

* fix: logging around company importing
2025-07-09 12:01:23 +10:00
e4fbc7cd50 Toggle for showing title & description overlay on store page #51 (#130)
* #51 Adds settings page with showTitleDescriptionOnGamePanel

* Removes console.log

* Renames isHidden to system, adds missing system column on Game and fixes nitro plugin on fresh database

* Implements a different way to handle the placeholder image

* Removes system column on Game

* Groups settings keys together

* Removes unused i18n keys

* fix: fix eslints and other small tweaks

---------

Co-authored-by: Francois Ribemont <ribemont.francois@gmail.com>
2025-07-06 13:13:57 +10:00
706f2aac83 FlatLibrary provider (#127) 2025-07-06 12:44:41 +10:00
73c27f0984 User invite uses external domain option (#118)
* feat: user invite uses external domain option
fixes #117

* fix: inconsistent external url format

* fix: normalize external url more cleanly
2025-07-01 09:11:26 +10:00
12837d44fe Fix linting 2025-07-01 09:09:10 +10:00
12d87d6256 Fix CodeQL warnings 2025-06-28 12:00:42 +10:00
856babbc21 fix: null strings in setup versions 2025-02-13 13:34:46 +11:00
aad5c23f45 fix: import ui setup autocomplete 2025-02-13 13:34:45 +11:00
9f456cec9d chore: Update changelog.md 2025-01-25 22:49:06 +11:00
a95be39c17 Merge pull request #18 from Drop-OSS/develop
Merge develop into main
2025-01-25 19:27:22 +11:00
c6bb21d9ee fix: Update README.md with discord link 2025-01-08 22:43:30 +00:00
405 changed files with 25041 additions and 13574 deletions

View File

@ -1,3 +1,9 @@
Dockerfile
.github
.vscode
*.md
#### gitignore below
# Nuxt dev/build outputs # Nuxt dev/build outputs
.output .output
.data .data
@ -24,8 +30,14 @@ logs
.env.* .env.*
!.env.example !.env.example
.data
# deploy template # deploy template
deploy-template/ deploy-template/*
!deploy-template/compose.yml
# generated prisma client # generated prisma client
/prisma/client /prisma/client
/prisma/validate

View File

@ -2,4 +2,4 @@ DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
GIANT_BOMB_API_KEY="" GIANT_BOMB_API_KEY=""
EXTERNAL_URL="localhost:3000" EXTERNAL_URL="http://localhost:3000"

View File

@ -8,6 +8,9 @@ on:
branches: branches:
- develop - develop
permissions:
contents: read
jobs: jobs:
typecheck: typecheck:
name: Typecheck name: Typecheck
@ -18,17 +21,20 @@ jobs:
with: with:
submodules: true submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js environment - name: Setup Node.js environment
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: lts/*
cache: "yarn" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
run: yarn install --immutable --network-timeout 1000000 run: pnpm install
- name: Typecheck - name: Typecheck
run: yarn typecheck run: pnpm run typecheck
lint: lint:
name: Lint name: Lint
@ -39,14 +45,17 @@ jobs:
with: with:
submodules: true submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js environment - name: Setup Node.js environment
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: lts/*
cache: "yarn" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
run: yarn install --immutable --network-timeout 1000000 run: pnpm install
- name: Lint - name: Lint
run: yarn lint run: pnpm run lint

View File

@ -20,7 +20,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
fetch-depth: 3 # fix for when this gets triggered by tag
fetch-tags: true fetch-tags: true
ref: ${{ github.ref }}
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine final version - name: Determine final version
@ -75,6 +77,21 @@ jobs:
# set latest tag for stable releases # set latest tag for stable releases
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }} type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
- name: Cache
uses: actions/cache@v4
id: cache
with:
path: cache-mount
key: cache-mount-${{ hashFiles('Dockerfile') }}
- name: Restore Docker cache mounts
uses: reproducible-containers/buildkit-cache-dance@v3
with:
builder: ${{ steps.setup-buildx.outputs.name }}
cache-dir: cache-mount
dockerfile: Dockerfile
skip-extraction: ${{ steps.cache.outputs.cache-hit }}
- name: Build and push image - name: Build and push image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6

View File

@ -1 +1,3 @@
drop-base/ drop-base/
# file is fully managed by pnpm, no reason to break it
pnpm-lock.yaml

View File

@ -16,11 +16,15 @@ you would make is not already covered.
- [Reporting Issues](#reporting-issues) - [Reporting Issues](#reporting-issues)
- [You have a problem](#you-have-a-problem) - [You have a problem](#you-have-a-problem)
- [You have a suggestion](#you-have-a-suggestion) - [You have a suggestion](#you-have-a-suggestion)
- [Development](#development)
- [Note: `--optional` flag is **REQUIRED**](#note-optional-flag-is-required)
- [Tech Stack](#tech-stack)
- [Submitting Pull Requests](#submitting-pull-requests) - [Submitting Pull Requests](#submitting-pull-requests)
- [Getting started](#getting-started) - [Getting started](#getting-started)
- [You have a solution](#you-have-a-solution) - [You have a solution](#you-have-a-solution)
- [You have an addition](#you-have-an-addition) - [You have an addition](#you-have-an-addition)
- [Use the Search, Luke](#use-the-search-luke) - [Use the Search, Luke](#use-the-search-luke)
- [Translation](#translation)
- [Commit Guidelines](#commit-guidelines) - [Commit Guidelines](#commit-guidelines)
- [Format](#format) - [Format](#format)
- [Style](#style) - [Style](#style)
@ -65,6 +69,31 @@ If you find one, comment on it, so we know more people are supporting it.
If not, you can go ahead and create an issue. Please copy to anyone relevant (e.g. plugin If not, you can go ahead and create an issue. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message. maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
## Development
To get started with development, you need `yarn` and `docker compose` installed (or know how to set up a PostgreSQL database).
Steps:
1. Run `git submodule update --init --recursive` to setup submodules
1. Copy the `.env.example` to `.env` and add any api keys you need to use (e.g. for the Giant Bomb API)
- You can find other configuration options in the [documentation](https://docs.droposs.org/)
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 prisma migrate dev` to setup the database
1. Run `yarn dev` to start the development 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/auth/register?id=admin
### Tech Stack
This repo uses the Nuxt 3 + TailwindCSS stack, with the `yarn` package manager.
For the database, Drop uses Prisma connected to PostgreSQL.
## Submitting Pull Requests ## Submitting Pull Requests
### Getting started ### Getting started
@ -132,7 +161,7 @@ and [create an issue](#reporting-issues) or [submit a PR](#submitting-pull-reque
## Translation ## Translation
If you want to help translate Drop, we would love to have your help! You can do so on our weblate instance. Please make sure to read the [message format syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) page before starting. Failure to do so may result in your translations causing errors in Drop. If you want to help translate Drop, we would love to have your help! You can do so on our [weblate instance](https://translate.droposs.org/engage/drop/). Please make sure to **read** the [message format syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) page before starting. We use this special syntax to enable high quality translations, and failure to do so may result in your translations **causing errors** in Drop.
## Commit Guidelines ## Commit Guidelines
@ -148,7 +177,6 @@ type(scope)!: subject
``` ```
- `type`: the type of the commit is one of the following: - `type`: the type of the commit is one of the following:
- `feat`: new features. - `feat`: new features.
- `fix`: bug fixes. - `fix`: bug fixes.
- `docs`: documentation changes. - `docs`: documentation changes.
@ -165,7 +193,6 @@ type(scope)!: subject
- `scope`: section of the codebase that the commit makes changes to. If it makes changes to - `scope`: section of the codebase that the commit makes changes to. If it makes changes to
many sections, or if no section in particular is modified, leave blank without the parentheses. many sections, or if no section in particular is modified, leave blank without the parentheses.
Examples: Examples:
- Commit that changes the `git` plugin: - Commit that changes the `git` plugin:
``` ```
@ -179,7 +206,6 @@ type(scope)!: subject
``` ```
For changes to plugins or themes, the scope should be the plugin or theme name: For changes to plugins or themes, the scope should be the plugin or theme name:
- ✅ `fix(agnoster): commit subject` - ✅ `fix(agnoster): commit subject`
- ❌ `fix(theme/agnoster): commit subject` - ❌ `fix(theme/agnoster): commit subject`
@ -209,7 +235,6 @@ type(scope)!: subject
to specify other details, you can use the commit body, but it won't be visible. to specify other details, you can use the commit body, but it won't be visible.
Formatting tricks: the commit subject may contain: Formatting tricks: the commit subject may contain:
- Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool: - Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool:
``` ```

View File

@ -1,15 +1,26 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Unified deps builder FROM node:lts-alpine AS base
FROM node:lts-alpine AS deps ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --network-timeout 1000000 --ignore-scripts
# Build for app # so corepack knows pnpm's version
FROM node:lts-alpine AS build-system COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# setup workdir - has to be the same filepath as app because fuckin' Prisma # prevent prompt to download
WORKDIR /app ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# setup for offline
RUN corepack pack
# don't call out to network anymore
ENV COREPACK_ENABLE_NETWORK=0
### Unified deps builder
FROM base AS deps
RUN pnpm install --frozen-lockfile --ignore-scripts
### Build for app
FROM base AS build-system
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
@ -21,21 +32,23 @@ RUN apk add --no-cache git
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG BUILD_DROP_VERSION="v0.0.0-unknown.1" ARG BUILD_DROP_VERSION
ARG BUILD_GIT_REF ARG BUILD_GIT_REF
# build # build
RUN yarn postinstall RUN pnpm run postinstall && pnpm run build
RUN yarn build
# create run environment for Drop ### create run environment for Drop
FROM node:lts-alpine AS run-system FROM base AS run-system
WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
RUN yarn add --network-timeout 1000000 --no-lockfile prisma@6.7.0 # RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
RUN apk add --no-cache pnpm
RUN pnpm install prisma@6.11.1
# init prisma to download all required files
RUN pnpm prisma init
COPY --from=build-system /app/package.json ./ COPY --from=build-system /app/package.json ./
COPY --from=build-system /app/.output ./app COPY --from=build-system /app/.output ./app

View File

@ -6,73 +6,32 @@
# Drop # Drop
[![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org) [![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org)
[![Docs](https://img.shields.io/badge/DOCS-black?style=for-the-badge&logo=docusaurus)](https://docs.droposs.org/)
[![Static Badge](https://img.shields.io/badge/FORUM-blue?style=for-the-badge)](https://forum.droposs.org) [![Static Badge](https://img.shields.io/badge/FORUM-blue?style=for-the-badge)](https://forum.droposs.org)
[![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE) [![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) [![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) [![Open Collective](https://img.shields.io/badge/OpenCollective-1F87FF?style=for-the-badge&logo=OpenCollective&logoColor=white)](https://opencollective.com/drop-oss)
[![Weblate project translated](https://img.shields.io/weblate/progress/drop?server=https%3A%2F%2Ftranslate.droposs.org&style=for-the-badge&logo=weblate)
](https://translate.droposs.org/engage/drop/)
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. Drop is an open-source game distribution platform, similar to GameVault or Steam. It's designed to distribute and share DRM-free games quickly, all while being incredibly flexible, beautiful, and fast.
<div align="center">
<img src="https://droposs.org/_ipx/f_webp&q_80/images/carousel/store.png" alt="Drop Screenshot" width="900rem"/>
</div>
## Philosophy ## Philosophy
1. Drop is flexible. While abstractions and interfaces can make the codebase more complicated, the flexibility is worth it. 1. Drop is flexible. While abstractions and interfaces can complicate the codebase, the flexibility is worth it.
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from a username/password to SSO. 2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from username/password to SSO.
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with complexity available to the users who want it. 3. Drop is user-friendly. The interface is designed to be clean and simple to use, with advanced features available to users who want them.
## Deployment ## Deployment
To just deploy Drop, we've set up a simple docker compose file in deploy-template. See our documentation on how to [deploy Drop](https://docs.droposs.org/docs/guides/quickstart) for more information.
1. Generate a [GiantBomb API Key](https://www.giantbomb.com/api/)
2. Navigate to the deploy-template directory in your terminal (`cd deploy-template`)
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
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
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
9. You should see the game which you have just imported listed in this menu. There should be a notification that "Drop has detected you have new verions of this game to import". Select import here.
10. Select the game version to import and thus fill in fields as required.
## Tech Stack
This repo uses the Nuxt 3 + TailwindCSS stack, with the `yarn` package manager.
For the database, Drop uses Prisma connected to PostgreSQL.
## Development
To get started with development, you need `yarn --optional` and `docker compose` installed (or know how to set up a PostgreSQL database).
### Note: `--optional` flag is **REQUIRED**
Drop uses a utility package called droplet that's written in Rust. It has builts for Linux (GNU) and Windows, and they are set up as optional packages. `npm` installs these by default, but `yarn` needs the `--optional` flag.
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)
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/auth/register?id=admin
## Contributing ## Contributing
Please see the [in-depth contributing guide](CONTRIBUTING.md) Please see the [in-depth contributing guide](CONTRIBUTING.md). The guide includes information on how to set up the project, how to contribute code, how to report issues, and even how to effectively translate Drop.
[![Drop Translation Progress](https://translate.droposs.org/widget/drop/horizontal-auto.svg)](https://translate.droposs.org/engage/drop/)

42
app.vue
View File

@ -4,10 +4,52 @@
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
<ModalStack /> <ModalStack />
<div
v-if="showExternalUrlWarning"
class="fixed flex flex-row gap-x-2 right-0 bottom-0 m-2 px-2 py-2 z-50 text-right bg-red-700/90 rounded-lg"
>
<div class="flex flex-col">
<span class="text-sm text-zinc-200 font-bold font-display">{{
$t("errors.externalUrl.title")
}}</span>
<span class="text-xs text-red-400">{{
$t("errors.externalUrl.subtitle")
}}</span>
</div>
<button class="text-red-200" @click="() => hideExternalURL()">
<XMarkIcon class="size-5" />
</button>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/outline";
await updateUser(); await updateUser();
const user = useUser();
const apiDetails = await $dropFetch("/api/v1");
const showExternalUrlWarning = ref(false);
function checkExternalUrl() {
if (!import.meta.client) return;
const realOrigin = window.location.origin.trim();
const chosenOrigin = apiDetails.external.trim();
const ignore = window.localStorage.getItem("ignoreExternalUrl");
if (ignore && ignore == "true") return;
showExternalUrlWarning.value = !(realOrigin == chosenOrigin);
}
function hideExternalURL() {
window.localStorage.setItem("ignoreExternalUrl", "true");
showExternalUrlWarning.value = false;
}
if (user.value?.admin) {
onMounted(() => {
checkExternalUrl();
});
}
</script> </script>
<style scoped> <style scoped>

View File

@ -2,3 +2,12 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms"; @plugin "@tailwindcss/forms";
@config "../tailwind.config.js"; @config "../tailwind.config.js";
@layer base {
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button,
input[type="number"] {
-webkit-appearance: none;
-moz-appearance: textfield !important;
}
}

View File

@ -2,7 +2,7 @@
# This file starts up the Drop server by running migrations and then starting the executable # This file starts up the Drop server by running migrations and then starting the executable
echo "[Drop] performing migrations..." echo "[Drop] performing migrations..."
yarn prisma migrate deploy pnpm prisma migrate deploy
# Actually start the application # Actually start the application
node /app/app/server/index.mjs node /app/app/server/index.mjs

View File

@ -1,352 +0,0 @@
## 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
- results are returned alphabetically #33d3770
- properly disconnect websockets from task handler #5358f1f
- follow best practices #54c5d55
- future lenience #5c78b20
- 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 types #b511b40
- fix expires requirement in the admin endpoint #c7b675f
- 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
- introduction of 'system user' #2c21a23
- automatically create library folder if it doesn't exist #39fe9d5
- smoother bar in admin task ui #4488ae2
- add version metadata route #5393db3
- completed admin UI, with minor changes to backend #599da0e
- 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
- generate a server certificate for mtls APIs #9c4b6f3
- new endpoints, ui and beginnings of main store page #9cbdcbc
- more subtle design improvements #a815542
- 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
- slightly improved game page #e796b46
- game carousel #ecc819e
- add enum dictionary type #f2e0182
- cleanup and raw accessors #f7d767d
- add support for overriding UMU id #fd4a7d1
### 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
- 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
- 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
- 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)

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4"> <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"> <span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<UserIcon class="size-5" /> {{ $t("account.settings") }} <UserIcon class="size-5" /> {{ $t("account.title") }}
</span> </span>
<nav class="flex flex-1 flex-col"> <nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7"> <ul role="list" class="flex flex-1 flex-col gap-y-7">
@ -45,6 +45,7 @@ import {
LockClosedIcon, LockClosedIcon,
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import { UserIcon } from "@heroicons/vue/24/solid"; import { UserIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue"; import type { Component } from "vue";
@ -74,7 +75,13 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
count: notifications.value.length, count: notifications.value.length,
}, },
{ {
label: t("settings"), label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{
label: t("account.settings"),
route: "/account/settings", route: "/account/settings",
prefix: "/account/settings", prefix: "/account/settings",
icon: WrenchScrewdriverIcon, icon: WrenchScrewdriverIcon,

View File

@ -84,7 +84,7 @@
</Menu> </Menu>
</div> </div>
<CreateCollectionModal <ModalCreateCollection
v-model="createCollectionModal" v-model="createCollectionModal"
:game-id="props.gameId" :game-id="props.gameId"
/> />
@ -122,20 +122,9 @@ async function toggleLibrary() {
body: { body: {
id: props.gameId, id: props.gameId,
}, },
failTitle: t("errors.library.add.title"),
}); });
await refreshLibrary(); await refreshLibrary();
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally { } finally {
isLibraryLoading.value = false; isLibraryLoading.value = false;
} }
@ -147,26 +136,18 @@ async function toggleCollection(id: string) {
if (!collection) return; if (!collection) return;
const index = collection.entries.findIndex((e) => e.gameId == props.gameId); const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
await $dropFetch(`/api/v1/collection/${id}/entry`, { await $dropFetch(`/api/v1/collection/:id/entry`, {
method: index == -1 ? "POST" : "DELETE", method: index == -1 ? "POST" : "DELETE",
params: { id },
body: { body: {
id: props.gameId, id: props.gameId,
}, },
failTitle: t("errors.library.add.title"),
}); });
await refreshCollection(id); await refreshCollection(id);
} catch (e) { } finally {
createModal( /* empty */
ModalType.Notification,
{
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} }
} }
</script> </script>

View File

@ -86,7 +86,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid"; import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { User } from "~/prisma/client"; import type { UserModel } from "~/prisma/client/models";
const username = ref(""); const username = ref("");
const password = ref(""); const password = ref("");
@ -106,7 +106,7 @@ function signin_wrapper() {
router.push(route.query.redirect?.toString() ?? "/"); router.push(route.query.redirect?.toString() ?? "/");
}) })
.catch((response) => { .catch((response) => {
const message = response.statusMessage || t("errors.unknown"); const message = response.message || t("errors.unknown");
error.value = message; error.value = message;
}) })
.finally(() => { .finally(() => {
@ -124,6 +124,6 @@ async function signin() {
}, },
}); });
const user = useUser(); const user = useUser();
user.value = await $dropFetch<User | null>("/api/v1/user"); user.value = await $dropFetch<UserModel | null>("/api/v1/user");
} }
</script> </script>

View File

@ -31,11 +31,11 @@
<li v-for="game in filteredLibrary" :key="game.id" class="flex"> <li v-for="game in filteredLibrary" :key="game.id" class="flex">
<NuxtLink <NuxtLink
:to="`/library/game/${game.id}`" :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" class="flex flex-row items-center w-full p-2 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
> >
<img <img
:src="useObject(game.mCoverObjectId)" :src="useObject(game.mIconObjectId)"
class="h-9 w-9 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg" class="h-5 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
alt="" alt=""
/> />
<div class="min-w-0 flex-1 pl-2.5"> <div class="min-w-0 flex-1 pl-2.5">

View File

@ -91,9 +91,7 @@
:src="useObject(article.imageObjectId)" :src="useObject(article.imageObjectId)"
class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110" class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110"
/> />
<div <div class="absolute inset-0 bg-zinc-900/50" />
class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-800 transition-all duration-200"
/>
</div> </div>
<h3 class="relative text-sm font-medium text-zinc-100"> <h3 class="relative text-sm font-medium text-zinc-100">
@ -151,11 +149,8 @@ const toggleTag = (tag: string) => {
}; };
const formatExcerpt = (excerpt: string) => { const formatExcerpt = (excerpt: string) => {
// TODO: same as one in NewsArticleCreateButton // Convert markdown to HTML, micromark is safe
// Convert markdown to HTML return micromark(excerpt);
const html = micromark(excerpt);
// Strip HTML tags using regex
return html.replace(/<[^>]*>/g, "");
}; };
const filteredArticles = computed(() => { const filteredArticles = computed(() => {

View File

@ -1,5 +1,6 @@
<template> <template>
<svg <svg
aria-label="Drop Logo"
class="text-blue-400" class="text-blue-400"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
@ -9,6 +10,16 @@
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" 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="currentColor"
stroke-width="2" stroke-width="2"
stroke-dasharray="100"
:stroke-dashoffset="dashArray"
/> />
</svg> </svg>
</template> </template>
<script setup lang="ts">
const props = defineProps<{ progress?: number }>();
const dashArray = computed(() =>
props.progress === undefined ? 0 : ((100 - props.progress) / 100) * 50 + 50,
);
</script>

View File

@ -10,7 +10,7 @@
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" 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> </svg>
<DropLogo class="h-6" /> <DropLogo aria-hidden="true" class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase"> <span class="text-blue-400 font-display font-bold text-xl uppercase">
{{ $t("drop.drop") }} {{ $t("drop.drop") }}
</span> </span>

View File

@ -7,7 +7,11 @@
:key="gameIdx" :key="gameIdx"
class="justify-start" class="justify-start"
> >
<GamePanel :game="game" /> <GamePanel
:game="game"
:href="game ? `/store/${game.id}` : undefined"
:show-title-description="showGamePanelTextDecoration"
/>
</VueSlide> </VueSlide>
<template #addons> <template #addons>
@ -31,19 +35,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Game } from "~/prisma/client"; import type { GameModel } from "~/prisma/client/models";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
const props = defineProps<{ const props = defineProps<{
items: Array<SerializeObject<Game>>; items: Array<SerializeObject<GameModel>>;
min?: number; min?: number;
width?: number; width?: number;
}>(); }>();
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const currentComponent = ref<HTMLDivElement>(); const currentComponent = ref<HTMLDivElement>();
const min = computed(() => Math.max(props.min ?? 8, props.items.length)); const min = computed(() => Math.max(props.min ?? 8, props.items.length));
const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() => const games: Ref<Array<SerializeObject<GameModel> | undefined>> = computed(() =>
Array(min.value) Array(min.value)
.fill(0) .fill(0)
.map((_, i) => props.items[i]), .map((_, i) => props.items[i]),

View File

@ -23,10 +23,14 @@
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" 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)" @click="() => (showEditCoreMetadata = true)"
> >
{{ $t("edit") }} <PencilIcon class="size-4" /> {{ $t("common.edit") }} <PencilIcon class="size-4" />
</button> </button>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" />
</div>
<!-- image carousel pick --> <!-- image carousel pick -->
<div class="border-b border-zinc-700"> <div class="border-b border-zinc-700">
<div class="border-b border-zinc-700 py-4"> <div class="border-b border-zinc-700 py-4">
@ -268,13 +272,13 @@
</div> </div>
</div> </div>
</div> </div>
<UploadFileDialog <ModalUploadFile
v-model="showUploadModal" v-model="showUploadModal"
:options="{ id: game.id }" :options="{ id: game.id }"
accept="image/*" accept="image/*"
endpoint="/api/v1/admin/game/image" endpoint="/api/v1/admin/game/image"
:multiple="true" :multiple="true"
@upload="(result: Game) => uploadAfterImageUpload(result)" @upload="(result: GameModel) => uploadAfterImageUpload(result)"
/> />
<ModalTemplate v-model="showAddCarouselModal"> <ModalTemplate v-model="showAddCarouselModal">
<template #default> <template #default>
@ -314,7 +318,7 @@
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 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto" 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 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false" @click="showAddCarouselModal = false"
> >
{{ $t("close") }} {{ $t("common.close") }}
</button> </button>
</template> </template>
</ModalTemplate> </ModalTemplate>
@ -335,7 +339,7 @@
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 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" 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 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => insertImageAtCursor(image)" @click="() => insertImageAtCursor(image)"
> >
{{ $t("insert") }} {{ $t("common.insert") }}
</button> </button>
</div> </div>
</div> </div>
@ -376,7 +380,7 @@
accept="image/*" accept="image/*"
class="hidden" class="hidden"
type="file" type="file"
@change="(e) => coreMetadataUploadFiles(e as any)" @change="(e: Event) => coreMetadataUploadFiles(e as any)"
/> />
</label> </label>
</div> </div>
@ -424,7 +428,7 @@
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']" :class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => coreMetadataUpdate_wrapper()" @click="() => coreMetadataUpdate_wrapper()"
> >
{{ $t("save") }} {{ $t("common.save") }}
</LoadingButton> </LoadingButton>
<button <button
ref="cancelButtonRef" ref="cancelButtonRef"
@ -440,7 +444,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Game } from "~/prisma/client"; import type { GameModel, GameTagModel } from "~/prisma/client/models";
import { micromark } from "micromark"; import { micromark } from "micromark";
import { import {
CheckIcon, CheckIcon,
@ -451,23 +455,42 @@ import {
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3"; import type { H3Error } from "h3";
definePageMeta({
layout: "admin",
});
const showUploadModal = ref(false); const showUploadModal = ref(false);
const showAddCarouselModal = ref(false); const showAddCarouselModal = ref(false);
const showAddImageDescriptionModal = ref(false); const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false); const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true); const mobileShowFinalDescription = ref(true);
const game = defineModel<SerializeObject<Game>>() as Ref<SerializeObject<Game>>; type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
const game = defineModel<ModelType>() as Ref<ModelType>;
if (!game.value) if (!game.value)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "Game not provided to editor component", message: "Game not provided to editor component",
}); });
const currentTags = ref<{ [key: string]: boolean }>(
Object.fromEntries(game.value.tags.map((e) => [e.id, true])),
);
const tags = (await $dropFetch("/api/v1/admin/tags")).map(
(e) => ({ name: e.name, param: e.id }) satisfies StoreSortOption,
);
watch(
currentTags,
async (v) => {
await $dropFetch(`/api/v1/admin/game/:id/tags`, {
method: "PATCH",
params: {
id: game.value.id,
},
body: { tags: Object.keys(v) },
failTitle: "Failed to update game tags",
});
},
{ deep: true },
);
const { t } = useI18n(); const { t } = useI18n();
// I don't know why I split these fields off. // I don't know why I split these fields off.
@ -491,7 +514,7 @@ function coreMetadataUploadFiles(e: InputEvent) {
{ {
title: t("errors.upload.title"), title: t("errors.upload.title"),
description: t("errors.upload.description", [t("errors.unknown")]), description: t("errors.upload.description", [t("errors.unknown")]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -508,14 +531,16 @@ async function coreMetadataUpdate() {
formData.append("icon", newIcon); formData.append("icon", newIcon);
} }
formData.append("id", game.value.id);
formData.append("name", coreMetadataName.value); formData.append("name", coreMetadataName.value);
formData.append("description", coreMetadataDescription.value); formData.append("description", coreMetadataDescription.value);
const result = await $dropFetch(`/api/v1/admin/game/metadata`, { const result = await $dropFetch(
method: "POST", `/api/v1/admin/game/${game.value.id}/metadata`,
body: formData, {
}); method: "POST",
body: formData,
},
);
return result; return result;
} }
@ -528,16 +553,18 @@ function coreMetadataUpdate_wrapper() {
{ {
title: t("errors.game.metadata.title"), title: t("errors.game.metadata.title"),
description: t("errors.game.metadata.description", [ description: t("errors.game.metadata.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.message ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
}) })
.then((newGame) => { .then((newGame) => {
console.log(newGame);
if (!newGame) return; if (!newGame) return;
Object.assign(game.value, newGame); Object.assign(game.value, newGame);
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);
}) })
.finally(() => { .finally(() => {
coreMetadataLoading.value = false; coreMetadataLoading.value = false;
@ -563,7 +590,7 @@ const descriptionSaving = ref<DescriptionSavingState>(
let savingTimeout: undefined | NodeJS.Timeout; let savingTimeout: undefined | NodeJS.Timeout;
type PatchGameBody = Partial<Game>; type PatchGameBody = Partial<GameModel>;
watch(descriptionHTML, (_v) => { watch(descriptionHTML, (_v) => {
descriptionSaving.value = DescriptionSavingState.Waiting; descriptionSaving.value = DescriptionSavingState.Waiting;
@ -571,10 +598,12 @@ watch(descriptionHTML, (_v) => {
savingTimeout = setTimeout(async () => { savingTimeout = setTimeout(async () => {
try { try {
descriptionSaving.value = DescriptionSavingState.Loading; descriptionSaving.value = DescriptionSavingState.Loading;
await $dropFetch("/api/v1/admin/game", { await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH", method: "PATCH",
body: { params: {
id: game.value.id, id: game.value.id,
},
body: {
mDescription: game.value.mDescription, mDescription: game.value.mDescription,
} satisfies PatchGameBody, } satisfies PatchGameBody,
}); });
@ -585,9 +614,9 @@ watch(descriptionHTML, (_v) => {
{ {
title: t("errors.game.description.title"), title: t("errors.game.description.title"),
description: t("errors.game.description.description", [ description: t("errors.game.description.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.message ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -615,10 +644,12 @@ function insertImageAtCursor(id: string) {
async function updateBannerImage(id: string) { async function updateBannerImage(id: string) {
try { try {
if (game.value.mBannerObjectId == id) return; if (game.value.mBannerObjectId == id) return;
const { mBannerObjectId } = await $dropFetch("/api/v1/admin/game", { const { mBannerObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH", method: "PATCH",
body: { params: {
id: game.value.id, id: game.value.id,
},
body: {
mBannerObjectId: id, mBannerObjectId: id,
} satisfies PatchGameBody, } satisfies PatchGameBody,
}); });
@ -629,9 +660,9 @@ async function updateBannerImage(id: string) {
{ {
title: t("errors.game.banner.title"), title: t("errors.game.banner.title"),
description: t("errors.game.banner.description", [ description: t("errors.game.banner.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.message ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -641,10 +672,12 @@ async function updateBannerImage(id: string) {
async function updateCoverImage(id: string) { async function updateCoverImage(id: string) {
try { try {
if (game.value.mCoverObjectId == id) return; if (game.value.mCoverObjectId == id) return;
const { mCoverObjectId } = await $dropFetch("/api/v1/admin/game", { const { mCoverObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH", method: "PATCH",
body: { params: {
id: game.value.id, id: game.value.id,
},
body: {
mCoverObjectId: id, mCoverObjectId: id,
} satisfies PatchGameBody, } satisfies PatchGameBody,
}); });
@ -655,9 +688,9 @@ async function updateCoverImage(id: string) {
{ {
title: t("errors.game.cover.title"), title: t("errors.game.cover.title"),
description: t("errors.game.cover.description", [ description: t("errors.game.cover.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.message ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -684,16 +717,16 @@ async function deleteImage(id: string) {
{ {
title: t("errors.game.deleteImage.title"), title: t("errors.game.deleteImage.title"),
description: t("errors.game.deleteImage.description", [ description: t("errors.game.deleteImage.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.message ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
} }
} }
async function uploadAfterImageUpload(result: Game) { async function uploadAfterImageUpload(result: GameModel) {
if (!game.value) return; if (!game.value) return;
game.value.mImageLibraryObjectIds = result.mImageLibraryObjectIds; game.value.mImageLibraryObjectIds = result.mImageLibraryObjectIds;
} }
@ -713,10 +746,12 @@ function removeImageFromCarousel(id: string) {
async function updateImageCarousel() { async function updateImageCarousel() {
try { try {
await $dropFetch("/api/v1/admin/game", { await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH", method: "PATCH",
body: { params: {
id: game.value.id, id: game.value.id,
},
body: {
mImageCarouselObjectIds: game.value.mImageCarouselObjectIds, mImageCarouselObjectIds: game.value.mImageCarouselObjectIds,
} satisfies PatchGameBody, } satisfies PatchGameBody,
}); });
@ -726,9 +761,9 @@ async function updateImageCarousel() {
{ {
title: t("errors.game.carousel.title"), title: t("errors.game.carousel.title"),
description: t("errors.game.carousel.description", [ description: t("errors.game.carousel.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.message ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );

View File

@ -1,95 +1,208 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<div v-if="game"> <div v-if="game && unimportedVersions" class="p-8">
<div class="grow flex flex-row gap-y-8"> <div>
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div> <div class="sm:flex sm:items-center">
<div <div class="sm:flex-auto">
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4" <h1 class="text-base font-semibold text-zinc-100">Versions</h1>
> <p class="mt-2 text-sm text-zinc-400 max-w-lg">
<!-- version manager --> Versions are a collection of files that are downloaded to clients.
Each version can have multiple configurations, for different
platforms.
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
:href="canImport ? `/admin/library/g/${game.id}/import` : ''"
type="button"
:class="[
canImport ? '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',
]"
>
{{
canImport
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</div>
</div>
<div class="mt-8 rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm">
<div> <div>
<!-- version priority --> <table class="min-w-full divide-y divide-zinc-800">
<div> <thead>
<div class="border-b border-zinc-800 pb-3"> <tr class="bg-zinc-800/50">
<div <th
class="flex flex-wrap items-center justify-between sm:flex-nowrap" scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
Version Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Imported
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Platforms
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="version in game.versions"
:key="version.versionId"
class="transition-colors duration-150 hover:bg-zinc-800/50"
> >
<h3 <td
class="text-base font-semibold font-display leading-6 text-zinc-100" class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
> >
{{ $t("library.admin.versionPriority") }} {{ version.versionName }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime :date="version.created" />
</td>
<td class="px-3 py-4">
<ul class="space-y-4">
<li
v-for="gameVersion in version.gameVersions"
:key="gameVersion.versionId"
class="px-3 py-2 bg-zinc-800 rounded-lg shadow"
>
<div>
<div
class="text-sm flex items-center text-zinc-200 font-semibold"
>
<IconsPlatform
:platform="
platforms[gameVersion.platformId].platformIcon.key
"
:fallback="
platforms[gameVersion.platformId].platformIcon
.fallback
"
class="size-5 text-blue-500"
/>
<span class="ml-3 block truncate">{{
platforms[gameVersion.platformId].name
}}</span>
</div>
<!-- import games button --> <!-- launch commands -->
<div class="space-y-1 mt-4">
<div
v-if="gameVersion.install"
class="flex items-center justify-between"
>
<span
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>Install</span
>
<NuxtLink <div
v-if="unimportedVersions !== undefined" class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
:href=" >
unimportedVersions.length > 0 <span class="text-zinc-700">(install dir)/</span
? `/admin/library/${game.id}/import` >{{ gameVersion.install.command }}
: '' {{ gameVersion.install.args }}
" </div>
type="button" </div>
:class="[
unimportedVersions.length > 0 <div>
? 'bg-blue-600 hover:bg-blue-700' <span class="font-semibold text-sm text-zinc-100"
: 'bg-blue-800/50', >Launch options</span
'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', >
]" <ul class="divide-y divide-zinc-700">
<li
v-for="launch in gameVersion.launches"
:key="launch.command"
class="ml-2 py-2 flex justify-between items-center"
>
<h1
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>
{{ launch.name }}
</h1>
<div
class="mt-1 whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700"
>(install dir)/</span
>{{ launch.command }} {{ launch.args }}
</div>
</li>
</ul>
</div>
<div
v-if="gameVersion.uninstall"
class="flex items-center justify-between"
>
<span
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>Uninstall</span
>
<div
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700">(install dir)/</span
>{{ gameVersion.uninstall.command }}
{{ gameVersion.uninstall.args }}
</div>
</div>
</div>
</div>
</li>
</ul>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => deleteVersion(version.versionId)"
> >
{{ Delete
unimportedVersions.length > 0 <span class="sr-only">
? $t("library.admin.import.version.import") {{ $t("chars.srComma", [version.versionName]) }}
: $t("library.admin.import.version.noVersions") </span>
}} </button>
</NuxtLink> </td>
</h3> </tr>
</div> <tr v-if="game.versions.length === 0">
</div> <td colspan="5" class="py-8 text-center text-sm text-zinc-400">
No versions
<div class="mt-4 text-center w-full text-sm text-zinc-600"> </td>
{{ $t("lowest") }} </tr>
</div> </tbody>
<draggable </table>
:list="game.versions" </div>
handle=".handle" </div>
class="mt-2 space-y-4" </div>
@update="() => updateVersionOrder()" </div>
> <div v-else class="grow w-full flex items-center justify-center">
<template #item="{ element: item }: { element: GameVersion }"> <div class="flex flex-col items-center">
<div <ExclamationCircleIcon
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between" class="h-12 w-12 text-red-600"
> aria-hidden="true"
<div class="text-zinc-100 font-semibold"> />
{{ item.versionName }} <div class="mt-3 text-center sm:mt-5">
</div> <h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
<div class="text-zinc-400"> {{ $t("library.admin.offlineTitle") }}
{{ item.delta ? $t("library.admin.version.delta") : "" }} </h1>
</div> <div class="mt-4">
<div class="inline-flex items-center gap-x-2"> <p class="text-sm text-zinc-400 max-w-md">
<component {{ $t("library.admin.offline") }}
:is="PLATFORM_ICONS[item.platform]" </p>
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>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -97,38 +210,46 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Game, GameVersion } from "~/prisma/client"; import type { SerializeObject, TypedInternalResponse } from "nitropack";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3"; import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
definePageMeta({
layout: "admin",
});
// TODO implement UI for this page // TODO implement UI for this page
defineProps<{ unimportedVersions: string[] }>(); const props = defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n(); const { t } = useI18n();
type GameAndVersions = Game & { versions: GameVersion[] }; const hasDeleted = ref(false);
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions> const canImport = computed(
>; () => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameFetchType = TypedInternalResponse<
"/api/v1/admin/game/:id",
unknown,
"get"
>["game"];
const game = defineModel<SerializeObject<GameFetchType>>({ required: true });
if (!game.value) if (!game.value)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "Game not provided to editor component", message: "Game not provided to editor component",
}); });
const rawPlatforms = await useAdminPlatforms();
const platforms = Object.fromEntries(
renderPlatforms(rawPlatforms).map((v) => [v.param, v]),
);
async function updateVersionOrder() { async function updateVersionOrder() {
try { try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", { const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH", method: "PATCH",
body: { body: {
id: game.value.id, id: game.value.id,
versions: game.value.versions.map((e) => e.versionName), versions: game.value.versions.map((e) => e.versionId),
}, },
}); });
game.value.versions = newVersions; game.value.versions = newVersions;
@ -138,40 +259,27 @@ async function updateVersionOrder() {
{ {
title: t("errors.version.order.title"), title: t("errors.version.order.title"),
description: t("errors.version.order.desc", { description: t("errors.version.order.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"), error: (e as H3Error)?.message ?? t("errors.unknown"),
}), }),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
} }
} }
async function deleteVersion(versionName: string) { async function deleteVersion(versionId: string) {
try { await $dropFetch("/api/v1/admin/game/version", {
await $dropFetch("/api/v1/admin/game/version", { method: "DELETE",
method: "DELETE", body: {
body: { id: versionId,
id: game.value.id, },
versionName: versionName, failTitle: "Failed to delete version.",
}, });
}); game.value.versions.splice(
game.value.versions.splice( game.value.versions.findIndex((e) => e.versionId === versionId),
game.value.versions.findIndex((e) => e.versionName === versionName), 1,
1, );
); hasDeleted.value = true;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.delete.title"),
description: t("errors.version.delete.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("close"),
},
(e, c) => c(),
);
}
} }
</script> </script>

View File

@ -1,44 +1,73 @@
<template> <template>
<NuxtLink <NuxtLink
v-if="game" v-if="game || defaultPlaceholder"
:href="props.href ?? `/store/${game.id}`" :href="href"
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" :class="{
@click="active = game.id" 'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5':
animate,
}"
class="group relative flex-1 min-w-42 max-w-48 h-64 rounded-lg overflow-hidden"
> >
<div <div
class="absolute inset-0 transition-all duration-300 group-hover:scale-110" :class="{
'transition-all duration-300 group-hover:scale-110': animate,
}"
class="absolute inset-0"
> >
<img <img
:src="useObject(game.mCoverObjectId)" :src="imageProps.src"
class="w-full h-full object-cover brightness-[90%]" class="w-full h-full object-cover brightness-[90%]"
:class="{ active: active === game.id }" :alt="imageProps.alt"
:alt="game.mName"
/> />
<div <div
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent" v-if="showTitleDescription"
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/0 to-transparent"
/> />
</div> </div>
<div class="absolute bottom-0 left-0 w-full p-3"> <div
v-if="showTitleDescription"
class="absolute bottom-0 left-0 w-full p-3"
>
<h1 <h1
class="text-zinc-100 text-sm font-bold font-display group-hover:text-white transition-colors" :class="{ 'group-hover:text-white transition-colors': animate }"
class="text-zinc-100 text-sm font-bold font-display"
> >
{{ game.mName }} {{
game ? game.mName : $t("settings.admin.store.dropGameNamePlaceholder")
}}
</h1> </h1>
<p <p
class="text-zinc-400 text-xs line-clamp-2 group-hover:text-zinc-300 transition-colors" :class="{
'group-hover:text-zinc-300 transition-colors': animate,
}"
class="text-zinc-400 text-xs line-clamp-2"
> >
{{ game.mShortDescription }} {{
game
? game.mShortDescription
: $t("settings.admin.store.dropGameDescriptionPlaceholder")
}}
</p> </p>
</div> </div>
</NuxtLink> </NuxtLink>
<SkeletonCard v-else :message="$t('store.noGame')" /> <SkeletonCard
v-else-if="defaultPlaceholder === false"
:message="$t('store.noGame')"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
const props = defineProps<{ const { t } = useI18n();
const {
game,
href = undefined,
showTitleDescription = true,
animate = true,
defaultPlaceholder = false,
} = defineProps<{
game: game:
| SerializeObject<{ | SerializeObject<{
id: string; id: string;
@ -46,11 +75,25 @@ const props = defineProps<{
mName: string; mName: string;
mShortDescription: string; mShortDescription: string;
}> }>
| undefined; | undefined
| null;
href?: string; href?: string;
showTitleDescription?: boolean;
animate?: boolean;
defaultPlaceholder?: boolean;
}>(); }>();
const active = useState(); const imageProps = {
src: "",
alt: t("settings.admin.store.dropGameAltPlaceholder"),
};
if (game) {
imageProps.src = useObject(game.mCoverObjectId);
imageProps.alt = game.mName;
} else if (defaultPlaceholder) {
imageProps.src = "/game-panel-placeholder.png";
}
</script> </script>
<style scoped> <style scoped>

View File

@ -19,6 +19,6 @@
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const { game } = defineProps<{ const { game } = defineProps<{
game: GameMetadataSearchResult & { sourceName?: string }; game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
}>(); }>();
</script> </script>

View File

@ -0,0 +1,26 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<component
:is="platformIcons[props.platform as HardwarePlatform]"
v-if="platformIcons[props.platform as HardwarePlatform]"
/>
<div v-else-if="props.fallback" v-html="props.fallback" />
<DropLogo v-else />
</template>
<script setup lang="ts">
import { HardwarePlatform } from "~/prisma/client/enums";
import type { Component } from "vue";
import LinuxLogo from "./LinuxLogo.vue";
import WindowsLogo from "./WindowsLogo.vue";
import MacLogo from "./MacLogo.vue";
import DropLogo from "../DropLogo.vue";
const props = defineProps<{ platform: string; fallback?: string }>();
const platformIcons: { [key in HardwarePlatform]: Component } = {
[HardwarePlatform.Linux]: LinuxLogo,
[HardwarePlatform.Windows]: WindowsLogo,
[HardwarePlatform.macOS]: MacLogo,
};
</script>

238
components/Import/Game.vue Normal file
View File

@ -0,0 +1,238 @@
<template>
<div class="flex flex-col gap-y-4">
<!-- without metadata option -->
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
@click="() => importGame(false)"
>
{{ $t("library.admin.import.withoutMetadata") }}
</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" />
{{ $t("auth.signin.or") }}
<div class="h-[1px] grow bg-zinc-800" />
</div>
<!-- with metadata option -->
<div class="flex flex-col gap-y-4">
<form @submit.prevent="() => searchGame()">
<label
for="searchTerm"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.import.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="$t('library.admin.import.searchPlaceholder')"
/>
</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"
/>
{{ $t("library.admin.import.search") }}
</LoadingButton>
</div>
</form>
<Listbox
v-if="metadataResults && metadataResults.length > 0"
v-model="model"
as="div"
>
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.selectGameSearch") }}</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="model !== undefined" :game="model" />
<span v-else class="block truncate text-zinc-600">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</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"
>
<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
v-for="result in metadataResults"
:key="result.id"
v-slot="{ active }"
as="template"
:value="result"
>
<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>
</ListboxOptions>
</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"
>
{{ $t("library.admin.import.loading") }}
<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"
/>
<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>
</div>
<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" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ gameSearchResultsError }}
</h3>
</div>
</div>
</div>
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
:disabled="model === undefined"
@click="() => importGame()"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<div
v-if="props.error"
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" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ props.error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const model = ref<GameMetadataSearchResult | undefined>(undefined);
const props = defineProps<{
gameName: string;
loading: boolean;
error?: string | undefined;
}>();
const emit = defineEmits<{
import: [metadata: GameMetadataSearchResult | undefined];
}>();
function importGame(metadata = true) {
emit("import", metadata ? model.value : undefined);
}
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
onMounted(() => {
if (!metadataResults.value) searchGame();
});
const gameSearchLoading = ref(false);
const gameSearchResultsLoading = ref(false);
const gameSearchResultsError = ref<string | undefined>();
const gameSearchTerm = ref(props.gameName);
async function searchGame() {
gameSearchResultsError.value = undefined;
gameSearchLoading.value = true;
try {
const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
);
metadataResults.value = results;
gameSearchLoading.value = false;
} catch (e) {
gameSearchLoading.value = false;
throw e;
}
}
</script>

View File

@ -0,0 +1,336 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="flex flex-row gap-x-4">
<label
for="icon-upload"
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
>
<input
id="icon-upload"
type="file"
class="sr-only"
accept="image/*"
@change="addFile"
/>
<img
v-if="currentFileObjectUrl"
:src="currentFileObjectUrl"
class="absolute inset-0 object-cover w-full h-full"
/>
<div
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50"
>
<ArrowUpTrayIcon class="size-6" />
<span class="text-xs font-bold font-display uppercase">Upload</span>
</div>
</label>
<div class="grow flex flex-col gap-y-4">
<div>
<label for="name" class="block text-sm font-medium text-zinc-100"
>Name</label
>
<input
id="name"
v-model="name"
type="text"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 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"
/>
</div>
<div>
<label for="description" class="block text-sm font-medium text-zinc-100"
>Description</label
>
<input
id="description"
v-model="description"
type="text"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 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"
/>
</div>
</div>
</div>
<SwitchGroup
as="div"
class="max-w-lg flex items-center justify-between gap-x-4"
>
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>Create as platform</SwitchLabel
>
<SwitchDescription as="span" class="text-sm text-zinc-400"
>Versions for this redistributable will be able to take a series of
launch commands. Intended to be used with emulators and similar
programs.</SwitchDescription
>
</span>
<Switch
v-model="isPlatform"
:class="[
isPlatform ? 'bg-blue-600' : 'bg-zinc-800',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
>
<span
aria-hidden="true"
:class="[
isPlatform ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</SwitchGroup>
<div class="relative">
<div class="flex flex-row gap-x-4">
<label
for="platform-icon-upload"
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
>
<input
id="platform-icon-upload"
type="file"
class="sr-only"
accept="image/svg+xml"
:disabled="!isPlatform"
@change="addSvg"
/>
<div
v-if="platform.icon"
class="absolute inset-0 object-cover w-full h-full text-blue-600"
v-html="platform.icon"
/>
<div
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50 focus:text-zinc-100"
>
<ArrowUpTrayIcon class="size-6" />
<span class="text-xs font-bold font-display uppercase"
>Upload SVG</span
>
</div>
</label>
<div class="grow flex flex-col gap-y-4">
<div>
<label
for="platform-name"
class="block text-sm font-medium text-zinc-100"
>Platform Name</label
>
<input
id="platform-name"
v-model="platform.name"
type="text"
:disabled="!isPlatform"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 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"
/>
</div>
</div>
</div>
<div class="mt-2 w-full">
<label for="platform-name" class="block text-sm font-medium text-zinc-100"
>File Extensions {{ currentExtDotted }}
</label
>
<Combobox
as="div"
:model-value="currentExtDotted"
nullable
class="mt-1 w-full"
:disabled="!isPlatform"
@update:model-value="(v) => addExt(v)"
>
<div class="relative">
<ComboboxInput
class="w-full block flex-1 rounded-lg border-1 border-zinc-800 py-2 px-2 bg-zinc-950 text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder=".exe"
@change="currentExt = $event.target.value"
@blur="currentExt = ''"
/>
<ComboboxOptions
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-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-if="currentExt"
v-slot="{ active }"
:value="currentExtDotted"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span class="block">
<span class="text-blue-300">filename</span
><span class="font-semibold">{{ currentExtDotted }}</span>
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<div class="mt-2 flex gap-1 flex-wrap">
<div
v-for="ext in platform.fileExts"
:key="ext"
class="bg-blue-600/10 border-1 border-blue-700 rounded-full px-2 py-1 text-xs text-blue-400"
>
{{ ext }}
</div>
<span
v-if="platform.fileExts.length == 0"
class="uppercase font-display text-zinc-700 font-bold text-xs"
>No suggested file extensions.</span
>
</div>
</div>
<div v-if="!isPlatform" class="absolute inset-0 bg-zinc-950/20" />
</div>
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
:disabled="buttonDisabled"
@click="() => importRedist()"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<div v-if="props.error" 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" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ props.error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Switch,
SwitchDescription,
SwitchGroup,
SwitchLabel,
} from "@headlessui/vue";
import { ArrowUpTrayIcon } from "@heroicons/vue/24/outline";
const currentFile = ref<File | undefined>(undefined);
const currentFileObjectUrl = ref<string | undefined>(undefined);
const emit = defineEmits<{
import: [
metadata: { name: string; description: string; icon: File } | undefined,
platform: typeof platform.value | undefined,
];
}>();
const name = ref("");
const description = ref("");
const isPlatform = ref(false);
const currentExt = ref("");
const currentExtDotted = computed(() => {
if(!currentExt.value) return "";
const cleaned = currentExt.value.replace(/\W/g, "").toLowerCase();
return `.${cleaned}`;
});
const platform = ref<{ name: string; icon: string; fileExts: string[] }>({
name: "",
icon: "",
fileExts: [],
});
const buttonDisabled = computed<boolean>(
() =>
!(
name.value &&
description.value &&
currentFileObjectUrl.value &&
(!isPlatform.value || (platform.value.name && platform.value.icon))
),
);
function addFile(event: Event) {
const file = (event.target as HTMLInputElement)?.files?.[0];
if (!file) return;
if (currentFileObjectUrl.value) {
URL.revokeObjectURL(currentFileObjectUrl.value);
}
currentFile.value = file;
currentFileObjectUrl.value = URL.createObjectURL(file);
}
async function addSvg(event: Event) {
const file = (event.target as HTMLInputElement)?.files?.[0];
if (!file) return;
const svgContent = await file.text();
const parser = new DOMParser();
try {
const document = parser.parseFromString(svgContent, "image/svg+xml");
const svg = document.getElementsByTagName("svg").item(0);
if (!svg) throw "No SVG in uploaded image.";
svg.removeAttribute("width");
svg.removeAttribute("height");
platform.value.icon = svg.outerHTML;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to upload SVG",
description: (e as string)?.toString() ?? e,
},
(_, c) => c(),
);
return;
}
}
function addExt(ext: string | null) {
if (!ext) return;
if (platform.value.fileExts.includes(ext)) return;
platform.value.fileExts.push(ext);
currentExt.value = "";
}
const props = defineProps<{
gameName: string;
loading: boolean;
error?: string | undefined;
}>();
function importRedist() {
if (!currentFile.value) return;
emit(
"import",
{
name: name.value,
description: description.value,
icon: currentFile.value,
},
isPlatform.value
? {
...platform.value,
fileExts: platform.value.fileExts.map((e) => e.slice(1)),
}
: undefined,
);
}
</script>

View File

@ -1,83 +1,9 @@
<template> <template>
<div> <div>
<Listbox v-model="wiredLocale" as="div"> <LanguageSelectorListbox />
<ListboxLabel class="block text-sm/6 font-medium text-zinc-400">{{
$t("selectLanguage")
}}</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="grid w-full cursor-default grid-cols-1 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-300 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
>
<span class="col-start-1 row-start-1 flex items-center gap-3 pr-6">
<EmojiText
:emoji="localeToEmoji(wiredLocale)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span class="block truncate">{{
currentLocaleInformation?.name ?? wiredLocale
}}</span>
</span>
<ChevronUpDownIcon
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-gray-500 sm:size-4"
aria-hidden="true"
/>
</ListboxButton>
<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-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm"
>
<ListboxOption
v-for="listLocale in locales"
:key="listLocale.code"
v-slot="{ active, selected }"
as="template"
:value="listLocale.code"
>
<li
:class="[
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-300',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<div class="flex items-center">
<EmojiText
:emoji="localeToEmoji(listLocale.code)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'ml-3 block truncate',
]"
>{{ listLocale.name }}</span
>
</div>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<NuxtLink <NuxtLink
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm" class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
to="https://translate.droposs.org/projects/drop/" to="https://translate.droposs.org/engage/drop/"
target="_blank" target="_blank"
> >
<i18n-t <i18n-t
@ -92,85 +18,12 @@
</i18n-t> </i18n-t>
</NuxtLink> </NuxtLink>
<DevOnly <DevOnly>
><h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1> <h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
</DevOnly> </DevOnly>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import {
ArrowTopRightOnSquareIcon,
CheckIcon,
} from "@heroicons/vue/24/outline";
import type { Locale } from "vue-i18n";
const { locales, locale: currLocale, setLocale } = useI18n();
function changeLocale(locale: Locale) {
setLocale(locale);
// dynamically update the HTML attributes for language and direction
// this is necessary for proper rendering of the page in the new language
useHead({
htmlAttrs: {
lang: locale,
dir: locales.value.find((l) => l.code === locale)?.dir || "ltr",
},
});
}
function localeToEmoji(local: string): string {
switch (local) {
// Default locale
case "en":
case "en-us":
return "🇺🇸";
case "en-gb":
return "🇬🇧";
case "en-ca":
return "🇨🇦";
case "en-au":
return "🇦🇺";
case "en-pirate":
return "🏴‍☠️";
case "fr":
return "🇫🇷";
case "de":
return "🇩🇪";
case "es":
return "🇪🇸";
case "it":
return "🇮🇹";
case "zh":
return "🇨🇳";
case "zh-tw":
return "🇹🇼";
default: {
return "❓";
}
}
}
const wiredLocale = computed({
get() {
return currLocale.value;
},
set(v) {
changeLocale(v);
},
});
const currentLocaleInformation = computed(() =>
locales.value.find((e) => e.code == wiredLocale.value),
);
</script> </script>

View File

@ -0,0 +1,155 @@
<template>
<Listbox v-model="wiredLocale" as="div">
<ListboxLabel
v-if="showText"
class="block text-sm/6 font-medium text-zinc-400"
>{{ $t("selectLanguage") }}</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="grid w-full cursor-default grid-cols-1 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-300 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
>
<span class="col-start-1 row-start-1 flex items-center gap-3 pr-6">
<EmojiText
:emoji="localeToEmoji(wiredLocale)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span class="block truncate">{{
currentLocaleInformation?.name ?? wiredLocale
}}</span>
</span>
<ChevronUpDownIcon
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-gray-500 sm:size-4"
aria-hidden="true"
/>
</ListboxButton>
<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-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm"
>
<ListboxOption
v-for="listLocale in locales"
:key="listLocale.code"
v-slot="{ active, selected }"
as="template"
:value="listLocale.code"
>
<li
:class="[
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-300',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<div class="flex items-center">
<EmojiText
:emoji="localeToEmoji(listLocale.code)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'ml-3 block truncate',
]"
>{{ listLocale.name }}</span
>
</div>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { CheckIcon } from "@heroicons/vue/24/outline";
import type { Locale } from "vue-i18n";
const { showText = true } = defineProps<{ showText?: boolean }>();
const { locales, locale: currLocale, setLocale } = useI18n();
function changeLocale(locale: Locale) {
setLocale(locale);
// dynamically update the HTML attributes for language and direction
// this is necessary for proper rendering of the page in the new language
useHead({
htmlAttrs: {
lang: locale,
dir: locales.value.find((l) => l.code === locale)?.dir || "ltr",
},
});
}
function localeToEmoji(local: string): string {
switch (local) {
// Default locale
case "en":
case "en-us":
return "🇺🇸";
case "en-gb":
return "🇬🇧";
case "en-ca":
return "🇨🇦";
case "en-au":
return "🇦🇺";
case "en-pirate":
return "🏴‍☠️";
case "fr":
return "🇫🇷";
case "de":
return "🇩🇪";
case "es":
return "🇪🇸";
case "it":
return "🇮🇹";
case "zh":
return "🇨🇳";
case "zh-tw":
return "🇹🇼";
default: {
return "❓";
}
}
}
const wiredLocale = computed({
get() {
return currLocale.value;
},
set(v) {
changeLocale(v);
},
});
const currentLocaleInformation = computed(() =>
locales.value.find((e) => e.code == wiredLocale.value),
);
</script>

27
components/LogLine.vue Normal file
View File

@ -0,0 +1,27 @@
<template>
<span class="text-xs font-mono text-zinc-400 inline-flex items-top gap-x-2"
><span v-if="!short" class="text-zinc-500">{{ log.timestamp }}</span>
<span
:class="[
colours[log.level] || 'text-green-400',
'uppercase font-display font-semibold',
]"
>{{ log.level }}</span
>
<pre :class="[short ? 'line-clamp-1' : '', 'mt-[1px]']">{{
log.message
}}</pre>
</span>
</template>
<script setup lang="ts">
import type { TaskLog } from "~/server/internal/tasks";
defineProps<{ log: typeof TaskLog.infer; short?: boolean }>();
const colours: { [key: string]: string } = {
info: "text-blue-400",
warn: "text-yellow-400",
error: "text-red-400",
};
</script>

View File

@ -0,0 +1,246 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.companies.addGame.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.companies.addGame.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => addGame()">
<Listbox v-model="currentGame" as="div">
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.selectGameSearch") }}</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="currentGame"
:game="currentGame"
/>
<span v-else class="block truncate text-zinc-600">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</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"
>
<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
v-for="result in metadataGames"
:key="result.id"
v-slot="{ active }"
as="template"
:value="result"
>
<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>
<p
v-if="metadataGames.length == 0"
class="w-full text-center p-2 uppercase font-display text-zinc-700 font-bold"
>
{{ $t("library.admin.metadata.companies.addGame.noGames") }}
</p>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="mt-6 flex items-center justify-between gap-3">
<label
id="published-label"
for="published"
class="font-medium text-md text-zinc-100"
>{{
$t("library.admin.metadata.companies.addGame.publisher")
}}</label
>
<div
class="group/published relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-5"
/>
<input
id="published"
v-model="published"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="published-label"
/>
</div>
</div>
<div class="mt-2 flex items-center justify-between gap-3">
<label
id="developer-label"
for="developer"
class="font-medium text-md text-zinc-100"
>{{
$t("library.admin.metadata.companies.addGame.developer")
}}</label
>
<div
class="group/developer relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developer:translate-x-5"
/>
<input
id="developer"
v-model="developed"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="developer-label"
/>
</div>
</div>
<button class="hidden" type="submit" />
</form>
</div>
<div v-if="addError" 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">
{{ addError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="addGameLoading"
:disabled="!(currentGame && (developed || published))"
class="w-full sm:w-fit"
@click="() => addGame()"
>
{{ $t("common.add") }}
</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()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { GameModel } from "~/prisma/client/models";
import {
DialogTitle,
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const props = defineProps<{
companyId: string;
exclude?: string[];
}>();
const emit = defineEmits<{
created: [
game: SerializeObject<GameModel>,
published: boolean,
developed: boolean,
];
}>();
const games = await $dropFetch("/api/v1/admin/game");
const metadataGames = computed(() =>
games
.filter((e) => !(props.exclude ?? []).includes(e.id))
.map(
(e) =>
({
id: e.id,
name: e.mName,
icon: useObject(e.mIconObjectId),
description: e.mShortDescription,
}) satisfies Omit<GameMetadataSearchResult, "year">,
),
);
const { t } = useI18n();
const open = defineModel<boolean>({ required: true });
const currentGame = ref<(typeof metadataGames.value)[number]>();
const developed = ref(false);
const published = ref(false);
const addGameLoading = ref(false);
const addError = ref<string | undefined>(undefined);
async function addGame() {
if (!currentGame.value) return;
addGameLoading.value = true;
try {
const game = await $dropFetch("/api/v1/admin/company/:id/game", {
method: "POST",
params: { id: props.companyId },
body: {
id: currentGame.value.id,
developed: developed.value,
published: published.value,
},
});
emit("created", game, published.value, developed.value);
} catch (e) {
if (e instanceof FetchError) {
addError.value = e.message ?? t("errors.unknown");
} else {
throw e;
}
} finally {
currentGame.value = undefined;
developed.value = false;
published.value = false;
addGameLoading.value = false;
open.value = false;
}
}
</script>

View File

@ -29,7 +29,7 @@
class="w-full sm:w-fit" class="w-full sm:w-fit"
@click="() => createCollection()" @click="() => createCollection()"
> >
{{ $t("create") }} {{ $t("common.create") }}
</LoadingButton> </LoadingButton>
<button <button
ref="cancelButtonRef" ref="cancelButtonRef"
@ -46,7 +46,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue"; import { DialogTitle } from "@headlessui/vue";
import type { CollectionEntry, Game } from "~/prisma/client"; import type { CollectionEntryModel, GameModel } from "~/prisma/client/models";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
const props = defineProps<{ const props = defineProps<{
@ -79,7 +79,7 @@ async function createCollection() {
// Add the game if provided // Add the game if provided
if (props.gameId) { if (props.gameId) {
const entry = await $dropFetch< const entry = await $dropFetch<
CollectionEntry & { game: SerializeObject<Game> } CollectionEntryModel & { game: SerializeObject<GameModel> }
>(`/api/v1/collection/${response.id}/entry`, { >(`/api/v1/collection/${response.id}/entry`, {
method: "POST", method: "POST",
body: { id: props.gameId }, body: { id: props.gameId },
@ -97,13 +97,13 @@ async function createCollection() {
} catch (error) { } catch (error) {
console.error("Failed to create collection:", error); console.error("Failed to create collection:", error);
const err = error as { statusMessage?: string }; const err = error as { message?: string };
createModal( createModal(
ModalType.Notification, ModalType.Notification,
{ {
title: t("errors.library.collection.create.title"), title: t("errors.library.collection.create.title"),
description: t("errors.library.collection.create.desc", [ description: t("errors.library.collection.create.desc", [
err?.statusMessage ?? t("errors.unknown"), err?.message ?? t("errors.unknown"),
]), ]),
}, },
(_, c) => c(), (_, c) => c(),

View File

@ -0,0 +1,148 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<h3 class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.companies.modals.createTitle") }}
</h3>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.companies.modals.createDescription") }}
</p>
</div>
<div class="mt-2">
<form class="space-y-4" @submit.prevent="() => createCompany()">
<div>
<label
for="name"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t("library.admin.metadata.companies.modals.createFieldName")
}}</label
>
<div class="mt-2">
<input
id="name"
v-model="companyName"
type="text"
name="name"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldNamePlaceholder',
)
"
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"
>{{
$t(
"library.admin.metadata.companies.modals.createFieldDescription",
)
}}</label
>
<div class="mt-2">
<input
id="description"
v-model="companyDescription"
type="text"
name="description"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldDescriptionPlaceholder',
)
"
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="website"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t("library.admin.metadata.companies.modals.createFieldWebsite")
}}</label
>
<div class="mt-2">
<input
id="website"
v-model="companyWebsite"
type="text"
name="website"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldWebsitePlaceholder',
)
"
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>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="loading"
:disabled="!companyValid"
class="w-full sm:w-fit"
@click="() => createCompany()"
>
{{ $t("common.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()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { CompanyModel } from "~/prisma/client/models";
const open = defineModel<boolean>({ required: true });
const emit = defineEmits<{
created: [company: CompanyModel];
}>();
const companyName = ref("");
const companyDescription = ref("");
const companyWebsite = ref("");
const loading = ref(false);
const companyValid = computed(
() => companyName.value && companyDescription.value,
);
async function createCompany() {
loading.value = true;
try {
const newCompany = await $dropFetch("/api/v1/admin/company", {
method: "POST",
body: {
name: companyName.value,
description: companyDescription.value,
website: companyWebsite.value,
},
failTitle: "Failed to create new company",
});
open.value = false;
emit("created", newCompany);
} finally {
/* empty */
}
loading.value = false;
}
</script>

View File

@ -0,0 +1,78 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.tags.modal.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.tags.modal.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => createTag()">
<input
v-model="tagName"
type="text"
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="createTagLoading"
:disabled="!tagName"
class="w-full sm:w-fit"
@click="() => createTag()"
>
{{ $t("common.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()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { GameTagModel } from "~/prisma/client/models";
const emit = defineEmits<{
created: [tag: GameTagModel];
}>();
const open = defineModel<boolean>({ required: true });
const tagName = ref("");
const createTagLoading = ref(false);
async function createTag() {
if (!tagName.value || createTagLoading.value) return;
createTagLoading.value = true;
// Create the collection
const tag = await $dropFetch("/api/v1/admin/tags", {
method: "POST",
body: { name: tagName.value },
failTitle: "Failed to create tag",
});
// Reset and emit
tagName.value = "";
open.value = false;
emit("created", tag);
createTagLoading.value = false;
}
</script>

View File

@ -0,0 +1,267 @@
<template>
<ModalTemplate v-model="model" size-class="max-w-3xl">
<template #default>
<div class="space-y-5">
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("account.token.name") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("account.token.nameDesc") }}
</p>
<div class="mt-2">
<input
id="name"
v-model="name"
name="name"
type="text"
autocomplete="name"
:placeholder="
props.suggestedName ?? $t('account.token.namePlaceholder')
"
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>
</div>
<div>
<Listbox v-model="expiryKey" as="div">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
$t("users.admin.simple.inviteExpiryLabel")
}}</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{ expiryKey }}</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"
>
<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
v-for="[label] in Object.entries(expiry)"
:key="label"
v-slot="{ active, selected }"
as="template"
:value="label"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ label }}</span
>
<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>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("account.token.acls") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("account.token.aclsDesc") }}
</p>
<fieldset class="divide-y divide-zinc-700">
<div
v-for="[sectionName, sectionAcls] in Object.entries(
aclsBySection,
)"
:key="sectionName"
class="grid lg:grid-cols-3 gap-1 py-3"
>
<div
v-for="[acl, description] in Object.entries(sectionAcls)"
:key="acl"
class="flex gap-3"
>
<div class="flex h-6 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
id="acl"
v-model="currentACLs[acl]"
aria-describedby="acl-description"
name="acl"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 border-white/10 bg-white/5 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:indeterminate:border-blue-500 dark:indeterminate:bg-blue-500 dark:focus-visible:outline-blue-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto"
/>
<svg
class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-white/25"
viewBox="0 0 14 14"
fill="none"
>
<path
class="opacity-0 group-has-checked:opacity-100"
d="M3 8L6 11L11 3.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
class="opacity-0 group-has-indeterminate:opacity-100"
d="M3 7H11"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
<div class="text-sm/6">
<label
for="acl"
class="font-display font-medium text-white"
>{{ acl }}</label
>
{{ " " }}
<span id="acl-description" class="text-xs text-zinc-400"
><span class="sr-only">{{ acl }} </span
>{{ description }}</span
>
</div>
</div>
</div>
</fieldset>
</div>
</div>
</template>
<template #buttons>
<LoadingButton :loading="props.loading" @click="() => createToken()">
{{ $t("common.create") }}
</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="() => cancel()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
import type { DurationLike } from "luxon";
// Reuse for both admin and user tokens
const model = defineModel<boolean>({ required: true });
const { t } = useI18n();
const props = defineProps<{
acls: { [key: string]: string };
loading?: boolean;
suggestedAcls?: string[];
suggestedName?: string;
}>();
// Label to parameters to moment.js .add()
const expiry: Record<string, DurationLike | undefined> = {
[t("account.token.expiryMonth")]: {
month: 1,
},
[t("account.token.expiry3Month")]: {
month: 3,
},
[t("account.token.expiry6Month")]: {
month: 6,
},
[t("account.token.expiryYear")]: {
year: 1,
},
[t("account.token.expiry5Year")]: {
year: 5,
},
[t("account.token.noExpiry")]: undefined,
};
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0]); // Cast to any because we just know it's okay
const name = ref(props.suggestedName ?? "");
const currentACLs = ref<{ [key: string]: boolean }>(
Object.fromEntries((props.suggestedAcls ?? []).map((v) => [v, true])),
);
const aclsBySection = computed(() => {
const sections: { [key: string]: { [key: string]: string } } = {};
for (const [acl, description] of Object.entries(props.acls)) {
const section = acl.split(":")[0];
sections[section] ??= {};
sections[section][acl] = description;
}
return sections;
});
const emit = defineEmits<{
create: [name: string, acls: string[], expiry: DurationLike | undefined];
}>();
function createToken() {
emit(
"create",
name.value,
Object.entries(currentACLs.value)
.filter(([_acl, enabled]) => enabled)
.map(([acl, _enabled]) => acl),
expiry[expiryKey.value],
);
}
function cancel() {
model.value = false;
}
watch(model, (c) => {
if (!c) {
name.value = "";
currentACLs.value = {};
}
});
</script>

View File

@ -35,10 +35,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Collection } from "~/prisma/client"; import type { CollectionModel } from "~/prisma/client/models";
import { DialogTitle } from "@headlessui/vue"; import { DialogTitle } from "@headlessui/vue";
const collection = defineModel<Collection | undefined>(); const collection = defineModel<CollectionModel | undefined>();
const deleteLoading = ref(false); const deleteLoading = ref(false);
const collections = await useCollections(); const collections = await useCollections();
@ -49,9 +49,11 @@ async function deleteCollection() {
if (!collection.value) return; if (!collection.value) return;
deleteLoading.value = true; deleteLoading.value = true;
await $dropFetch(`/api/v1/collection/${collection.value.id}`, { await $dropFetch(`/api/v1/collection/:id`, {
// @ts-expect-error not documented
method: "DELETE", method: "DELETE",
params: {
id: collection.value.id,
},
}); });
const index = collections.value.findIndex( const index = collections.value.findIndex(
(e) => e.id == collection.value?.id, (e) => e.id == collection.value?.id,
@ -65,8 +67,8 @@ async function deleteCollection() {
{ {
title: t("errors.library.add.title"), title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [ description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display statusMessage on error // @ts-expect-error attempt to display message on error
e?.statusMessage ?? t("errors.unknown"), e?.message ?? t("errors.unknown"),
]), ]),
}, },
(_, c) => c(), (_, c) => c(),

View File

@ -71,8 +71,8 @@ async function deleteArticle() {
{ {
title: t("errors.news.article.delete.title"), title: t("errors.news.article.delete.title"),
description: t("errors.news.article.delete.desc", [ description: t("errors.news.article.delete.desc", [
// @ts-expect-error attempt to display statusMessage on error // @ts-expect-error attempt to display message on error
e?.statusMessage ?? t("errors.unknown"), e?.message ?? t("errors.unknown"),
]), ]),
}, },
(_, c) => c(), (_, c) => c(),

View File

@ -36,9 +36,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogTitle } from "@headlessui/vue"; import { DialogTitle } from "@headlessui/vue";
import type { User } from "~/prisma/client"; import type { UserModel } from "~/prisma/client/models";
const user = defineModel<User | undefined>(); const user = defineModel<UserModel | undefined>();
const deleteLoading = ref(false); const deleteLoading = ref(false);
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
@ -62,8 +62,8 @@ async function deleteUser() {
{ {
title: t("errors.admin.user.delete.title"), title: t("errors.admin.user.delete.title"),
description: t("errors.admin.user.delete.desc", [ description: t("errors.admin.user.delete.desc", [
// @ts-expect-error attempt to display statusMessage on error // @ts-expect-error attempt to display message on error
e?.statusMessage ?? t("errors.unknown"), e?.message ?? t("errors.unknown"),
]), ]),
}, },
(_, c) => c(), (_, c) => c(),

View File

@ -67,7 +67,7 @@
class="hidden" class="hidden"
type="file" type="file"
:multiple="props.multiple" :multiple="props.multiple"
@change="(e) => (file = (e.target as any)?.files)" @change="(e: Event) => (file = (e.target as any)?.files)"
/> />
</div> </div>
</div> </div>
@ -177,7 +177,7 @@ function uploadFile_wrapper() {
uploadLoading.value = true; uploadLoading.value = true;
uploadFile() uploadFile()
.catch((error) => { .catch((error) => {
uploadError.value = error.statusMessage ?? t("errors.unknown"); uploadError.value = error.message ?? t("errors.unknown");
}) })
.finally(() => { .finally(() => {
uploadLoading.value = false; uploadLoading.value = false;

View File

@ -0,0 +1,115 @@
<template>
<div>
<div class="inline-flex gap-1 items-center flex-wrap">
<span
v-for="item in enabledItems"
:key="item.param"
class="inline-flex items-center gap-x-0.5 rounded-md bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-500 ring-1 ring-blue-800 ring-inset"
>
{{ item.name }}
<button
type="button"
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-600/20"
@click="() => remove(item.param)"
>
<span class="sr-only">{{ $t("common.remove") }}</span>
<svg
viewBox="0 0 14 14"
class="size-3.5 stroke-blue-500 group-hover:stroke-blue-400"
>
<path d="M4 4l6 6m0-6l-6 6" />
</svg>
<span class="absolute -inset-1" />
</button>
</span>
<span
v-if="enabledItems.length == 0"
class="font-display uppercase text-xs font-bold text-zinc-700"
>
{{ $t("common.noSelected") }}
</span>
</div>
<Combobox as="div" @update:model-value="add">
<div class="relative mt-2">
<ComboboxInput
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:display-value="(item) => (item as StoreSortOption)?.name"
placeholder="Start typing..."
@change="search = $event.target.value"
@blur="search = ''"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
>
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
v-if="filteredItems.length > 0 || search.length > 0"
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ComboboxOption
v-for="item in filteredItems"
:key="item.param"
v-slot="{ active }"
:value="item.param"
as="template"
>
<li
:class="[
'relative cursor-default py-2 pr-9 pl-3 select-none',
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
]"
>
<span class="block truncate">
{{ item.name }}
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from "@heroicons/vue/20/solid";
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
const props = defineProps<{
items: Array<StoreSortOption>;
}>();
const model = defineModel<{ [key: string]: boolean }>();
const search = ref("");
const filteredItems = computed(() =>
props.items.filter(
(item) =>
!model.value?.[item.param] &&
item.name.toLowerCase().includes(search.value.toLowerCase()),
),
);
const enabledItems = computed(() =>
props.items.filter((e) => model.value?.[e.param]),
);
function add(item: string) {
search.value = "";
model.value ??= {};
model.value[item] = true;
}
function remove(item: string) {
model.value ??= {};
model.value[item] = false;
}
</script>

View File

@ -132,7 +132,7 @@
accept="image/*" accept="image/*"
class="hidden" class="hidden"
type="file" type="file"
@change="(e) => (file = (e.target as any)?.files)" @change="(e: Event) => (file = (e.target as any)?.files)"
/> />
</div> </div>
@ -414,8 +414,8 @@ async function createArticle() {
modalOpen.value = false; modalOpen.value = false;
} catch (e) { } catch (e) {
// @ts-expect-error attempt to get statusMessage on error // @ts-expect-error attempt to get message on error
error.value = e?.statusMessage ?? t("errors.unknown"); error.value = e?.message ?? t("errors.unknown");
} finally { } finally {
loading.value = false; loading.value = false;
} }

View File

@ -33,7 +33,7 @@
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" 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()" @click="() => deleteMe()"
> >
<span class="sr-only">{{ $t("close") }}</span> <span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" /> <XMarkIcon class="size-5" aria-hidden="true" />
</button> </button>
</div> </div>
@ -44,13 +44,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/solid"; import { XMarkIcon } from "@heroicons/vue/24/solid";
import type { Notification } from "~/prisma/client"; import type { NotificationModel } from "~/prisma/client/models";
const props = defineProps<{ notification: Notification }>(); const props = defineProps<{ notification: NotificationModel }>();
async function deleteMe() { async function deleteMe() {
await $dropFetch(`/api/v1/notifications/${props.notification.id}`, { await $dropFetch(`/api/v1/notifications/:id`, {
method: "DELETE", method: "DELETE",
params: {
id: props.notification.id,
},
}); });
const notifications = useNotifications(); const notifications = useNotifications();
const indexOfMe = notifications.value.findIndex( const indexOfMe = notifications.value.findIndex(

View File

@ -0,0 +1,22 @@
<template>
<div
:class="[
'transition border border-3 rounded-xl relative cursor-pointer',
active ? 'border-blue-600' : 'border-zinc-700',
]"
>
<div v-if="active" class="absolute top-1 right-1 z-1">
<CheckIcon
class="rounded-full p-1.5 bg-blue-600 size-6 text-transparent stroke-3 stroke-zinc-900 font-bold"
/>
</div>
<slot />
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from "@heroicons/vue/24/solid";
const { active = false } = defineProps<{ active?: boolean }>();
</script>

View File

@ -1,19 +1,19 @@
<template> <template>
<Listbox v-model="typedModel" as="div"> <Listbox v-model="model" as="div">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100" <ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
><slot ><slot
/></ListboxLabel> /></ListboxLabel>
<div class="relative mt-2"> <div class="relative">
<ListboxButton <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" 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" class="flex items-center"> <span v-if="currentEntry" class="flex items-center">
<component <IconsPlatform
:is="PLATFORM_ICONS[model]" :platform="currentEntry.platformIcon.key"
alt="" :fallback="currentEntry.platformIcon.fallback"
class="h-5 w-5 flex-shrink-0 text-blue-600" class="h-5 w-5 flex-shrink-0 text-blue-600"
/> />
<span class="ml-3 block truncate">{{ model }}</span> <span class="ml-3 block truncate">{{ currentEntry.name }}</span>
</span> </span>
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span> <span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
<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" 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 <ListboxOption
v-for="[name, value] in Object.entries(values)" v-for="entry in values"
:key="value" :key="entry.param"
v-slot="{ active, selected }" v-slot="{ active, selected }"
as="template" as="template"
:value="value" :value="entry.param"
> >
<li <li
:class="[ :class="[
@ -45,15 +45,13 @@
]" ]"
> >
<div class="flex items-center"> <div class="flex items-center">
<component <IconsPlatform
:is="PLATFORM_ICONS[value]" v-if="entry.platformIcon"
alt="" :platform="entry.platformIcon.key"
:class="[ :fallback="entry.platformIcon.fallback"
active ? 'text-zinc-100' : 'text-blue-600', class="size-5 text-blue-500"
'h-5 w-5 flex-shrink-0',
]"
/> />
<span class="ml-3 block truncate">{{ name }}</span> <span class="ml-3 block truncate">{{ entry.name }}</span>
</div> </div>
<span <span
@ -83,17 +81,14 @@ import {
} from "@headlessui/vue"; } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
const model = defineModel<PlatformClient | undefined>(); const model = defineModel<string | undefined>();
const typedModel = computed<PlatformClient | null>({ const props = defineProps<{ platforms: PlatformRenderable[] }>();
get() { const currentEntry = computed(() =>
return model.value || null; model.value
}, ? props.platforms.find((v) => v.param === model.value)
set(v) { : undefined,
if (v === null) return (model.value = undefined); );
model.value = v;
},
});
const values = Object.fromEntries(Object.entries(PlatformClient)); const values = props.platforms;
</script> </script>

View File

@ -0,0 +1,120 @@
<template>
<Combobox
as="div"
:value="props.value"
nullable
@update:model-value="(v) => emit('update', 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="file.exe"
@change="query = $event.target.value"
@blur="query = ''"
/>
<ComboboxButton
v-if="filtered?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
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-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-for="guess in filtered"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<IconsPlatform
:platform="guess.platform.platformIcon.key"
:fallback="guess.platform.platformIcon.fallback"
class="size-5 flex-shrink-0 text-blue-600"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="query"
v-slot="{ active, selected }"
:value="query"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
]"
>
<span :class="['block truncate', selected && 'font-semibold']">
{{ $t("chars.quoted", { text: query }) }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
const emit = defineEmits<{
update: [v: string];
}>();
const props = defineProps<{
value?: string;
guesses?: Array<{ platform: PlatformRenderable; filename: string }>;
}>();
const query = ref("");
const filtered = computed(() =>
props.guesses?.filter((e) =>
e.filename.toLowerCase().includes(query.value.toLowerCase()),
),
);
</script>

View File

@ -0,0 +1,14 @@
<template>
<div>{{ model }}</div>
</template>
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
import type { RedistModel, UserPlatformModel } from "~/prisma/client/models";
type ModelType = SerializeObject<
RedistModel & { platform?: UserPlatformModel }
>;
const model = defineModel<ModelType>({ required: true });
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="relative inline-block group"> <div class="relative inline-block group/relative-time">
<!-- Visible relative time --> <!-- Visible relative time -->
<time :datetime="isoDate" class="text-sm text-muted-foreground"> <time :datetime="isoDate" class="text-sm text-muted-foreground">
{{ DateTime.fromJSDate(date).toRelative({ locale: $i18n.locale }) }} {{ DateTime.fromJSDate(date).toRelative({ locale: $i18n.locale }) }}
@ -8,7 +8,7 @@
<!-- Custom tooltip that shows on hover --> <!-- Custom tooltip that shows on hover -->
<div <div
role="tooltip" role="tooltip"
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 rounded bg-zinc-900 text-white text-xs whitespace-nowrap shadow z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 rounded bg-zinc-900 text-white text-xs whitespace-nowrap shadow z-10 opacity-0 group-hover/relative-time:opacity-100 transition-opacity pointer-events-none"
aria-hidden="true" aria-hidden="true"
> >
{{ $d(date, "long") }} {{ $d(date, "long") }}

View File

@ -0,0 +1,159 @@
<template>
<div class="p-2 lg:p-4">
<div class="px-4 py-2 max-w-xl">
<h1 class="font-semibold text-zinc-100 text-xl">
{{ $t("setup.auth.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("setup.auth.description") }}
</p>
</div>
<div class="grid lg:grid-cols-2 xl:grid-cols-3 h-fit p-4 gap-4">
<div class="p-4 border-1 border-zinc-800 rounded-xl">
<div>
<h1 class="text-zinc-100 font-semibold text-lg">
{{ $t("setup.auth.simple.title") }}
</h1>
<p class="text-sm text-zinc-400">
{{ $t("setup.auth.simple.description") }}
</p>
<NuxtLink
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
href="https://docs.droposs.org/docs/authentication/simple"
target="_blank"
>
<i18n-t
keypath="setup.auth.docs"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</div>
<div class="mt-4">
<div class="w-full flex justify-between items-center">
<span class="text-zinc-100 font-semibold text-sm">{{
$t("setup.auth.enabled")
}}</span>
<CheckIcon
v-if="enabledAuth.Simple"
class="size-5 text-green-600"
/>
<XMarkIcon v-else class="size-5 text-red-600" />
</div>
<LoadingButton
class="mt-4"
:loading="invitationLoading"
:disabled="!enabledAuth.Simple"
@click="() => registerAsAdmin()"
>
<i18n-t
keypath="setup.auth.simple.register"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
{{ $t("chars.arrow") }}
</template>
</i18n-t>
</LoadingButton>
</div>
</div>
<div class="p-4 border-1 border-zinc-800 rounded-xl">
<div>
<h1 class="text-zinc-100 font-semibold text-lg">
{{ $t("setup.auth.openid.title") }}
</h1>
<p class="text-sm text-zinc-400">
{{ $t("setup.auth.openid.description") }}
</p>
<NuxtLink
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
href="https://docs.droposs.org/docs/authentication/oidc"
target="_blank"
>
<i18n-t
keypath="setup.auth.docs"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</div>
<div class="mt-4">
<div class="w-full flex justify-between items-center">
<span class="text-zinc-100 font-semibold text-sm">{{
$t("setup.auth.enabled")
}}</span>
<CheckIcon
v-if="enabledAuth.OpenID"
class="size-5 text-green-600"
/>
<XMarkIcon v-else class="size-5 text-red-600" />
</div>
<LoadingButton
class="mt-4"
:loading="false"
:disabled="!enabledAuth.OpenID"
@click="() => (complete = true)"
>
<i18n-t
keypath="setup.auth.openid.skip"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
{{ $t("chars.arrow") }}
</template>
</i18n-t>
</LoadingButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ArrowTopRightOnSquareIcon,
CheckIcon,
XMarkIcon,
} from "@heroicons/vue/24/outline";
import { DateTime } from "luxon";
const complete = defineModel<boolean>({ required: true });
const { token } = defineProps<{ token: string }>();
const invitationLoading = ref(false);
const enabledAuth = await $dropFetch("/api/v1/admin/auth", {
headers: { Authorization: token },
});
async function registerAsAdmin() {
invitationLoading.value = true;
const expiryDate = DateTime.now().plus({ year: 5000 }).toJSON();
const invitation = await $dropFetch("/api/v1/admin/auth/invitation", {
method: "POST",
body: { isAdmin: true, expires: expiryDate },
headers: { Authorization: token },
failTitle: "Failed to create admin invitation",
});
window.open(`${invitation.inviteUrl}&after=close`, "_blank")?.focus();
invitationLoading.value = false;
complete.value = true;
}
</script>

View File

@ -0,0 +1,15 @@
<template>
<div class="p-8">
<AdminSourcesPage :token="token" />
</div>
</template>
<script setup lang="ts">
import AdminSourcesPage from "~/pages/admin/library/sources/index.vue";
const complete = defineModel<boolean>({ required: true });
// Only runs on component load, so it's fine
complete.value = true;
const { token } = defineProps<{ token: string }>();
</script>

View File

@ -0,0 +1,27 @@
<template>
<div>
<label
for="path"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.sources.fsPath") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.fsPathDesc") }}
</p>
<div class="mt-2">
<input
id="path"
v-model="model!.baseDir"
name="path"
type="text"
autocomplete="path"
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
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>
</div>
</template>
<script setup lang="ts">
const model = defineModel<{ baseDir: string }>();
</script>

496
components/StoreView.vue Normal file
View File

@ -0,0 +1,496 @@
<template>
<div>
<div>
<!-- Mobile filter dialog -->
<TransitionRoot as="template" :show="mobileFiltersOpen">
<Dialog
class="relative z-100 lg:hidden"
@close="mobileFiltersOpen = 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-black/25" />
</TransitionChild>
<div class="fixed inset-0 z-40 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 ml-auto flex size-full max-w-sm flex-col overflow-y-auto bg-zinc-900 pt-4 pb-6 shadow-xl"
>
<div class="flex items-center justify-between px-4">
<h2 class="text-lg font-medium text-zinc-100">
{{ $t("store.view.srFilters") }}
</h2>
<button
type="button"
class="relative -mr-2 flex size-10 items-center justify-center rounded-md bg-zinc-900 p-2 text-zinc-500 hover:bg-zinc-800 focus:ring-2 focus:ring-blue-500 focus:outline-hidden"
@click="mobileFiltersOpen = false"
>
<span class="absolute -inset-0.5" />
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-6" aria-hidden="true" />
</button>
</div>
<!-- Filters -->
<form class="mt-4 border-t border-zinc-700">
<Disclosure
v-for="section in options"
v-slot="{ open }"
:key="section.param"
as="div"
class="border-t border-zinc-700 px-4 py-6"
>
<h3 class="-mx-2 -my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 px-2 py-3 text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div
v-if="section.options.length <= 10"
class="gap-3 grid grid-cols-2"
>
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${option}`"
v-model="
(optionValues[section.param] as any)[
option.param
]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-900 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@update:value="
() =>
(optionValues[section.param] = option.param)
"
/>
</div>
</div>
<label
:for="`filter-mobile-${section.param}-${optionIdx}`"
class="min-w-0 flex-1 text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<main class="mx-auto px-4 sm:px-6 lg:px-8">
<div
class="flex items-baseline justify-between border-b border-zinc-700 py-6"
>
<div />
<div class="flex items-center">
<Menu as="div" class="relative inline-block text-left">
<div>
<MenuButton
class="group inline-flex justify-center text-sm font-medium text-zinc-400 hover:text-zinc-100"
>
{{ $t("store.view.sort") }}
<ChevronDownIcon
class="-mr-1 ml-1 size-5 shrink-0 text-gray-400 group-hover:text-zinc-100"
aria-hidden="true"
/>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-zinc-950 shadow-2xl ring-1 ring-white/5 focus:outline-hidden"
>
<div class="py-1">
<MenuItem
v-for="option in sorts"
:key="option.param"
v-slot="{ active }"
>
<button
:class="[
currentSort == option.param
? 'font-medium text-zinc-100'
: 'text-zinc-400',
active ? 'bg-zinc-900 outline-hidden' : '',
'w-full text-left block px-4 py-2 text-sm',
]"
@click="() => (currentSort = option.param)"
>
{{ option.name }}
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
<button
v-if="false"
type="button"
class="-m-2 ml-5 p-2 text-zinc-500 hover:text-zinc-400 sm:ml-7"
>
<span class="sr-only">{{ $t("store.view.srViewGrid") }}</span>
<Squares2X2Icon class="size-5" aria-hidden="true" />
</button>
<button
type="button"
:class="[
'-m-2 ml-4 p-2 sm:ml-6 lg:hidden',
filterQuery
? 'text-zinc-100 hover:text-zinc-200'
: 'text-zinc-500 hover:text-zinc-400',
]"
@click="mobileFiltersOpen = true"
>
<span class="sr-only"> {{ $t("store.view.srFilters") }} </span>
<FunnelIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
<section aria-labelledby="games-heading" class="pt-6 pb-24">
<h2 id="games-heading" class="sr-only">
{{ $t("store.view.srGames") }}
</h2>
<div class="grid grid-cols-1 gap-x-8 gap-y-10 lg:grid-cols-5">
<!-- Filters -->
<form class="hidden lg:block">
<Disclosure
v-for="section in options"
:key="section.param"
v-slot="{ open }"
as="div"
class="border-b border-zinc-700 py-6"
>
<h3 class="-my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 py-3 text-sm text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div v-if="section.options.length <= 10" class="space-y-4">
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex items-center gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${optionIdx}`"
v-model="
(optionValues[section.param] as any)[option.param]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}-${optionIdx}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="radio"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@input="optionValues[section.param] = option.param"
/>
</div>
</div>
<IconsPlatform
v-if="option.platformIcon"
:platform="option.platformIcon.key"
:fallback="option.platformIcon.fallback"
class="size-5 text-blue-500"
/>
<label
:for="`filter-${section.param}-${optionIdx}`"
class="text-sm text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
<!-- Product grid -->
<div
v-if="games?.length ?? 0 > 0"
ref="product-grid"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
>
<!-- Your content -->
<GamePanel
v-for="game in games"
:key="game.id"
:game="game"
:href="`/store/${game.id}`"
:show-title-description="showGamePanelTextDecoration"
/>
<div
v-if="loading"
class="absolute inset-0 bg-zinc-900/40 flex items-center justify-center"
>
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
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>
</div>
</div>
<div v-else class="flex lg:col-span-4 items-start justify-center">
<span class="uppercase text-zinc-700 font-display font-bold">{{
$t("common.noResults")
}}</span>
</div>
</div>
</section>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import {
Dialog,
DialogPanel,
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { XMarkIcon } from "@heroicons/vue/24/outline";
import {
ChevronDownIcon,
FunnelIcon,
MinusIcon,
PlusIcon,
Squares2X2Icon,
} from "@heroicons/vue/20/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel, GameTagModel } from "~/prisma/client/models";
import MultiItemSelector from "./MultiItemSelector.vue";
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const mobileFiltersOpen = ref(false);
const props = defineProps<{
params?: { [key: string]: string };
extraOptions?: Array<StoreFilterOption>;
prefilled?: {
[key: string]: { [key: string]: string | { [key: string]: boolean } };
};
}>();
const tags =
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
const userPlatforms = await $dropFetch("/api/v1/store/platforms");
const sorts: Array<StoreSortOption> = [
{
name: "Default",
param: "default",
},
{
name: "Newest",
param: "newest",
},
{
name: "Recently Added",
param: "recent",
},
];
const currentSort = ref(sorts[0].param);
const options: Array<StoreFilterOption> = [
...(tags.length > 0
? [
{
name: "Tags",
param: "tags",
multiple: true,
options: tags.map((e) => ({ name: e.name, param: e.id })),
},
]
: []),
{
name: "Platform",
param: "platform",
multiple: true,
options: renderPlatforms(userPlatforms),
},
...(props.extraOptions ?? []),
];
const optionValues = ref<{
[key: string]: string | undefined | { [key: string]: boolean | undefined };
}>(
Object.fromEntries(
options.map((v) => [v.param, v.multiple ? {} : undefined]),
),
);
Object.assign(optionValues.value, props.prefilled);
const filterQuery = computed(() => {
const query = Object.entries(optionValues.value)
.filter(
([_, v]) =>
v &&
(typeof v !== "object" || Object.values(v).filter((e) => e).length > 0),
)
.map(([n, v]) => {
if (typeof v === "string") return [`${n}=${v}`];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const enabledOptions = Object.entries(v as any).filter(([_, e]) => e);
return `${n}=${enabledOptions.map(([k, _]) => k).join(",")}`;
})
.join("&");
const extraFilters = props.params
? Object.entries(props.params)
.map(([k, v]) => `${k}=${v}`)
.join("&")
: props.params;
return `${query}${extraFilters ? (query ? "&" : "") + extraFilters : ""}`;
});
const games = ref<Array<SerializeObject<GameModel>>>();
const loading = ref(false);
const productGrid = useTemplateRef<HTMLElement>("product-grid");
const { reset } = useInfiniteScroll(
productGrid,
async () => await updateGames(filterQuery.value, false),
{
distance: 10,
canLoadMore: () => {
return canLoadMore.value;
},
},
);
const canLoadMore = ref(true);
async function updateGames(query: string, resetGames: boolean) {
loading.value = true;
games.value ??= [];
const newValues = await $dropFetch<{
results: Array<SerializeObject<GameModel>>;
count: number;
}>(
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
);
if (resetGames) {
games.value = newValues.results;
if (import.meta.client) await reset();
} else {
games.value.push(...newValues.results);
}
canLoadMore.value = games.value.length < newValues.count;
loading.value = false;
}
watch(filterQuery, (newUrl) => {
updateGames(newUrl, true);
});
watch(currentSort, (_) => {
updateGames(filterQuery.value, true);
});
await updateGames(filterQuery.value, true);
</script>

55
components/TaskWidget.vue Normal file
View File

@ -0,0 +1,55 @@
<template>
<div
v-if="task"
class="flex w-full items-center justify-between space-x-6 p-6"
>
<div class="flex-1 truncate">
<div class="flex items-center space-x-1">
<div>
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
<XMarkIcon v-else-if="task.error" class="size-5 text-red-600" />
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse m-1"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.name }}
</h3>
</div>
<div
v-if="active"
class="mt-2 w-full rounded-full overflow-hidden bg-zinc-900"
>
<div
:style="{ width: `${task.progress}%` }"
class="bg-blue-600 h-[3px] transition-all"
/>
</div>
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
</div>
<NuxtLink
type="button"
:href="`/admin/task/${task.id}`"
class="mt-3 ml-1 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t keypath="tasks.admin.viewTask" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-else>
<!-- renders server side when we don't want to access the current tasks -->
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import type { TaskMessage } from "~/server/internal/tasks";
defineProps<{ task: TaskMessage | undefined; active?: boolean }>();
</script>

View File

@ -91,10 +91,11 @@
</div> </div>
<div class="flex items-center justify-center xl:col-span-3 mt-8"> <div class="flex items-center justify-center xl:col-span-3 mt-8">
<p <NuxtLink
:to="`https://github.com/Drop-OSS/drop/releases/tag/${versionInfo.version}`"
class="text-xs text-zinc-700 hover:text-zinc-400 transition-colors duration-200 cursor-default select-none" class="text-xs text-zinc-700 hover:text-zinc-400 transition-colors duration-200 cursor-default select-none"
> >
<i18n-t keypath="footer.version" tag="p" scope="global"> <i18n-t keypath="footer.version" tag="span" scope="global">
<template #version> <template #version>
<span>{{ versionInfo.version }}</span> <span>{{ versionInfo.version }}</span>
</template> </template>
@ -102,7 +103,7 @@
<span>{{ versionInfo.gitRef }}</span> <span>{{ versionInfo.gitRef }}</span>
</template> </template>
</i18n-t> </i18n-t>
</p> </NuxtLink>
</div> </div>
</div> </div>
</div> </div>
@ -116,7 +117,7 @@ const { t } = useI18n();
const versionInfo = await $dropFetch("/api/v1"); const versionInfo = await $dropFetch("/api/v1");
const navigation = { const navigation = computed(() => ({
games: [ games: [
{ name: t("store.recentlyAdded"), href: "#" }, { name: t("store.recentlyAdded"), href: "#" },
{ name: t("store.recentlyReleased"), href: "#" }, { name: t("store.recentlyReleased"), href: "#" },
@ -156,5 +157,5 @@ const navigation = {
icon: IconsDiscordLogo, icon: IconsDiscordLogo,
}, },
], ],
}; }));
</script> </script>

View File

@ -46,7 +46,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Notification } from "~/prisma/client"; import type { NotificationModel } from "~/prisma/client/models";
const props = defineProps<{ notifications: Array<Notification> }>(); const props = defineProps<{ notifications: Array<NotificationModel> }>();
</script> </script>

View File

@ -100,6 +100,11 @@ const navigation = computed<NavigationItem[]>(() =>
route: "/account", route: "/account",
prefix: "", prefix: "",
}, },
{
label: "Authorize client",
route: "/client/code",
prefix: "",
},
].filter((e) => e !== undefined), ].filter((e) => e !== undefined),
); );
</script> </script>

View File

@ -1,8 +1,12 @@
import type { Collection, CollectionEntry, Game } from "~/prisma/client"; import type {
CollectionModel,
CollectionEntryModel,
GameModel,
} from "~/prisma/client/models";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
type FullCollection = Collection & { type FullCollection = CollectionModel & {
entries: Array<CollectionEntry & { game: SerializeObject<Game> }>; entries: Array<CollectionEntryModel & { game: SerializeObject<GameModel> }>;
}; };
export const useCollections = async () => { export const useCollections = async () => {

View File

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

View File

@ -1,11 +1,11 @@
import type { Article } from "~/prisma/client"; import type { ArticleModel } from "~/prisma/client/models";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
export const useNews = () => export const useNews = () =>
useState< useState<
| Array< | Array<
SerializeObject< SerializeObject<
Article & { ArticleModel & {
tags: Array<{ id: string; name: string }>; tags: Array<{ id: string; name: string }>;
author: { displayName: string; id: string } | null; author: { displayName: string; id: string } | null;
} }

View File

@ -1,12 +1,12 @@
import type { Notification } from "~/prisma/client"; import type { NotificationModel } from "~/prisma/client/models";
const ws = new WebSocketHandler("/api/v1/notifications/ws"); const ws = new WebSocketHandler("/api/v1/notifications/ws");
export const useNotifications = () => export const useNotifications = () =>
useState<Array<Notification>>("notifications", () => []); useState<Array<NotificationModel>>("notifications", () => []);
ws.listen((e) => { ws.listen((e) => {
const notification = JSON.parse(e) as Notification; const notification = JSON.parse(e) as NotificationModel;
const notifications = useNotifications(); const notifications = useNotifications();
notifications.value.push(notification); notifications.value.push(notification);
}); });

36
composables/platform.ts Normal file
View File

@ -0,0 +1,36 @@
import type { UserPlatform } from "~/prisma/client/client";
import { HardwarePlatform } from "~/prisma/client/enums";
export type PlatformRenderable = {
name: string;
param: string;
platformIcon: { key: string; fallback?: string };
};
export function renderPlatforms(
userPlatforms: { platformName: string; id: string; iconSvg: string }[],
): PlatformRenderable[] {
return [
...Object.values(HardwarePlatform).map((e) => ({
name: e,
param: e,
platformIcon: { key: e },
})),
...userPlatforms.map((e) => ({
name: e.platformName,
param: e.id,
platformIcon: { key: e.id, fallback: e.iconSvg },
})),
];
}
const rawUseAdminPlatforms = () => useState<Array<UserPlatform> | null>('adminPlatforms', () => null);
export async function useAdminPlatforms() {
const platforms = rawUseAdminPlatforms();
if(platforms.value === null){
platforms.value = await $dropFetch("/api/v1/admin/platforms");
}
return platforms.value!
}

View File

@ -4,6 +4,7 @@ import type {
NitroFetchRequest, NitroFetchRequest,
TypedInternalResponse, TypedInternalResponse,
} from "nitropack/types"; } from "nitropack/types";
import { FetchError } from "ofetch";
interface DropFetch< interface DropFetch<
DefaultT = unknown, DefaultT = unknown,
@ -15,7 +16,7 @@ interface DropFetch<
O extends NitroFetchOptions<R> = NitroFetchOptions<R>, O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>( >(
request: R, request: R,
opts?: O, opts?: O & { failTitle?: string },
): Promise< ): Promise<
// sometimes there is an error, other times there isn't // sometimes there is an error, other times there isn't
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -28,12 +29,50 @@ interface DropFetch<
>; >;
} }
export const $dropFetch: DropFetch = async (request, opts) => { export const $dropFetch: DropFetch = async (rawRequest, opts) => {
const requestParts = rawRequest.toString().split("/");
requestParts.forEach((part, index) => {
if (!part.startsWith(":")) {
return;
}
const partName = part.slice(1);
const replacement = opts?.params?.[partName] as string | undefined;
if (!replacement) {
return;
}
requestParts[index] = replacement;
delete opts?.params?.[partName];
});
const request = requestParts.join("/");
// If not in setup
if (!getCurrentInstance()?.proxy) { if (!getCurrentInstance()?.proxy) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment try {
// @ts-ignore Excessive stack depth comparing types // eslint-disable-next-line @typescript-eslint/ban-ts-comment
return await $fetch(request, opts); // @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts);
} catch (e) {
if (import.meta.client && opts?.failTitle) {
console.warn(e);
createModal(
ModalType.Notification,
{
title: opts.failTitle,
description:
(e as FetchError)?.message ?? (e as string).toString(),
//buttonText: $t("common.close"),
},
(_, c) => c(),
);
}
if(e instanceof FetchError) {
e.message = e.data.message ?? e.message;
}
throw e;
}
} }
const id = request.toString(); const id = request.toString();
const state = useState(id); const state = useState(id);
@ -41,14 +80,14 @@ export const $dropFetch: DropFetch = async (request, opts) => {
// Deep copy // Deep copy
const object = JSON.parse(JSON.stringify(state.value)); const object = JSON.parse(JSON.stringify(state.value));
// Never use again on client // Never use again on client
state.value = undefined; if (import.meta.client) state.value = undefined;
return object; return object;
} }
const headers = useRequestHeaders(["cookie", "authorization"]); const headers = useRequestHeaders(["cookie", "authorization"]);
const data = await $fetch(request, { const data = await $fetch(request, {
...opts, ...opts,
headers: { ...opts?.headers, ...headers }, headers: { ...headers, ...opts?.headers },
}); });
if (import.meta.server) state.value = data; if (import.meta.server) state.value = data;
return data; return data;

12
composables/store.ts Normal file
View File

@ -0,0 +1,12 @@
export type StoreFilterOption = {
name: string;
param: string;
options: Array<StoreSortOption>;
multiple?: boolean;
};
export type StoreSortOption = {
name: string;
param: string;
platformIcon?: { key: string; fallback?: string };
};

View File

@ -11,9 +11,3 @@ export type QuickActionNav = {
notifications?: Ref<number>; notifications?: Ref<number>;
action: () => Promise<void>; action: () => Promise<void>;
}; };
export enum PlatformClient {
Windows = "Windows",
Linux = "Linux",
macOS = "macOS",
}

View File

@ -1,13 +1,13 @@
import type { User } from "~/prisma/client"; import type { UserModel } from "~/prisma/client/models";
// undefined = haven't check // undefined = haven't check
// null = check, no user // null = check, no user
// {} = check, user // {} = check, user
export const useUser = () => useState<User | undefined | null>(undefined); export const useUser = () => useState<UserModel | undefined | null>(undefined);
export const updateUser = async () => { export const updateUser = async () => {
const user = useUser(); const user = useUser();
if (user.value === null) return; if (user.value === null) return;
user.value = await $dropFetch<User | null>("/api/v1/user"); user.value = await $dropFetch<UserModel | null>("/api/v1/user");
}; };

View File

@ -1,11 +1,12 @@
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { User, AuthMec } from "~/prisma/client"; import type { UserModel } from "~/prisma/client/models";
import type { AuthMec } from "~/prisma/client/enums";
export const useUsers = () => export const useUsers = () =>
useState< useState<
| Array< | Array<
SerializeObject< SerializeObject<
User & { UserModel & {
authMecs?: Array<{ id: string; mec: AuthMec }>; authMecs?: Array<{ id: string; mec: AuthMec }>;
} }
> >

View File

@ -33,7 +33,7 @@ export class WebSocketHandler {
case "unauthenticated": { case "unauthenticated": {
const error = createError({ const error = createError({
statusCode: 403, statusCode: 403,
statusMessage: "Unable to connect to websocket - unauthenticated", message: "Unable to connect to websocket - unauthenticated",
}); });
if (this.errorHandler) { if (this.errorHandler) {
return this.errorHandler(error); return this.errorHandler(error);

View File

@ -17,7 +17,7 @@ services:
- POSTGRES_USER=drop - POSTGRES_USER=drop
- POSTGRES_DB=drop - POSTGRES_DB=drop
drop: drop:
image: ghcr.io/drop-oss/drop:latest image: ghcr.io/drop-oss/drop:nightly
stdin_open: true stdin_open: true
tty: true tty: true
init: true init: true

View File

@ -8,6 +8,8 @@ const props = defineProps({
}, },
}); });
await updateUser();
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const user = useUser(); const user = useUser();

View File

@ -13,11 +13,12 @@ export default withNuxt([
// Optional. // Optional.
"@intlify/vue-i18n/no-dynamic-keys": "error", "@intlify/vue-i18n/no-dynamic-keys": "error",
"@intlify/vue-i18n/no-unused-keys": [ "@intlify/vue-i18n/no-unused-keys": [
"error", "off",
{ {
extensions: [".js", ".vue", ".ts"], extensions: [".js", ".vue", ".ts"],
}, },
], ],
"@intlify/vue-i18n/no-missing-keys": "error",
}, },
settings: { settings: {
"vue-i18n": { "vue-i18n": {

View File

@ -1 +1,480 @@
{} {
"account": {
"devices": {
"capabilities": "Möglichkeiten",
"lastConnected": "Zuletzt verbunden",
"noDevices": "Keine Geräte sind mit deinem Konto verbunden.",
"platform": "Plattform",
"revoke": "Wiederrufen",
"title": "Geräte"
},
"notifications": {
"all": "Alle anzeigen {arrow}",
"desc": "Benachrichtigungen anzeigen und verwalten.",
"markAllAsRead": "Alles als gelesen markieren",
"markAsRead": "Als gelesen Markieren",
"none": "Keine Benachrichtigungen",
"notifications": "Benachrichtigungen",
"title": "Benachrichtigungen",
"unread": "Ungelesene Benachrichtigungen"
},
"settings": "Einstellungen",
"title": "Kontoeinstellungen"
},
"actions": "Aktionen",
"add": "Hinzufügen",
"adminTitle": "Administrator Dashbord - Drop",
"adminTitleTemplate": "{0} - Administrator - Drop",
"auth": {
"callback": {
"authClient": "Client autorisieren?",
"authorize": "Autorisieren",
"authorizedClient": "Drop hat den Client erfolgreich autorisiert. Du darfst dieses Fenster nun schließen.",
"issues": "Probleme?",
"learn": "Mehr erfahren {arrow}",
"paste": "Füge diesen Code in den Client ein, um fortzufahren:",
"permWarning": "Das akzeptieren dieser Anfrage erlaubt \"{name}\" auf \"{plattform}\" folgende Berechtigungen:",
"requestedAccess": "\"{name}\" hat Zugriff auf dein Drop Konto angefordert.",
"success": "Erfolgreich!"
},
"code": {
"description": "Verwende ein Code, um dein Drop Client zu verbinden, wenn dein Gerät kein Webbrowser öffnen kann.",
"title": "Verbinde dein Drop Client"
},
"displayName": "Anzeigename",
"email": "E-Mail",
"password": "Passwort",
"register": {
"confirmPasswordFormat": "Muss mit oben genanntem übereinstimmen",
"emailFormat": "Muss im Format nutzer{'@'}beispiel.de sein",
"passwordFormat": "Muss mindestens 14 Zeichen enthalten",
"subheader": "Gebe unten deine Daten ein, um dein Konto zu erstellen.",
"title": "Erstelle dein Drop Konto",
"usernameFormat": "Muss mindestens 5 Zeichen enthalten und aus Kleinbuchstaben bestehen"
},
"signin": {
"externalProvider": "Bei externem Anbieter anmelden {arrow}",
"forgot": "Passwort vergessen?",
"noAccount": "Noch kein Konto? Bitten den Administrator, eines für dich zu erstellt.",
"or": "ODER",
"pageTitle": "Bei Drop anmelden",
"rememberMe": "An mich erinnern",
"signin": "Anmelden",
"title": "Melde dich bei deinem Konto an"
},
"signout": "Ausloggen",
"username": "Nutzername"
},
"cancel": "Abbrechen",
"chars": {
"arrow": "→",
"arrowBack": "←",
"quoted": "\"{text}\"",
"srComma": ", {0}"
},
"common": {
"add": "Hinzufügen",
"cannotUndo": "Diese Aktion kann nicht rückgängig gemacht werden.",
"close": "Schließen",
"create": "Erstellen",
"date": "Datum",
"deleteConfirm": "Möchtest du \"{0}\" wirklich löschen?",
"divider": "{'|'}",
"edit": "Bearbeiten",
"friends": "Freunde",
"groups": "Gruppen",
"insert": "Einfügen",
"name": "Name",
"noResults": "Keine Ergebnisse",
"noSelected": "Keine Elemente ausgewählt.",
"remove": "Entfernen",
"save": "Speichern",
"saved": "Gespeichert",
"servers": "Server",
"srLoading": "Lade…",
"tags": "Tags",
"today": "Heute"
},
"delete": "Löschen",
"drop": {
"desc": "Eine Open-Source-Plattform für die Verteilung von Spielen, die auf Geschwindigkeit, Flexibilität und Ästhetik ausgelegt ist.",
"drop": "Drop"
},
"editor": {
"bold": "Fett",
"boldPlaceholder": "fettgedruckter Text",
"code": "Code",
"codePlaceholder": "code",
"heading": "Überschrift",
"headingPlaceholder": "überschrift",
"italic": "Kursiv",
"italicPlaceholder": "kursiver Text",
"link": "Link",
"linkPlaceholder": "link Text",
"listItem": "Listenelement",
"listItemPlaceholder": "listenelement"
},
"errors": {
"admin": {
"user": {
"delete": {
"desc": "Drop konnte diesen Benutzer nicht löschen: {0}",
"title": "Benutzer konnte nicht gelöscht werden"
}
}
},
"auth": {
"disabled": "Ungültiges oder deaktiviertes Konto. Bitte kontaktiere einen Server Administrator.",
"invalidInvite": "Ungültige oder abgelaufene Einladung",
"invalidPassState": "Ungültiger Passwortzustand. Bitte kontaktiere einen Server Administrator.",
"invalidUserOrPass": "Ungültiger Nutzername oder Passwort.",
"inviteIdRequired": "id erforderlich beim Abrufen der Einladung",
"method": {
"signinDisabled": "Anmeldemethode nicht aktiviert"
},
"usernameTaken": "Nutzername bereits vergeben."
},
"backHome": "{arrow} Zurück zur Startseite",
"game": {
"banner": {
"description": "Das Aktualisieren des Banners ist fehlgeschlagen: {0}",
"title": "Das Aktualisieren des Banners ist fehlgeschlagen"
},
"carousel": {
"description": "Das Aktualisieren des Bildkarussells ist fehlgeschlagen: {0}",
"title": "Das Aktualisieren des Bildkarussells ist fehlgeschlagen"
},
"cover": {
"description": "Das Aktualisieren des Titelbildes ist fehlgeschlagen: {0}",
"title": "Das Aktualisieren des Titelbildes ist fehlgeschlagen"
},
"deleteImage": {
"description": "Das Löschen des Bildes ist fehlgeschlagen: {0}",
"title": "Das Löschen des Bildes ist fehlgeschlagen"
},
"description": {
"description": "Das Aktualisieren der Spielbeschreibung ist fehlgeschlagen: {0}",
"title": "Das Aktualisieren der Spielbeschreibung ist fehlgeschlagen"
},
"metadata": {
"description": "Das Aktualisieren der Spielmetadaten ist fehlgeschlagen: {0}",
"title": "Das Aktualisieren der Spielmetadaten ist fehlgeschlagen"
}
},
"invalidBody": "Ungültiger Anfragenkörper: {0}",
"inviteRequired": "Registrierung nur mit Einladung möglich.",
"library": {
"add": {
"desc": "Drop konnte dieses Spiel nicht zu deiner Bibliothek hinzufügen: {0}",
"title": "Das Spiel konnte nicht zur Bibliothek hinzugefügt werden"
},
"collection": {
"create": {
"desc": "Das Erstellen der Sammlung ist fehlgeschlagen: {0}",
"title": "Das Erstellen der Sammlung ist fehlgeschlagen"
}
},
"source": {
"delete": {
"desc": "Das Löschen der Quelle ist fehlgeschlagen: {0}",
"title": "Das Löschen der Quellbibliothek ist fehlgeschlagen"
}
}
},
"news": {
"article": {
"delete": {
"desc": "Das Löschen des Artikels ist fehlgeschlagen: {0}",
"title": "Das Löschen des Artikels ist fehlgeschlagen"
}
}
},
"occurred": "Bei der Bearbeitung deiner Anfrage ist ein Fehler aufgetreten. Wenn du glaubst, dass es sich um einen Bug handelt, melde diesen bitte. Versuche dich anzumelden, um zu sehen, ob dadurch das Problem behoben wird.",
"ohNo": "Oh nein!",
"pageTitle": "{0} | Drop",
"signIn": "Anmelden {arrow}",
"support": "Support Discord",
"unknown": "Ein unbekannter Fehler ist aufgetreten",
"upload": {
"description": "Drop konnte die Datei nicht hochladen: {0}",
"title": "Das hochladen der Datei ist Fehlgeschlagen"
},
"version": {
"delete": {
"desc": "Beim Löschen der Version ist ein Fehler aufgetreten: {error}",
"title": "Beim Löschen der Version ist ein Fehler aufgetreten"
},
"order": {
"desc": "Beim Aktualisieren der Version ist ein Fehler aufgetreten: {error}",
"title": "Beim Aktualisieren der Versionsreihenfolge ist ein Fehler aufgetreten"
}
}
},
"footer": {
"about": "Über",
"aboutDrop": "Über Drop",
"comparison": "Vergleich",
"docs": {
"client": "Client Dokumentation",
"server": "Server Dokumentation"
},
"documentation": "Dokumentation",
"findGame": "Finde ein Spiel",
"footer": "Fußzeile",
"games": "Spiels",
"social": {
"discord": "Discord",
"github": "GitHub"
},
"topSellers": "Bestseller",
"version": "Drop {version} {gitRef}"
},
"header": {
"admin": {
"admin": "Administrator",
"settings": "Einstellungen",
"tasks": "Aufgaben",
"users": "Benutzer"
},
"back": "Zurück",
"openSidebar": "Öffne Seitenleiste"
},
"helpUsTranslate": "Hilf uns Drop zu übersetzen {arrow}",
"home": "Startseite",
"library": {
"addGames": "Alle Spiele",
"addToLib": "Zur Bibliothek hinzufügen",
"admin": {
"detectedGame": "Drop hat erkannt, dass du ein neues Spiel importieren kannst.",
"detectedVersion": "Drop hat erkannt, dass du eine neue Version dieses Spiels importieren kannst.",
"game": {
"addCarouselNoImages": "Keine Bilder zum hinzufügen.",
"addDescriptionNoImages": "Keine Bilder zum hinzufügen."
},
"import": {
"version": {
"import": "Version Importieren",
"launchDesc": "Ausführbare Datei zum starten des Spiels",
"launchPlaceholder": "spiel.exe",
"loadingVersion": "Lade Versionsmetadaten…",
"noAdv": "Keine erweiterten Optionen für diese Konfiguration.",
"noVersions": "Keine Version zum importieren",
"setupMode": "Einrichtungsmodus",
"setupPlaceholder": "setup.exe",
"umuLauncherId": "UMU Launcher ID",
"updateMode": "Aktualisierungsmodus",
"version": "Wähle die Version für den Import aus"
},
"withoutMetadata": "Ohne Metadaten importieren"
},
"metadata": {
"companies": {
"action": "Verwalte {arrow}",
"addGame": {
"developer": "Entwickler?",
"noGames": "Keine Spiele zum hinzufügen",
"publisher": "Publisher?"
},
"editor": {
"action": "Spiel hinzufügen {plus}",
"developed": "Entwickelt",
"libraryDescription": "Hinzufügen, bearbeiten oder entfernen, was diese Firma entwickelt und/oder veröffentlicht hat.",
"libraryTitle": "Spielebibliothek",
"noDescription": "(Keine Beschreibung)",
"published": "Veröffentlicht",
"uploadBanner": "Banner hochladen",
"uploadIcon": "Icon hochladen"
},
"modals": {
"shortDeckDescription": "Bearbeite die Firmenbeschreibung. Beeinträchtigt nicht die Lange (markdown) Beschreibung.",
"shortDeckTitle": "Bearbeite Firmenbeschreibung"
}
},
"tags": {
"action": "Verwalte {arrow}",
"create": "Erstellen"
}
},
"metadataProvider": "Metadatenanbieter",
"noGames": "Keine Spiele importiert",
"offline": "Drop konnte auf dieses Spiel nicht zugreifen.",
"offlineTitle": "Spiel offline",
"openEditor": "Im Editor öffnen {arrow}",
"openStore": "Im Store öffnen",
"sources": {
"fsPath": "Pfad",
"fsPathDesc": "Absoluter Pfad zur Spielebibliothek.",
"fsPathPlaceholder": "/mnt/spiele"
},
"title": "Bibliotheken",
"version": {
"noVersions": "Du hast keine verfügbare Version dieses Spiels.",
"noVersionsAdded": "keine Versionen hinzugefügt"
},
"versionPriority": "Versions Priorität"
},
"back": "Zurück zur Bibliothek",
"collection": {
"addToNew": "Zur neuen Sammlung hinzufügen",
"collections": "Sammlungen",
"create": "Sammlung erstellen",
"createDesc": "Sammlungen können genutzt werden, um deine Spiele zu organisieren und sie einfacher zu finden. Besonders bei großen Bibliotheken.",
"delete": "Sammlung löschen",
"namePlaceholder": "Sammlungsname",
"noCollections": "Keine Sammlungen",
"notFound": "Sammlung nicht gefunden",
"subheader": "Füge eine neue Sammlung hinzu, um deine Spiele zu organisieren",
"title": "Sammlung"
},
"gameCount": "{0} Spiele | {0} Spiele | {0} Spiele",
"inLib": "In der Bibliothek",
"launcherOpen": "Im Launcher öffnen",
"noGames": "Keine Spiele in der Bibliothek",
"notFound": "Spiel nicht gefunden",
"search": "Durchsuche Bibliothek…"
},
"news": {
"article": {
"add": "Hinzufügen",
"create": "Neuen Artikel erstellen",
"editor": "Editor",
"new": "Neuer Artikel",
"preview": "Vorschau",
"titles": "Titel"
},
"delete": "Artikel löschen",
"filter": {
"month": "Diesen Monat",
"week": "Diese Woche",
"year": "Dieses Jahr"
},
"none": "Keine Artikel",
"notFound": "Artikel nicht gefunden",
"search": "Suche Artikel",
"searchPlaceholder": "Suche Artikel…"
},
"options": "Einstellungen",
"security": "Sicherheit",
"selectLanguage": "Sprache auswählen",
"settings": {
"admin": {
"description": "Konfiguriere Drop Einstellungen",
"title": "Einstellungen"
}
},
"setup": {
"auth": {
"docs": "Dokumentation {arrow}",
"enabled": "Aktiviert?",
"openid": {
"description": "OpenID Connect (OIDC) ist eine oft unterstützte OAuth2 Erweiterung. Drop erfordert die Konfiguration von OIDC über Umgebungsvariablen.",
"title": "OpenID Connect"
},
"simple": {
"description": "Die einfache Authentifizierung verwendet Nutzername und Password zur Authentifizierung von Benutzern. Sie ist standartmäßig aktiviert, wenn kein anderer Authentifizierungsanbieter aktiviert ist.",
"title": "Einfache Authentifizierung"
},
"title": "Authentifizierung"
},
"finish": "Los geht's {arrow}",
"stages": {
"account": {
"description": "Du benötigst mindestens ein Konto, um Drop zu benutzen.",
"name": "Richte dein Administratorkonto ein."
},
"library": {
"name": "Erstelle eine Bibliothek."
}
},
"welcome": "Hallo.",
"welcomeDescription": "Willkommen zum Drop Einrichtungsassistenten. Er führt dich durch die erstmalige Konfiguration von Drop und erklärt dir, wie es funktioniert."
},
"store": {
"about": "Über",
"developers": "Entwickler | Entwickler | Entwickler",
"exploreMore": "Mehr entdecken {arrow}",
"featured": "Empfohlen",
"noDevelopers": "Keine Entwickler",
"noGame": "Kein Spiel",
"noImages": "Keine Bilder",
"platform": "Plattform | Plattform | Plattform",
"rating": "Bewertung",
"recentlyAdded": "Kürzlich hinzugefügt",
"recentlyReleased": "Kürzlich veröffentlicht",
"recentlyUpdated": "Kürzlich aktualisiert",
"released": "Veröffentlicht",
"reviews": "({0} Bewertungen)",
"view": {
"sort": "Sortieren",
"srFilters": "Filter",
"srGames": "Spiele"
},
"website": "Webseite"
},
"tasks": {
"admin": {
"scheduled": {
"cleanupInvitationsName": "Einladungen bereinigen",
"cleanupSessionsName": "Sitzungen bereinigen."
}
}
},
"title": "Drop",
"titleTemplate": "{0} - Drop",
"upload": "Hochladen",
"uploadFile": "Datei hochladen",
"userHeader": {
"closeSidebar": "Seitenleiste schließen",
"links": {
"library": "Bibliothek"
},
"profile": {
"settings": "Kontoeinstellungen"
}
},
"users": {
"admin": {
"adminHeader": "Administrator?",
"authLink": "Authentifizierung {arrow}",
"authentication": {
"configure": "Konfigurieren",
"disabled": "Deaktiviert",
"enabled": "Aktiviert",
"enabledKey": "Aktiviert?",
"oidc": "OpenID Connect",
"srOpenOptions": "Einstellungen öffnen",
"title": "Authentifizierung"
},
"authoptionsHeader": "Authentifizierungseinstellungen",
"delete": "Löschen",
"deleteUser": "Benutzer löschen {0}",
"displayNameHeader": "Anzeigename",
"emailHeader": "E-Mail",
"normalUserLabel": "Normaler Benutzer",
"simple": {
"createInvitation": "Einladung erstellen",
"invitationTitle": "Einladungen",
"invite3Days": "3 Tage",
"invite6Months": "6 Monate",
"inviteAdminSwitchDescription": "Erstelle diesen Benutzer als Administrator",
"inviteButton": "Einladung",
"inviteEmailLabel": "E-Mail-Adresse (optional)",
"inviteMonth": "1 Monat",
"inviteNever": "Niemals",
"inviteTitle": "Ein Benutzer zu Drop einladen",
"inviteUsernameFormat": "Muss mindestens 5 Zeichen lang sein",
"inviteUsernameLabel": "Nutzername (optional)",
"inviteWeek": "1 Woche",
"inviteYear": "1 Jahr",
"neverExpires": "Läuft niemals ab.",
"noEmailEnforced": "Keine E-Mail erforderlich.",
"noInvitations": "Keine Einladungen.",
"noUsernameEnforced": "Kein Nutzername erforderlich.",
"title": "Einfache Authentifizierung",
"userInvitation": "Benutzereinladung"
},
"srEditLabel": "Bearbeiten",
"usernameHeader": "Nutzername"
}
}
}

View File

@ -1 +1,23 @@
{} {
"setup": {
"welcome": "G'day."
},
"account": {
"devices": {
"subheader": "Manage the devices authorised to access your Drop account."
}
},
"auth": {
"callback": {
"authClient": "Authorise client?",
"authorize": "Authorise",
"authorizedClient": "Drop has successfully authorised the client. You may now close this window."
}
},
"library": {
"collection": {
"subheader": "Add a new collection to organise your games"
},
"subheader": "Organise your games into collections for easy access, and access all your games."
}
}

View File

@ -19,13 +19,13 @@
"title": "Messages from the Crows' Nest", "title": "Messages from the Crows' Nest",
"unread": "Unread Messages" "unread": "Unread Messages"
}, },
"settings": "Account Settings, savvy?", "settings": "Settings",
"title": "Yer Own Coffer" "title": "Yer Own Coffer"
}, },
"actions": "Deeds", "actions": "Deeds",
"add": "Add", "add": "Add",
"adminTitle": "Cap'n's Quarters | Drop", "adminTitle": "Cap'n's Quarters - Drop",
"adminTitleTemplate": "{0} | Cap'n | Drop", "adminTitleTemplate": "{0} - Cap'n - Drop",
"auth": { "auth": {
"callback": { "callback": {
"authClient": "Grant passage to this scallywag?", "authClient": "Grant passage to this scallywag?",
@ -38,6 +38,10 @@
"requestedAccess": "\"{name}\" has requested passage to yer Drop coffer.", "requestedAccess": "\"{name}\" has requested passage to yer Drop coffer.",
"success": "Shiver me timbers, it worked!" "success": "Shiver me timbers, it worked!"
}, },
"code": {
"description": "Use the secret map to dock ye ship when lacking a web surfer.",
"title": "Dock ye ship"
},
"confirmPassword": "Confirm @:auth.password", "confirmPassword": "Confirm @:auth.password",
"displayName": "Yer Scallywag Name", "displayName": "Yer Scallywag Name",
"email": "Salty Mail", "email": "Salty Mail",
@ -67,30 +71,37 @@
"chars": { "chars": {
"arrow": "→", "arrow": "→",
"arrowBack": "←", "arrowBack": "←",
"quoted": "\"\"", "quoted": "\"{text}\"",
"srComma": ", {0}" "srComma": ", {0}"
}, },
"close": "Shut yer trap!",
"common": { "common": {
"add": "Append",
"cannotUndo": "This deed cannot be undone, ye hear!", "cannotUndo": "This deed cannot be undone, ye hear!",
"close": "Shut yer trap!",
"create": "Forge!",
"date": "Date", "date": "Date",
"deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?", "deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?",
"divider": "{'|'}", "divider": "{'|'}",
"edit": "Amend",
"friends": "Shipmates", "friends": "Shipmates",
"groups": "Crews", "groups": "Crews",
"insert": "Insert",
"name": "Name, argh!",
"noResults": "No plunder found!", "noResults": "No plunder found!",
"noSelected": "No cargo selected.",
"remove": "Walk the plank",
"save": "Stow it!",
"saved": "Preserved",
"servers": "Ships", "servers": "Ships",
"srLoading": "Loading, loading, argh...", "srLoading": "Loading, loading, argh",
"tags": "Marks", "tags": "Marks",
"today": "Today" "today": "Today"
}, },
"create": "Forge!",
"delete": "Scuttle!", "delete": "Scuttle!",
"drop": { "drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!", "desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!",
"drop": "Drop" "drop": "Drop"
}, },
"edit": "Amend",
"editor": { "editor": {
"bold": "Bold, like a cannonball!", "bold": "Bold, like a cannonball!",
"boldPlaceholder": "bold text, matey", "boldPlaceholder": "bold text, matey",
@ -106,6 +117,25 @@
"listItemPlaceholder": "list item, eh?" "listItemPlaceholder": "list item, eh?"
}, },
"errors": { "errors": {
"admin": {
"user": {
"delete": {
"desc": "Couldn't make {0} walk the plank!",
"title": "Couldn't make 'em walk the plank"
}
}
},
"auth": {
"disabled": "Ya' angered the Cap'n, give him a holler!",
"invalidInvite": "Boarding pass no longer valid, ya scallywag!",
"invalidPassState": "Cap'n's rolled snake eyes, see how he's doin'.",
"invalidUserOrPass": "Are ya lying to me about your username and password, ya dog?",
"inviteIdRequired": "Ya need to include the ID for your boarding pass, matey!",
"method": {
"signinDisabled": "No entrance through these parts, arr!"
},
"usernameTaken": "We already have a scallywag with that 'ere name of yours."
},
"backHome": "{arrow} Back to yer safe harbor", "backHome": "{arrow} Back to yer safe harbor",
"game": { "game": {
"banner": { "banner": {
@ -200,11 +230,14 @@
"discord": "Discord, argh!", "discord": "Discord, argh!",
"github": "GitHub, savvy?" "github": "GitHub, savvy?"
}, },
"topSellers": "Top Plunderers" "topSellers": "Top Plunderers",
"version": "Drop {version} {gitRef}"
}, },
"header": { "header": {
"admin": { "admin": {
"admin": "Cap'n", "admin": "Cap'n",
"metadata": "Meta argh",
"settings": "Shape",
"tasks": "Duties", "tasks": "Duties",
"users": "Crew" "users": "Crew"
}, },
@ -214,7 +247,6 @@
"helpUsTranslate": "Help us translate Drop {arrow}, argh!", "helpUsTranslate": "Help us translate Drop {arrow}, argh!",
"highest": "highest", "highest": "highest",
"home": "Home Port", "home": "Home Port",
"insert": "Insert",
"library": { "library": {
"addGames": "All Plunder", "addGames": "All Plunder",
"addToLib": "Add to Yer Treasure Hoard", "addToLib": "Add to Yer Treasure Hoard",
@ -241,16 +273,18 @@
}, },
"gameLibrary": "Game Treasure Hoard", "gameLibrary": "Game Treasure Hoard",
"import": { "import": {
"bulkImportDescription": "When importing ye versions, ye won't be sent to the import duty.",
"bulkImportTitle": "Plunder the imports",
"import": "Import, ye dog!", "import": "Import, ye dog!",
"link": "Import {arrow}", "link": "Import {arrow}",
"loading": "Loadin' plunder results, arrr...", "loading": "Loadin' plunder results, arrr",
"search": "Search", "search": "Search",
"searchPlaceholder": "Fallout 4, savvy?", "searchPlaceholder": "Fallout 4, savvy?",
"selectDir": "Pick a directory, ye landlubber...", "selectDir": "Pick a directory, ye landlubber",
"selectGame": "Pick plunder to import", "selectGame": "Pick plunder to import",
"selectGamePlaceholder": "Pick a game, ye dog...", "selectGamePlaceholder": "Pick a game, ye dog",
"selectGameSearch": "Pick game", "selectGameSearch": "Pick game",
"selectPlatform": "Pick a ship, ye scallywag...", "selectPlatform": "Pick a ship, ye scallywag",
"version": { "version": {
"advancedOptions": "Advanced options, savvy?", "advancedOptions": "Advanced options, savvy?",
"import": "Import version", "import": "Import version",
@ -258,7 +292,7 @@
"launchCmd": "Launch executable/command, argh!", "launchCmd": "Launch executable/command, argh!",
"launchDesc": "Executable to launch the game, matey!", "launchDesc": "Executable to launch the game, matey!",
"launchPlaceholder": "game.exe, aye!", "launchPlaceholder": "game.exe, aye!",
"loadingVersion": "Loading version charts...", "loadingVersion": "Loading version charts",
"noAdv": "No advanced options for this rig, argh.", "noAdv": "No advanced options for this rig, argh.",
"noVersions": "No versions to import, savvy!", "noVersions": "No versions to import, savvy!",
"platform": "Ship type", "platform": "Ship type",
@ -276,6 +310,16 @@
}, },
"withoutMetadata": "Import without charts" "withoutMetadata": "Import without charts"
}, },
"metadata": {
"companies": {
"action": "Shape {arrow}",
"addGame": {
"developer": "Creator?",
"noGames": "No games to plunder",
"publisher": "Distributor?"
}
}
},
"metadataProvider": "Charts Provider", "metadataProvider": "Charts Provider",
"noGames": "No plunder imported, savvy!", "noGames": "No plunder imported, savvy!",
"openEditor": "Open in Editor {arrow}", "openEditor": "Open in Editor {arrow}",
@ -323,11 +367,10 @@
"launcherOpen": "Open in Launcher, argh!", "launcherOpen": "Open in Launcher, argh!",
"noGames": "No plunder in treasure hoard, savvy!", "noGames": "No plunder in treasure hoard, savvy!",
"notFound": "Plunder not found, matey!", "notFound": "Plunder not found, matey!",
"search": "Search treasure hoard, ye dog...", "search": "Search treasure hoard, ye dog",
"subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!" "subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!"
}, },
"lowest": "lowest", "lowest": "lowest",
"name": "Name, argh!",
"news": { "news": {
"article": { "article": {
"add": "Add, ye dog!", "add": "Add, ye dog!",
@ -339,7 +382,7 @@
"preview": "Preview, matey!", "preview": "Preview, matey!",
"shortDesc": "Short description", "shortDesc": "Short description",
"submit": "Submit, ye scurvy dog!", "submit": "Submit, ye scurvy dog!",
"tagPlaceholder": "Add a mark, ye dog...", "tagPlaceholder": "Add a mark, ye dog",
"titles": "Title, argh!", "titles": "Title, argh!",
"uploadCover": "Hoist cover image" "uploadCover": "Hoist cover image"
}, },
@ -355,12 +398,11 @@
"none": "No articles, savvy!", "none": "No articles, savvy!",
"notFound": "Article not found, matey!", "notFound": "Article not found, matey!",
"search": "Search articles, ye dog!", "search": "Search articles, ye dog!",
"searchPlaceholder": "Search articles, argh...", "searchPlaceholder": "Search articles, argh",
"subheader": "Stay up to date with the latest charts and announcements, savvy!", "subheader": "Stay up to date with the latest charts and announcements, savvy!",
"title": "Latest News from the High Seas" "title": "Latest News from the High Seas"
}, },
"options": "Options, matey!", "options": "Options, matey!",
"save": "Stow it!",
"security": "Safety", "security": "Safety",
"selectLanguage": "Pick yer tongue", "selectLanguage": "Pick yer tongue",
"settings": "Settings", "settings": "Settings",
@ -401,7 +443,8 @@
"cleanupSessionsDescription": "Cleans up expired sessions to save space and keep ye safe, ye dog!", "cleanupSessionsDescription": "Cleans up expired sessions to save space and keep ye safe, ye dog!",
"cleanupSessionsName": "Clean up sessions." "cleanupSessionsName": "Clean up sessions."
}, },
"viewTask": "View {arrow}" "viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly chores"
} }
}, },
"title": "Drop", "title": "Drop",
@ -439,6 +482,8 @@
"title": "Passage" "title": "Passage"
}, },
"authoptionsHeader": "Passage Options", "authoptionsHeader": "Passage Options",
"delete": "Scuttle!",
"deleteUser": "Make {0} walk the plank!",
"description": "Manage the crew on yer Drop vessel, and set yer passage methods, savvy?", "description": "Manage the crew on yer Drop vessel, and set yer passage methods, savvy?",
"displayNameHeader": "Scallywag Name", "displayNameHeader": "Scallywag Name",
"emailHeader": "Salty Mail", "emailHeader": "Salty Mail",

View File

@ -19,14 +19,35 @@
"title": "Notifications", "title": "Notifications",
"unread": "Unread Notifications" "unread": "Unread Notifications"
}, },
"settings": "Account Settings", "token": {
"title": "Account" "title": "API Tokens",
"subheader": "Manage your API tokens, and what they can access.",
"name": "API token name",
"nameDesc": "The name of the token, for reference.",
"namePlaceholder": "My New Token",
"acls": "ACLs/scopes",
"aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.",
"expiry": "Expiry",
"noExpiry": "No expiry",
"revoke": "Revoke",
"noTokens": "No tokens connected to your account.",
"expiryMonth": "A month",
"expiry3Month": "3 months",
"expiry6Month": "6 months",
"expiryYear": "A year",
"expiry5Year": "5 years",
"success": "Successfully created token.",
"successNote": "Make sure to copy it now, as it won't be shown again."
},
"settings": "Settings",
"title": "Account Settings"
}, },
"actions": "Actions", "actions": "Actions",
"adminTitle": "Admin Dashboard | Drop", "add": "Add",
"adminTitleTemplate": "{0} | Admin | Drop", "adminTitle": "Admin Dashboard - Drop",
"title": "Drop", "adminTitleTemplate": "{0} - Admin - Drop",
"titleTemplate": "{0} | Drop",
"auth": { "auth": {
"callback": { "callback": {
"authClient": "Authorize client?", "authClient": "Authorize client?",
@ -39,6 +60,10 @@
"requestedAccess": "\"{name}\" has requested access to your Drop account.", "requestedAccess": "\"{name}\" has requested access to your Drop account.",
"success": "Successful!" "success": "Successful!"
}, },
"code": {
"description": "Use a code to connect your Drop client if you are unable to open a web browser on your device.",
"title": "Connect your Drop client"
},
"confirmPassword": "Confirm @:auth.password", "confirmPassword": "Confirm @:auth.password",
"displayName": "Display Name", "displayName": "Display Name",
"email": "Email", "email": "Email",
@ -68,30 +93,37 @@
"chars": { "chars": {
"arrow": "→", "arrow": "→",
"arrowBack": "←", "arrowBack": "←",
"quoted": "\"\"", "quoted": "\"{text}\"",
"srComma": ", {0}" "srComma": ", {0}"
}, },
"close": "Close",
"common": { "common": {
"add": "Add",
"cannotUndo": "This action cannot be undone.", "cannotUndo": "This action cannot be undone.",
"close": "Close",
"create": "Create",
"date": "Date", "date": "Date",
"deleteConfirm": "Are you sure you want to delete \"{0}\"?", "deleteConfirm": "Are you sure you want to delete \"{0}\"?",
"divider": "{'|'}",
"edit": "Edit",
"friends": "Friends", "friends": "Friends",
"groups": "Groups", "groups": "Groups",
"insert": "Insert",
"name": "Name",
"noResults": "No results", "noResults": "No results",
"noSelected": "No items selected.",
"remove": "Remove",
"save": "Save",
"saved": "Saved",
"servers": "Servers", "servers": "Servers",
"srLoading": "Loading…",
"tags": "Tags", "tags": "Tags",
"today": "Today", "today": "Today"
"divider": "{'|'}",
"srLoading": "Loading..."
}, },
"create": "Create",
"delete": "Delete", "delete": "Delete",
"drop": { "drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty.", "desc": "An open-source game distribution platform built for speed, flexibility and beauty.",
"drop": "Drop" "drop": "Drop"
}, },
"edit": "Edit",
"editor": { "editor": {
"bold": "Bold", "bold": "Bold",
"boldPlaceholder": "bold text", "boldPlaceholder": "bold text",
@ -107,17 +139,6 @@
"listItemPlaceholder": "list item" "listItemPlaceholder": "list item"
}, },
"errors": { "errors": {
"auth": {
"method": {
"signinDisabled": "Sign in method not enabled"
},
"invalidUserOrPass": "Invalid username or password.",
"disabled": "Invalid or disabled account. Please contact the server administrator.",
"invalidPassState": "Invalid password state. Please contact the server administrator.",
"inviteIdRequired": "id required in fetching invitation",
"invalidInvite": "Invalid or expired invitation",
"usernameTaken": "Username already taken."
},
"admin": { "admin": {
"user": { "user": {
"delete": { "delete": {
@ -126,7 +147,48 @@
} }
} }
}, },
"auth": {
"disabled": "Invalid or disabled account. Please contact the server administrator.",
"invalidInvite": "Invalid or expired invitation",
"invalidPassState": "Invalid password state. Please contact the server administrator.",
"invalidUserOrPass": "Invalid username or password.",
"inviteIdRequired": "id required in fetching invitation",
"method": {
"signinDisabled": "Sign in method not enabled"
},
"usernameTaken": "Username already taken."
},
"backHome": "{arrow} Back to home", "backHome": "{arrow} Back to home",
"externalUrl": {
"subtitle": "This message is only visible to admins.",
"title": "Accessing over different EXTERNAL_URL. Please check the docs."
},
"game": {
"banner": {
"description": "Drop failed to update the banner image: {0}",
"title": "Failed to update the banner image"
},
"carousel": {
"description": "Drop failed to update the image carousel: {0}",
"title": "Failed to update image carousel"
},
"cover": {
"description": "Drop failed to update the cover image: {0}",
"title": "Failed to update the cover image"
},
"deleteImage": {
"description": "Drop failed to delete the image: {0}",
"title": "Failed to delete the image"
},
"description": {
"description": "Drop failed to update the game description: {0}",
"title": "Failed to update game description"
},
"metadata": {
"description": "Drop failed to update the game's metadata: {0}",
"title": "Failed to update metadata"
}
},
"invalidBody": "Invalid request body: {0}", "invalidBody": "Invalid request body: {0}",
"inviteRequired": "Invitation required to sign up.", "inviteRequired": "Invitation required to sign up.",
"library": { "library": {
@ -142,7 +204,7 @@
}, },
"source": { "source": {
"delete": { "delete": {
"desc": "Drop couldn't add delete this source: {0}", "desc": "Drop couldn't delete this source: {0}",
"title": "Failed to delete library source" "title": "Failed to delete library source"
} }
} }
@ -163,6 +225,10 @@
"signIn": "Sign in {arrow}", "signIn": "Sign in {arrow}",
"support": "Support Discord", "support": "Support Discord",
"unknown": "An unknown error occurred", "unknown": "An unknown error occurred",
"upload": {
"description": "Drop couldn't upload the file: {0}",
"title": "Failed to upload file"
},
"version": { "version": {
"delete": { "delete": {
"desc": "Drop encountered an error while deleting the version: {error}", "desc": "Drop encountered an error while deleting the version: {error}",
@ -172,47 +238,17 @@
"desc": "Drop encountered an error while updating the version: {error}", "desc": "Drop encountered an error while updating the version: {error}",
"title": "There an error while updating the version order" "title": "There an error while updating the version order"
} }
},
"upload": {
"title": "Failed to upload file",
"description": "Drop couldn't upload the file: {0}"
},
"game": {
"metadata": {
"title": "Failed to update metadata",
"description": "Drop failed to update the game's metadata: {0}"
},
"description": {
"title": "Failed to update game description",
"description": "Drop failed to update the game description: {0}"
},
"banner": {
"title": "Failed to update the banner image",
"description": "Drop failed to update the banner image: {0}"
},
"cover": {
"title": "Failed to update the cover image",
"description": "Drop failed to update the cover image: {0}"
},
"deleteImage": {
"title": "Failed to delete the image",
"description": "Drop failed to delete the image: {0}"
},
"carousel": {
"title": "Failed to update image carousel",
"description": "Drop failed to update the image carousel: {0}"
}
} }
}, },
"footer": { "footer": {
"about": "About", "about": "About",
"aboutDrop": "About Drop", "aboutDrop": "About Drop",
"comparison": "Comparison",
"docs": { "docs": {
"client": "Client Docs", "client": "Client Docs",
"server": "Server Docs" "server": "Server Docs"
}, },
"documentation": "Documentation", "documentation": "Documentation",
"comparison": "Comparison",
"findGame": "Find a Game", "findGame": "Find a Game",
"footer": "Footer", "footer": "Footer",
"games": "Games", "games": "Games",
@ -226,100 +262,67 @@
"header": { "header": {
"admin": { "admin": {
"admin": "Admin", "admin": "Admin",
"metadata": "Meta",
"settings": {
"title": "Settings",
"store": "Store",
"tokens": "API tokens"
},
"tasks": "Tasks", "tasks": "Tasks",
"users": "Users" "users": "Users"
}, },
"back": "Back", "back": "Back",
"openSidebar": "Open sidebar" "openSidebar": "Open sidebar"
}, },
"helpUsTranslate": "Help us translate Drop {arrow}",
"highest": "highest", "highest": "highest",
"home": "Home", "home": "Home",
"users": {
"admin": {
"description": "Manage the users on your Drop instance, and configure your authentication methods.",
"authLink": "Authentication {arrow}",
"displayNameHeader": "Display Name",
"usernameHeader": "Username",
"emailHeader": "Email",
"adminHeader": "Admin?",
"authoptionsHeader": "Auth Options",
"srEditLabel": "Edit",
"adminUserLabel": "Admin user",
"normalUserLabel": "Normal user",
"delete": "Delete",
"deleteUser": "Delete user {0}",
"authentication": {
"title": "Authentication",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
"enabledKey": "Enabled?",
"enabled": "Enabled",
"disabled": "Disabled",
"srOpenOptions": "Open options",
"configure": "Configure",
"simple": "Simple (username/password)",
"oidc": "OpenID Connect"
},
"simple": {
"title": "Simple authentication",
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
"invitationTitle": "invitations",
"createInvitation": "Create invitation",
"noUsernameEnforced": "No username enforced.",
"noEmailEnforced": "No email enforced.",
"adminInvitation": "Admin invitation",
"userInvitation": "User invitation",
"expires": "Expires: {expiry}",
"neverExpires": "Never expires.",
"noInvitations": "No invitations.",
"inviteTitle": "Invite user to Drop",
"inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.",
"inviteUsernameLabel": "Username (optional)",
"inviteUsernameFormat": "Must be 5 or more characters",
"inviteUsernamePlaceholder": "myUsername",
"inviteEmailLabel": "Email address (optional)",
"inviteEmailDescription": "Must be in the format user{'@'}example.com",
"inviteEmailPlaceholder": "me{'@'}example.com",
"inviteAdminSwitchLabel": "Admin invitation",
"inviteAdminSwitchDescription": "Create this user as an administrator",
"inviteExpiryLabel": "Expires",
"inviteButton": "Invite",
"invite3Days": "3 days",
"inviteWeek": "1 week",
"inviteMonth": "1 month",
"invite6Months": "6 months",
"inviteYear": "1 year",
"inviteNever": "Never"
}
}
},
"library": { "library": {
"addGames": "All Games", "addGames": "All Games",
"addToLib": "Add to Library", "addToLib": "Add to Library",
"admin": { "admin": {
"detectedGame": "Drop has detected you have new games to import.", "detectedGame": "Drop has detected you have new items to import.",
"detectedVersion": "Drop has detected you have new verions of this game to import.", "detectedVersion": "Drop has detected you have new versions to import.",
"game": {
"addCarouselNoImages": "No images to add.",
"addDescriptionNoImages": "No images to add.",
"addImageCarousel": "Add from image library",
"currentBanner": "banner",
"currentCover": "cover",
"deleteImage": "Delete image",
"editGameDescription": "Game Description",
"editGameName": "Game Name",
"imageCarousel": "Image Carousel",
"imageCarouselDescription": "Customise what images and what order are shown on the store page.",
"imageCarouselEmpty": "No images added to the carousel yet.",
"imageLibrary": "Image library",
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
"removeImageCarousel": "Remove image",
"setBanner": "Set as banner",
"setCover": "Set as cover"
},
"gameLibrary": "Game Library", "gameLibrary": "Game Library",
"import": { "import": {
"bulkImportDescription": "When on this page, you won't be redirect to the import task, so you can import multiple games in succession.",
"bulkImportTitle": "Bulk import mode",
"import": "Import", "import": "Import",
"link": "Import {arrow}", "link": "Import {arrow}",
"loading": "Loading game results...", "loading": "Loading game results",
"search": "Search", "search": "Search",
"searchPlaceholder": "Fallout 4", "searchPlaceholder": "Fallout 4",
"selectDir": "Please select a directory...", "selectDir": "Please select a directory",
"selectGame": "Select game to import", "selectGame": "Select game to import",
"selectGamePlaceholder": "Please select a game...", "selectGamePlaceholder": "Please select a game",
"selectGameSearch": "Select game", "selectGameSearch": "Select game",
"selectPlatform": "Please select a platform...", "selectPlatform": "Please select a platform",
"version": { "version": {
"advancedOptions": "Advanced options", "advancedOptions": "Advanced options",
"import": "Import version", "import": "Import version",
"installDir": "(install_dir)/", "installDir": "(install_dir)/",
"launchCmd": "Launch executable/command", "launchCmd": "Launch executables/commands",
"launchDesc": "Executable to launch the game", "launchDesc": "Executables to launch the game",
"launchPlaceholder": "game.exe", "launchPlaceholder": "game.exe",
"loadingVersion": "Loading version metadata...", "loadingVersion": "Loading version metadata",
"noAdv": "No advanced options for this configuration.", "noAdv": "No advanced options for this configuration.",
"noVersions": "No versions to import", "noVersions": "No versions to import",
"platform": "Version platform", "platform": "Version platform",
@ -337,42 +340,84 @@
}, },
"withoutMetadata": "Import without metadata" "withoutMetadata": "Import without metadata"
}, },
"metadata": {
"companies": {
"action": "Manage {arrow}",
"addGame": {
"description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.",
"developer": "Developer?",
"noGames": "No games to add",
"publisher": "Publisher?",
"title": "Connect game to this company"
},
"description": "Companies organize games by who they were developed or published by.",
"editor": {
"action": "Add Game {plus}",
"descriptionPlaceholder": "{'<'}description{'>'}",
"developed": "Developed",
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
"libraryTitle": "Game Library",
"noDescription": "(no description)",
"published": "Published",
"uploadBanner": "Upload banner",
"uploadIcon": "Upload icon",
"websitePlaceholder": "{'<'}website{'>'}"
},
"modals": {
"createDescription": "Create a company to further organize your games.",
"createFieldDescription": "Company Description",
"createFieldDescriptionPlaceholder": "A small indie studio that...",
"createFieldName": "Company Name",
"createFieldNamePlaceholder": "My New Company...",
"createFieldWebsite": "Company Website",
"createFieldWebsitePlaceholder": "https://example.com/",
"createTitle": "Create a company",
"nameDescription": "Edit the company's name. Used to match to new game imports.",
"nameTitle": "Edit company name",
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
"shortDeckTitle": "Edit company description",
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection.",
"websiteTitle": "Edit company website"
},
"noCompanies": "No companies",
"noGames": "No games",
"search": "Search companies…",
"searchGames": "Search company games…",
"title": "Companies"
},
"tags": {
"action": "Manage {arrow}",
"create": "Create",
"description": "Tags are automatically created from imported genres. You can add custom tags to add categorisation to your game library.",
"modal": {
"description": "Create a tag to organize your library.",
"title": "Create Tag"
},
"title": "Tags"
}
},
"metadataProvider": "Metadata provider", "metadataProvider": "Metadata provider",
"noGames": "No games imported", "noGames": "No games imported",
"libraryHint": "No libraries configured.",
"libraryHintDocsLink": "What does this mean? {arrow}",
"offline": "Drop couldn't access this game.",
"offlineTitle": "Game offline",
"openEditor": "Open in Editor {arrow}", "openEditor": "Open in Editor {arrow}",
"openStore": "Open in Store", "openStore": "Open in Store",
"shortDesc": "Short Description", "shortDesc": "Short Description",
"version": {
"noVersions": "You have no versions of this game available.",
"noVersionsAdded": "no versions added",
"delta": "Upgrade mode"
},
"game": {
"imageCarousel": "Image Carousel",
"imageCarouselDescription": "Customise what images and what order are shown on the store page.",
"addImageCarousel": "Add from image library",
"imageCarouselEmpty": "No images added to the carousel yet.",
"removeImageCarousel": "Remove image",
"addCarouselNoImages": "No images to add.",
"imageLibrary": "Image library",
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
"setBanner": "Set as banner",
"setCover": "Set as cover",
"deleteImage": "Delete image",
"currentBanner": "banner",
"currentCover": "cover",
"addDescriptionNoImages": "No images to add.",
"editGameName": "Game Name",
"editGameDescription": "Game Description"
},
"sources": { "sources": {
"create": "Create source", "create": "Create source",
"createDesc": "Drop will use this source to access your game library, and make them available.", "createDesc": "Drop will use this source to access your game library, and make them available.",
"desc": "Configure your library sources, where Drop will look for new games and versions to import.", "desc": "Configure your library sources, where Drop will look for new games and versions to import.",
"documentationLink": "Documentation {arrow}",
"edit": "Edit source",
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.", "fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
"fsFlatTitle": "Compatibility",
"fsPath": "Path", "fsPath": "Path",
"fsPathDesc": "An absolute path to your game library.", "fsPathDesc": "An absolute path to your game library.",
"fsPathPlaceholder": "/mnt/games", "fsPathPlaceholder": "/mnt/games",
"fsTitle": "Drop-style",
"link": "Sources {arrow}", "link": "Sources {arrow}",
"nameDesc": "The name of your source, for reference.", "nameDesc": "The name of your source, for reference.",
"namePlaceholder": "My New Source", "namePlaceholder": "My New Source",
@ -382,6 +427,11 @@
}, },
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.", "subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
"title": "Libraries", "title": "Libraries",
"version": {
"delta": "Upgrade mode",
"noVersions": "No versions available.",
"noVersionsAdded": "no versions added"
},
"versionPriority": "Version priority" "versionPriority": "Version priority"
}, },
"back": "Back to Library", "back": "Back to Library",
@ -402,32 +452,10 @@
"launcherOpen": "Open in Launcher", "launcherOpen": "Open in Launcher",
"noGames": "No games in library", "noGames": "No games in library",
"notFound": "Game not found", "notFound": "Game not found",
"search": "Search library...", "search": "Search library",
"subheader": "Organize your games into collections for easy access, and access all your games." "subheader": "Organize your games into collections for easy access, and access all your games."
}, },
"tasks": {
"admin": {
"scheduled": {
"cleanupInvitationsName": "Clean up invitations",
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
"cleanupObjectsName": "Clean up objects",
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
"cleanupSessionsName": "Clean up sessions.",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
"checkUpdateName": "Check update.",
"checkUpdateDescription": "Check if Drop has an update."
},
"runningTasksTitle": "Running tasks",
"noTasksRunning": "No tasks currently running",
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"weeklyScheduledTitle": "Weekly scheduled tasks",
"viewTask": "View {arrow}",
"back": "{arrow} Back to Tasks"
}
},
"lowest": "lowest", "lowest": "lowest",
"name": "Name",
"news": { "news": {
"article": { "article": {
"add": "Add", "add": "Add",
@ -439,7 +467,7 @@
"preview": "Preview", "preview": "Preview",
"shortDesc": "Short description", "shortDesc": "Short description",
"submit": "Submit", "submit": "Submit",
"tagPlaceholder": "Add a tag...", "tagPlaceholder": "Add a tag",
"titles": "Title", "titles": "Title",
"uploadCover": "Upload cover image" "uploadCover": "Upload cover image"
}, },
@ -455,25 +483,76 @@
"none": "No articles", "none": "No articles",
"notFound": "Article not found", "notFound": "Article not found",
"search": "Search articles", "search": "Search articles",
"searchPlaceholder": "Search articles...", "searchPlaceholder": "Search articles",
"subheader": "Stay up to date with the latest updates and announcements.", "subheader": "Stay up to date with the latest updates and announcements.",
"title": "Latest News" "title": "Latest News"
}, },
"options": "Options", "options": "Options",
"save": "Save",
"add": "Add",
"insert": "Insert",
"security": "Security", "security": "Security",
"settings": "Settings", "selectLanguage": "Select language",
"settings": {
"admin": {
"description": "Configure Drop settings",
"store": {
"dropGameAltPlaceholder": "Example Game icon",
"dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.",
"dropGameNamePlaceholder": "Example Game",
"showGamePanelTextDecoration": "Show title and description on game tiles (default: on)",
"title": "Store"
},
"title": "Settings"
}
},
"setup": {
"auth": {
"description": "Authentication in Drop happens through multiple configured 'providers'. Each one can allow users to sign-in through their method. To get started, have at least one authentication provider enabled, and create an account through it.",
"docs": "Documentation {arrow}",
"enabled": "Enabled?",
"openid": {
"description": "OpenID Connect (OIDC) is an OAuth2 extension commonly supported. Drop requires OIDC configuration to be done via environment variables.",
"skip": "I have a user with OIDC",
"title": "OpenID Connect"
},
"simple": {
"description": "Simple authentication uses username/password to authentication users. It is enabled by default if no other authentication provider is enabled.",
"register": "Register as admin {arrow}",
"title": "Simple authentication"
},
"title": "Authentication"
},
"finish": "Let's go {arrow}",
"noPage": "no page",
"stages": {
"account": {
"description": "You need at least one account to start using Drop.",
"name": "Setup your admin account."
},
"library": {
"description": "Add at least one library source to use Drop.",
"name": "Create a library."
}
},
"welcome": "Hey there.",
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works."
},
"store": { "store": {
"about": "About",
"commingSoon": "coming soon", "commingSoon": "coming soon",
"developers": "Developers | Developer | Developers",
"exploreMore": "Explore more {arrow}", "exploreMore": "Explore more {arrow}",
"featured": "Featured",
"images": "Game Images", "images": "Game Images",
"lookAt": "Check it out", "lookAt": "Check it out",
"noGame": "no game", "noDevelopers": "No developers",
"noFeatured": "NO FEATURED GAMES",
"noGame": "NO GAME",
"noImages": "No images", "noImages": "No images",
"noPublishers": "No publishers.",
"noTags": "No tags",
"openAdminDashboard": "Open in Admin Dashboard", "openAdminDashboard": "Open in Admin Dashboard",
"openFeatured": "Star games in Admin Library {arrow}",
"platform": "Platform | Platform | Platforms", "platform": "Platform | Platform | Platforms",
"publishers": "Publishers | Publisher | Publishers",
"rating": "Rating", "rating": "Rating",
"readLess": "Click to read less", "readLess": "Click to read less",
"readMore": "Click to read more", "readMore": "Click to read more",
@ -482,9 +561,43 @@
"recentlyUpdated": "Recently Updated", "recentlyUpdated": "Recently Updated",
"released": "Released", "released": "Released",
"reviews": "({0} Reviews)", "reviews": "({0} Reviews)",
"tags": "Tags",
"title": "Store", "title": "Store",
"view": "View in Store" "view": {
"sort": "Sort",
"srFilters": "Filters",
"srGames": "Games",
"srViewGrid": "View grid"
},
"viewInStore": "View in Store",
"website": "Website"
}, },
"tasks": {
"admin": {
"back": "{arrow} Back to Tasks",
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"noTasksRunning": "No tasks currently running",
"runningTasksTitle": "Running tasks",
"scheduled": {
"checkUpdateDescription": "Check if Drop has an update.",
"checkUpdateName": "Check update.",
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
"cleanupInvitationsName": "Clean up invitations",
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
"cleanupObjectsName": "Clean up objects",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
"cleanupSessionsName": "Clean up sessions."
},
"viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly scheduled tasks",
"progress": "{0}%",
"execute": "{arrow} Execute"
}
},
"title": "Drop",
"titleTemplate": "{0} - Drop",
"todo": "Todo",
"type": "Type", "type": "Type",
"upload": "Upload", "upload": "Upload",
"uploadFile": "Upload file", "uploadFile": "Upload file",
@ -500,8 +613,63 @@
"settings": "Account settings" "settings": "Account settings"
} }
}, },
"todo": "Todo", "users": {
"selectLanguage": "Select language", "admin": {
"helpUsTranslate": "Help us translate Drop {arrow}", "adminHeader": "Admin?",
"adminUserLabel": "Admin user",
"authentication": {
"configure": "Configure",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
"disabled": "Disabled",
"enabled": "Enabled",
"enabledKey": "Enabled?",
"oidc": "OpenID Connect",
"simple": "Simple (username/password)",
"srOpenOptions": "Open options",
"title": "Authentication"
},
"authLink": "Authentication {arrow}",
"authoptionsHeader": "Auth Options",
"delete": "Delete",
"deleteUser": "Delete user {0}",
"description": "Manage the users on your Drop instance, and configure your authentication methods.",
"displayNameHeader": "Display Name",
"emailHeader": "Email",
"normalUserLabel": "Normal user",
"simple": {
"adminInvitation": "Admin invitation",
"createInvitation": "Create invitation",
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
"expires": "Expires: {expiry}",
"invitationTitle": "Invitations",
"invite3Days": "3 days",
"invite6Months": "6 months",
"inviteAdminSwitchDescription": "Create this user as an administrator",
"inviteAdminSwitchLabel": "Admin invitation",
"inviteButton": "Invite",
"inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.",
"inviteEmailDescription": "Must be in the format user{'@'}example.com",
"inviteEmailLabel": "Email address (optional)",
"inviteEmailPlaceholder": "me{'@'}example.com",
"inviteExpiryLabel": "Expires",
"inviteMonth": "1 month",
"inviteNever": "Never",
"inviteTitle": "Invite user to Drop",
"inviteUsernameFormat": "Must be 5 or more characters",
"inviteUsernameLabel": "Username (optional)",
"inviteUsernamePlaceholder": "myUsername",
"inviteWeek": "1 week",
"inviteYear": "1 year",
"neverExpires": "Never expires.",
"noEmailEnforced": "No email enforced.",
"noInvitations": "No invitations.",
"noUsernameEnforced": "No username enforced.",
"title": "Simple authentication",
"userInvitation": "User invitation"
},
"srEditLabel": "Edit",
"usernameHeader": "Username"
}
},
"welcome": "American, Welcome!" "welcome": "American, Welcome!"
} }

View File

@ -1 +1,619 @@
{} {
"account": {
"devices": {
"capabilities": "Capacités",
"lastConnected": "Dernière Connexion",
"noDevices": "Aucun appareil n'est connecté à vôtre compte.",
"platform": "Plateforme",
"revoke": "Révoquer",
"subheader": "Gérer les appareils authorisés à accéder à votre compte Drop.",
"title": "Appareils"
},
"notifications": {
"all": "Tout voir {arrow}",
"desc": "Voir et gérer vos notifications.",
"markAllAsRead": "Tout marqué comme lu",
"markAsRead": "Marquer comme lu",
"none": "Pas de notification",
"notifications": "Notifications",
"title": "Notifications",
"unread": "Notifications Non Lues"
},
"settings": "Paramètres",
"title": "Paramètres du Compte"
},
"actions": "Actions",
"add": "Ajouter",
"adminTitle": "Tableau de Bord Administratif - Drop",
"adminTitleTemplate": "{0} - Administration - Drop",
"auth": {
"callback": {
"authClient": "Authoriser le client ?",
"authorize": "Authoriser",
"authorizedClient": "Drop a réussi a autoriser le client. Vous pouvez fermer cette fenêtre.",
"issues": "Vous avez des problèmes ?",
"learn": "En savoir plus {arrow}",
"paste": "Collez ce code dans le client pour continuer :",
"permWarning": "Accepter cette requête autorisera \"{name}\" sur \"{plateform} à :",
"requestedAccess": "\"{name} a demandé accès à votre compte Drop.",
"success": "Réussi !"
},
"code": {
"description": "Utiliser un code pour vous connecter à votre client Drop si vous ne pouvez pas ouvrir un navigateur web sur votre appareil.",
"title": "Connecter votre client Drop"
},
"displayName": "Nom d'Affichage",
"email": "Email",
"password": "Mot de passe",
"register": {
"confirmPasswordFormat": "Doit être pareil qu'au dessus",
"emailFormat": "Doit être au format utilisateur{'@'}exemple.com",
"passwordFormat": "Doit être au moins 14 caractères ou plus",
"subheader": "Remplissez vos coordonnées pour créer votre compte.",
"title": "Créer votre compte Drop",
"usernameFormat": "Doit être au moins 5 caractères et en minuscules"
},
"signin": {
"externalProvider": "Connectez vous avec un fournisseur externe {arrow}",
"forgot": "Mot de passe oublié ?",
"noAccount": "Pas de compte ? Demandez à un administrateur d'en créer un pour vous.",
"or": "OU",
"pageTitle": "Se connecter à Drop",
"rememberMe": "Se souvenir de moi",
"signin": "Se connecter",
"title": "Se connecter à votre compte"
},
"signout": "Déconnexion",
"username": "Nom d'utilisateur"
},
"cancel": "Annuler",
"chars": {
"arrow": "→",
"arrowBack": "←",
"quoted": "\"{text}\"",
"srComma": ", {0}"
},
"common": {
"add": "Ajouter",
"cannotUndo": "Cette action ne peut pas être défaite.",
"close": "Fermer",
"create": "Créer",
"date": "Date",
"deleteConfirm": "Êtes vous sûr de vouloir supprimer \"{0}\" ?",
"divider": "{'|'}",
"edit": "Éditer",
"friends": "Amis",
"groups": "Groupes",
"insert": "Insérer",
"name": "Nom",
"noResults": "Pas de résultat",
"noSelected": "Pas d'élément sélectionné.",
"remove": "Retirer",
"save": "Sauvegarder",
"saved": "Sauvegardé",
"servers": "Serveurs",
"srLoading": "Chargement…",
"tags": "Étiquettes",
"today": "Aujourd'hui"
},
"delete": "Supprimer",
"drop": {
"desc": "Une plateforme de distribution libre conçue pour être rapide, flexible et belle.",
"drop": "Drop"
},
"editor": {
"bold": "Gras",
"boldPlaceholder": "Caractères gras",
"code": "Code",
"codePlaceholder": "code",
"heading": "En-tête",
"headingPlaceholder": "en-tête",
"italic": "Italique",
"italicPlaceholder": "texte italique",
"link": "Lien",
"linkPlaceholder": "texte du lien",
"listItem": "Élement de liste",
"listItemPlaceholder": "élément de liste"
},
"errors": {
"admin": {
"user": {
"delete": {
"desc": "Drop n'a pas pu supprimer cet utilisateur : {0}",
"title": "Échec de la suppression de l'utilisateur"
}
}
},
"auth": {
"disabled": "Compte invalide or désactivé. Merci de contacter l'administrateur du serveur.",
"invalidInvite": "Invitation invalide ou expirée",
"invalidUserOrPass": "Nom d'utilisateur ou password invalide.",
"inviteIdRequired": "id est requis pour récupérer l'invitation",
"method": {
"signinDisabled": "Méthode de connexion non activée"
},
"usernameTaken": "Nom d'utilisateur déjà pris."
},
"backHome": "{arrow} Retour a l'accueil",
"game": {
"banner": {
"description": "Drop a échoué a mettre à jour l'image de la bannière : {0}",
"title": "Échec de la mise à jour de l'image de la bannière"
},
"carousel": {
"description": "Drop a échoué a mettre a jour le carrousel à images : {0}",
"title": "Échec de la mise à jour du carrousel à images"
},
"cover": {
"description": "Drop a échoué à mettre à jour l'image de couverture : {0}",
"title": "Échec de la mise à jour de l'image de couverture"
},
"deleteImage": {
"description": "Drop a échoué à supprimer l'image : {0}",
"title": "Échec de la suppression de l'image"
},
"description": {
"description": "Drop a échoué à mettre à jour la description du jeu : {0}",
"title": "Échec de la mise à jour de la description du jeu"
},
"metadata": {
"description": "Drop a échoué à mettre à jour les données méta : {0}",
"title": "Échec de la mise à jour des données méta"
}
},
"invalidBody": "Corps de requête non valide : {0}",
"inviteRequired": "Invitation requise pour créer un compte.",
"library": {
"add": {
"desc": "Drop n'a pas pu ajouter ce jeu à votre bibliothèque : {0}",
"title": "Échec de l'ajout du jeu à la bibliothèque"
},
"collection": {
"create": {
"desc": "Drop n'a pas pu créer votre collection : {0}",
"title": "Échec de la création de la collection"
}
},
"source": {
"delete": {
"desc": "Drop n'a pas pu supprimer cette source : {0}",
"title": "Échec de la suppression de la source de bibliothèque"
}
}
},
"news": {
"article": {
"delete": {
"desc": "Drop n'a pas pu supprimer cet article : {0}",
"title": "Échec de la suppression de l'article"
}
}
},
"occurred": "Une erreur s'est produite en réponse à vôtre requête. Si vous pensez que c'est un bug, merci de le rapporter. Essayer de vous connecter et voyez si cela résoud le problème.",
"ohNo": "Oh non !",
"pageTitle": "{0} | Drop",
"revokeClient": "Échec de la révocation du client",
"revokeClientFull": "Échec de la revocation du client {0}",
"signIn": "Se connecter {arrow}",
"unknown": "Une erreur inconnue est survenue",
"upload": {
"description": "Drop n'a pas pu uploader le fichier : {0}",
"title": "Échec de l'upload du fichier"
},
"version": {
"delete": {
"desc": "Drop a rencontré une erreur pendant la suppression de la version : {error}",
"title": "Une erreur est survenue pendant la supression de la version"
},
"order": {
"desc": "Drop a rencontré une erreur pendant la mise a jour de la version : {error}",
"title": "Une erreur est survenue pendant la mise a jour de l'ordre des versions"
}
}
},
"footer": {
"about": "À propos",
"aboutDrop": "À propos de Drop",
"comparison": "Comparaison",
"docs": {
"client": "Documentation du client",
"server": "Documentation du serveur"
},
"documentation": "Documentation",
"findGame": "Trouver un jeu",
"footer": "Pied de page",
"games": "Jeux",
"social": {
"discord": "Discord",
"github": "GitHub"
},
"topSellers": "Meilleures Ventes",
"version": "Drop {version} {gitRef}"
},
"header": {
"admin": {
"admin": "Administration",
"metadata": "Méta",
"settings": "Paramètres",
"tasks": "Tâches",
"users": "Utilisateurs"
},
"back": "Retour",
"openSidebar": "Ouvrir la barre latérale"
},
"helpUsTranslate": "Aidez nous à traduire Drop {arrow}",
"highest": "le plus haut",
"home": "Accueil",
"library": {
"addGames": "Tous les jeux",
"addToLib": "Ajouter à la bibliothèque",
"admin": {
"detectedGame": "Drop a détecté que vous avez des nouveaux jeux a importer.",
"detectedVersion": "Drop a détecté que vous avez des nouvelles versions de ce jeu à importer.",
"game": {
"addCarouselNoImages": "Pas d'image a ajouter.",
"addDescriptionNoImages": "Pas d'image à ajouter.",
"addImageCarousel": "Ajouter à partir d'une bibliothèque d'images",
"currentBanner": "bannière",
"currentCover": "couverture",
"deleteImage": "Supprimer l'image",
"editGameDescription": "Description du jeu",
"editGameName": "Nom du jeu",
"imageCarousel": "Carrousel d'images",
"imageCarouselDescription": "Personnaliser quelles images et dans quel ordre elles sont affichées sur la page du Store.",
"imageCarouselEmpty": "Aucune image n'a encore été ajoutée au carousel.",
"imageLibrary": "Bibliothèque d'images",
"imageLibraryDescription": "Veuillez noter que toutes les images uploadées sont accessible a tous les utilisateurs via des outils de développement des navigateurs.",
"removeImageCarousel": "Retirer l'image",
"setBanner": "Définir comme bannière",
"setCover": "Définir comme couverture"
},
"gameLibrary": "Bibliothèque de jeux",
"import": {
"bulkImportDescription": "Lorsque vous êtes sur cette page, vous ne serez pas redirigé sur la tâche d'importation, pour que vous puissiez importer plusieurs jeux successivement.",
"bulkImportTitle": "Mode d'importation de masse",
"import": "Importer",
"link": "Imported {arrow}",
"loading": "Chargement des résultats des jeux…",
"search": "Rechercher",
"searchPlaceholder": "Fallout 4",
"selectDir": "Merci de choisir un dossier…",
"selectGame": "Sélectionnez le jeu à importer",
"selectGamePlaceholder": "Merci de sélectionner un jeu…",
"selectGameSearch": "Sélectionner un jeu",
"selectPlatform": "Merci de sélectionner une plateforme…",
"version": {
"advancedOptions": "Options avancées",
"import": "Importer une version",
"installDir": "(install_dir)/",
"launchCmd": "Lancer l'exécutable/commande",
"launchDesc": "Exécutable pour lancer le jeu",
"launchPlaceholder": "jeu.exe",
"loadingVersion": "Chargement des métadonnées de la version…",
"noAdv": "Pas d'option avancée pour cette configuration.",
"noVersions": "Pas de version à importer",
"platform": "Version de la plateforme",
"setupCmd": "Exécutable/commande d'installation",
"setupDesc": "Exécuté une fois lorsque le jeu a été installé",
"setupMode": "Mode de configuration",
"setupModeDesc": "Lorsqu'elle est activée, cette version n'a pas de commande de lancement, et exécute simplement l'exécutable sur l'ordinateur de l'utilisateur. Utile pour les jeux qui distribue uniquement des fichiers d'installation et non les fichiers portables.",
"setupPlaceholder": "setup.exe",
"umuLauncherId": "UMU Launcher ID",
"umuOverride": "Remplacer l'ID de jeu du lanceur UMU",
"umuOverrideDesc": "Par défaut, Drop utilise un non-ID pour lancer les jeux avec UMU Launcher. Pour récupérer les bons patchs pour certains jeux, vous pourriez avoir besoin de changer ce champ manuellement.",
"updateMode": "Mode de mise à jour",
"updateModeDesc": "Lorsqu'ils sont activés, ces fichiers seront installés par-dessus (remplaçant) la version précédente. Si plusieurs \"modes de mise à jour\" sont enchaînés, ils sont appliqués dans l'ordre.",
"version": "Sélectionner la version à importer"
},
"withoutMetadata": "Importer sans les données méta"
},
"metadata": {
"companies": {
"action": "Gérer {arrow}",
"addGame": {
"description": "Choisissez un jeu à ajouter à la société, et si il faudrait la lister en tant que développeur, éditeur, ou les deux.",
"developer": "Développeur ?",
"noGames": "Pas de jeu à ajouter",
"publisher": "Éditeur ?",
"title": "Connecter le jeu a cette société"
},
"description": "Les sociétés organisent les jeux par qui les a développer ou éditer.",
"editor": {
"action": "Ajouter un jeu {plus}",
"developed": "Développé",
"libraryDescription": "Ajouter, supprimer ou personnaliser ce que cette société a développé et/ou publié.",
"libraryTitle": "Bibliothèque de jeux",
"noDescription": "(pas de description)",
"published": "Publié",
"uploadBanner": "Uploader bannière",
"uploadIcon": "Uplader icône"
},
"modals": {
"nameDescription": "Éditer le nom de la société. Ce nom est utilisé pour trouver les jeux nouvellement importés.",
"nameTitle": "Éditer le nom de la société",
"shortDeckDescription": "Éditer la description de la company. Cela n'affecte pas la description longue (markdown).",
"shortDeckTitle": "Éditer la description de la société",
"websiteDescription": "Éditer le site internet de la société. Note : cela sera un lien, et ne bénéficiera pas de la protection aux redirects.",
"websiteTitle": "Éditer le site internet de la société"
},
"noCompanies": "Pas de société",
"noGames": "Pas de jeu",
"search": "Chercher des sociétés…",
"searchGames": "Chercher les jeux de l'entreprise…",
"title": "Sociétés"
},
"tags": {
"action": "Gérer {arrow}",
"create": "Créer",
"description": "Les tags sont automatiquement créés à partir des genres importés. Vous pouvez ajouter des tags personnalisés pour ajouter la catégorisation de votre bibliothèque de jeux.",
"modal": {
"description": "Créer un tag pour organiser votre bibliothèque.",
"title": "Créer un tag"
},
"title": "Tags"
}
},
"metadataProvider": "Fournisseur de données méta",
"noGames": "Pas de jeu importé",
"offline": "Drop n'a pas pu accéder à ce jeu.",
"offlineTitle": "Jeu hors-ligne",
"openEditor": "Ouvrir dans l'éditeur {arrow}",
"openStore": "Ouvrir dans le Store",
"shortDesc": "Description Courte",
"sources": {
"create": "Créer une source",
"createDesc": "Drop va utiliser cette source pour accéder à votre bibliothèque de jeux, et les rendre disponible.",
"desc": "Configurer vos sources de bibliothèques où Drop va regarder pour les nouveaux jeux et versions à importer.",
"edit": "Éditer la source",
"fsDesc": "Importe les jeux à partir d'un chemin d'accès sur le disque. Cela requière une structure des dossiers basées sur la version, et qui supporte les jeux archivés.",
"fsFlatDesc": "Importe les jeux à partir d'un chemin daccès sur le disque, mais sans le sous-dossier version séparé. Utile pour migrer une bibliothèque vers Drop.",
"fsPath": "Chemin daccès",
"fsPathDesc": "Un chemin daccès absolu à votre bibliothèque de jeux.",
"fsPathPlaceholder": "/mnt/jeux",
"link": "Sources {arrow}",
"nameDesc": "Le nom de votre source, pour référence.",
"namePlaceholder": "Mes Nouvelle Source",
"sources": "Sources de Bibliothèques",
"typeDesc": "Le type de source. Affecte les options requises.",
"working": "Marche ?"
},
"subheader": "Lorsque que vous rajoutez des dossiers à vos sources de bibliothèques, Drop le détectera et vous demandera de les importer. Chaque jeu a besoin dêtre importé avant que vous puissiez importer une version.",
"title": "Bibliothèques",
"version": {
"delta": "Mode de mise à jour",
"noVersions": "Vous n'avez aucune version de ce jeu de disponible.",
"noVersionsAdded": "pas de version ajoutée"
},
"versionPriority": "Priorité des versions"
},
"back": "Retour à la Bibliothèque",
"collection": {
"addToNew": "Ajouter à une nouvelle collection",
"collections": "Collections",
"create": "Créer une Collection",
"createDesc": "Les collections peuvent être utilisées pour organiser vos jeux et vous permettre de les trouver plus facilement, surtout si vous possédez une grosse bibliothèque.",
"delete": "Supprimer la Collection",
"namePlaceholder": "Nom de la collection",
"noCollections": "Pas de collection",
"notFound": "Collection non trouvée",
"subheader": "Ajouter une nouvelle collection pour organiser vos jeux",
"title": "Collection"
},
"gameCount": "{0} jeux | {0} jeu | {0} jeux",
"inLib": "Dans la Bibliothèque",
"launcherOpen": "Ouvrir dans le Launcher",
"noGames": "Pas de jeu dans la bibliothèque",
"notFound": "Jeu non trouvé",
"search": "Chercher bibliothèque…",
"subheader": "Organiser vos jeux en collections pour un accès facile, et accéder à tous vos jeux."
},
"lowest": "le plus bas",
"news": {
"article": {
"add": "Ajouter",
"content": "Contenu (Markdown)",
"create": "Créer un Nouvel Article",
"editor": "Éditeur",
"editorGuide": "Utilisez les raccourcis ci-dessus ou écrivez en Markdown directement. Supporte **gras**, *italique*, [lines](adresse), et plus.",
"new": "Nouvel article",
"preview": "Aperçu",
"shortDesc": "Description courte",
"submit": "Soumettre",
"tagPlaceholder": "Ajouter un tag…",
"titles": "Titre",
"uploadCover": "Uploader l'image de couverture"
},
"back": "Retour aux Nouvelles",
"checkLater": "Vérifier plus tard pour les mises à jour.",
"delete": "Supprimer l'Article",
"filter": {
"month": "Ce mois",
"week": "Cette semaine",
"year": "Cette année"
},
"none": "Pas d'article",
"notFound": "Article non trouvé",
"search": "Chercher des articles",
"searchPlaceholder": "Chercher des articles…",
"subheader": "Rester à jour avec les dernières mises à et annonces.",
"title": "Dernières Nouvelles"
},
"options": "Options",
"security": "Sécurité",
"selectLanguage": "Sélectionner la langue",
"settings": {
"admin": {
"description": "Configurer les paramètres de Drop",
"store": {
"dropGameAltPlaceholder": "Exemple d'icône de Jeu",
"dropGameDescriptionPlaceholder": "Ceci est un jeu exemple. Il sera remplacé si vous importez un jeu.",
"dropGameNamePlaceholder": "Jeu Exemple",
"showGamePanelTextDecoration": "Afficher le titre et la description sur les tuiles de jeu (par défaut : activé)",
"title": "Store"
},
"title": "Paramètres"
}
},
"setup": {
"auth": {
"description": "Authentification sur Drop se passe à travers multiple 'fournisseurs' pré-configuré. Chaque fournisseur peut autoriser des utilisateurs à se connecter via leurs méthodes. Pour commencer, aillez au moins un fournisseur d'authentification d'activé, et créer un compte via celui-ci.",
"docs": "Documentation {arrow}",
"enabled": "Activé ?",
"openid": {
"description": "OpenID Connect (OIDC) est une extension OAuth2 communément supportée. Drop requière que la configuration OIDC se fasse via des variables d'environnement.",
"skip": "J'ai un utiliser avec OIDC",
"title": "OpenID Connect"
},
"simple": {
"description": "L'authentification simple utilise le nom d'utiliser et mot de passe pour authentifier les utilisateurs. Elle est activée par défaut si aucun autre fournisseur d'authentification est activé.",
"register": "Créer un compte administrateur",
"title": "Authentification simple"
},
"title": "Authentification"
},
"finish": "C'est parti {arrow}",
"noPage": "Pas de page",
"stages": {
"account": {
"description": "Vous avez besoin d'au moins un compte pour démarrer Drop.",
"name": "Configurez votre compte administrateur."
},
"library": {
"description": "Ajouter au moins une source de bibliothèques pour utiliser Drop.",
"name": "Créer une bibliothèque."
}
},
"welcome": "Salut.",
"welcomeDescription": "Bienvenue dans l'assistant de configuration de Drop. Il va aidera configurer Drop pour la première fois, et vous expliquera son fonctionnement."
},
"store": {
"about": "À propos",
"commingSoon": "prochainement",
"exploreMore": "Explorer plus {arrow}",
"featured": "Mis en avant",
"images": "Images de Jeux",
"noDevelopers": "Pas de développeur",
"noGame": "pas de jeu",
"noImages": "Pas d'image",
"noPublishers": "Pas d'éditeur.",
"noTags": "Pas de tag",
"openAdminDashboard": "Ouvrir dans le Tableau de Bord d'Administration",
"platform": "Plateforme | Plateforme | Plateformes",
"publishers": "Éditeurs | Éditeur | Éditeurs",
"rating": "Note",
"readLess": "Cliquez pour lire moins",
"readMore": "Clique pour lire plus",
"recentlyAdded": "Ajouté Récemment",
"recentlyReleased": "Récemment publié",
"recentlyUpdated": "Récemment Mis à Jour",
"released": "Publié",
"reviews": "({0} Avis)",
"tags": "Tags",
"title": "Store",
"view": {
"sort": "Trier",
"srFilters": "Filtres",
"srGames": "Jeux",
"srViewGrid": "Voir grille"
},
"viewInStore": "Voir dans le Store",
"website": "Site internet"
},
"tasks": {
"admin": {
"back": "{arrow} Retour aux Tâches",
"completedTasksTitle": "Tâches complétées",
"dailyScheduledTitle": "Tâches quotidiennes planifiées",
"noTasksRunning": "Pas de tâche en cours",
"runningTasksTitle": "Tâches en cours d'exécution",
"scheduled": {
"checkUpdateDescription": "Vérifier si Drop a une mise à jour.",
"checkUpdateName": "Vérifier la mise à jour.",
"cleanupInvitationsDescription": "Nettoie les invitations expirées de la base de données pour économiser de l'espace.",
"cleanupInvitationsName": "Nettoie les invitations",
"cleanupObjectsDescription": "Détecte et supprime les objets non référencés et non utilisés pour économiser de l'espace.",
"cleanupObjectsName": "Nettoyer les objets",
"cleanupSessionsDescription": "Nettoie les sessions expirées pour économiser de l'espace et assurer la sécurité.",
"cleanupSessionsName": "Nettoie les sessions."
},
"viewTask": "Voir {arrow}",
"weeklyScheduledTitle": "Tâches hebdomadaires planifiées"
}
},
"title": "Drop",
"titleTemplate": "{0} - Drop",
"todo": "À faire",
"type": "Type",
"upload": "Uploader",
"uploadFile": "Uploader fichier",
"userHeader": {
"closeSidebar": "Fermer la barre latérale",
"links": {
"community": "Communauté",
"library": "Bibliothèque",
"news": "Nouvelles"
},
"profile": {
"admin": "Tableau de Bord Administratif",
"settings": "Paramètres du compte"
}
},
"users": {
"admin": {
"adminHeader": "Administrateur ?",
"adminUserLabel": "Administrateur",
"authLink": "Authentification {arrow}",
"authentication": {
"configure": "Configurer",
"description": "Drop supporte une variété de \"mécanismes d'authentification\". Lorsque vous les activez ou les désactivez, ils sont affichés sur la page de connection pour que les utilisateurs puissent les sélectionner. Cliquer sur le menu à points pour configurer le mécanisme d'authentification.",
"disabled": "Désactivé",
"enabled": "Activé",
"oidc": "OpenID Connect",
"simple": "Simple (nom d'utilisateur/mot de passe)",
"srOpenOptions": "Ouvrir les options",
"title": "Authentification"
},
"authoptionsHeader": "Options Auth",
"delete": "Supprimer",
"deleteUser": "Supprimer l'utilisateur {0}",
"description": "Gérer les utilisateurs sur votre instance Drop, et configurer vos méthodes d'authentification.",
"displayNameHeader": "Nom d'affichage",
"emailHeader": "Email",
"normalUserLabel": "Utilisateur normal",
"simple": {
"adminInvitation": "Invitation adminstrateur",
"createInvitation": "Créer invitation",
"description": "L'authentification simple utilise un système d'invitations pour créer les utilisateurs. Tu peux créer une invitation et optionnellement spécifier le nom d'utilisateur ou email de cet utilisateur, et un lien magique sera généré un lien magique qui peut être utilisé pour créer le compte.",
"expires": "Expire : {expiry}",
"invitationTitle": "invitations",
"invite3Days": "3 jours",
"invite6Months": "6 mois",
"inviteAdminSwitchDescription": "Créer cet utilisateur en tant qu'adminstrateur",
"inviteAdminSwitchLabel": "Invitation adminstrateur",
"inviteButton": "Invitation",
"inviteDescription": "Drop va générer une adresse que vous pouvez envoyer à la personne que vous voulez inviter. Vous pouvez optionnellement spécifier un nom d'utilisateur ou une adresse e-mail qu'elle pourra utiliser.",
"inviteEmailDescription": "Doit être dans le format utilisateur{'@'}exemple.com",
"inviteEmailLabel": "E-mail adresse (optionnel)",
"inviteEmailPlaceholder": "moi{'@'}exemple.com",
"inviteExpiryLabel": "Expire",
"inviteMonth": "1 mois",
"inviteNever": "Jamais",
"inviteTitle": "Inviter l'utilisateur sur Drop",
"inviteUsernameFormat": "Doit être 5 caractères ou plus",
"inviteUsernameLabel": "Nom d'utilisateur (optionnel)",
"inviteUsernamePlaceholder": "monNomDUtilisateur",
"inviteWeek": "1 semaine",
"inviteYear": "1 an",
"neverExpires": "N'expire jamais.",
"noEmailEnforced": "Pas d'e-mail imposé.",
"noInvitations": "Pas d'invitation.",
"noUsernameEnforced": "Pas de nom d'utilisateur imposé.",
"title": "Authentication simple",
"userInvitation": "Invitation utilisateur"
},
"srEditLabel": "Éditer",
"usernameHeader": "Nom d'utilisateur"
}
},
"welcome": "Américain, bienvenue !"
}

40
i18n/locales/ru.json Normal file
View File

@ -0,0 +1,40 @@
{
"account": {
"devices": {
"capabilities": "Возможности",
"lastConnected": "Последнее подключение",
"noDevices": "К вашей учетной записи не подключено ни одного устройства.",
"platform": "Платформа",
"subheader": "Управляйте устройствами, имеющими доступ к вашей учетной записи Drop.",
"title": "Устройства"
},
"notifications": {
"all": "Показать все {arrow}",
"desc": "Просмотр и управление уведомлениями.",
"markAllAsRead": "Отметить все как прочитанные",
"markAsRead": "Отметить как прочитанное",
"none": "Нет уведомлений",
"notifications": "Уведомления",
"title": "Уведомления",
"unread": "Непрочитанные уведомления"
},
"settings": "Настройки",
"title": "Настройки учетной записи"
},
"actions": "Действия",
"add": "Добавить",
"adminTitle": "Панель администратора - Drop",
"adminTitleTemplate": "{0} - Админ - Drop",
"auth": {
"callback": {
"authClient": "Авторизовать клиента?",
"authorize": "Авторизовать",
"authorizedClient": "Drop успешно авторизовал клиента. Теперь вы можете закрыть это окно.",
"issues": "Есть проблемы?",
"learn": "Узнать больше {arrow}",
"paste": "Вставьте этот код в клиент, чтобы продолжить:",
"permWarning": "Принятие этого запроса позволит \"{name}\" на \"{platform}\" выполнять следующие действия:",
"requestedAccess": "\"{name}\" запросил доступ к вашей учетной записи Drop."
}
}
}

View File

@ -164,6 +164,7 @@ import {
Cog6ToothIcon, Cog6ToothIcon,
UserGroupIcon, UserGroupIcon,
RectangleStackIcon, RectangleStackIcon,
DocumentIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import type { NavigationItem } from "~/composables/types"; import type { NavigationItem } from "~/composables/types";
import { useCurrentNavigationIndex } from "~/composables/current-page-engine"; import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
@ -180,6 +181,12 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
prefix: "/admin/library", prefix: "/admin/library",
icon: ServerStackIcon, icon: ServerStackIcon,
}, },
{
label: $t("header.admin.metadata"),
route: "/admin/metadata",
prefix: "/admin/metadata",
icon: DocumentIcon,
},
{ {
label: $t("header.admin.users"), label: $t("header.admin.users"),
route: "/admin/users", route: "/admin/users",
@ -193,7 +200,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
icon: RectangleStackIcon, icon: RectangleStackIcon,
}, },
{ {
label: $t("settings"), label: $t("header.admin.settings.title"),
route: "/admin/settings", route: "/admin/settings",
prefix: "/admin/settings", prefix: "/admin/settings",
icon: Cog6ToothIcon, icon: Cog6ToothIcon,

View File

@ -1,20 +1,15 @@
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { cpSync } from "node:fs"; import { cpSync, readFileSync, existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import module from "module"; import module from "module";
import { viteStaticCopy } from "vite-plugin-static-copy"; import { viteStaticCopy } from "vite-plugin-static-copy";
import { type } from "arktype";
// get drop version const packageJsonSchema = type({
const dropVersion = process.env.BUILD_DROP_VERSION ?? "v0.3.0-alpha.1"; name: "string",
// example nightly: "v0.3.0-nightly.2025.05.28" version: "string",
});
// get git ref or supply during build
const commitHash =
process.env.BUILD_GIT_REF ??
execSync("git rev-parse --short HEAD").toString().trim();
console.log(`Building Drop ${dropVersion} #${commitHash}`);
const twemojiJson = module.findPackageJSON( const twemojiJson = module.findPackageJSON(
"@discordapp/twemoji", "@discordapp/twemoji",
@ -24,6 +19,16 @@ if (!twemojiJson) {
throw new Error("Could not find @discordapp/twemoji package."); throw new Error("Could not find @discordapp/twemoji package.");
} }
// get drop version
const dropVersion = getDropVersion();
// get git ref or supply during build
const commitHash =
process.env.BUILD_GIT_REF ??
execSync("git rev-parse --short HEAD").toString().trim();
console.log(`Drop ${dropVersion} #${commitHash}`);
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
extends: ["./drop-base"], extends: ["./drop-base"],
@ -31,11 +36,11 @@ export default defineNuxtConfig({
modules: [ modules: [
"vue3-carousel-nuxt", "vue3-carousel-nuxt",
"nuxt-security", "nuxt-security", // "@nuxt/image",
// "@nuxt/image",
"@nuxt/fonts", "@nuxt/fonts",
"@nuxt/eslint", "@nuxt/eslint",
"@nuxtjs/i18n", "@nuxtjs/i18n",
"@vueuse/nuxt",
], ],
// Nuxt-only config // Nuxt-only config
@ -69,7 +74,8 @@ export default defineNuxtConfig({
vite: { vite: {
plugins: [ plugins: [
tailwindcss(), // eslint-disable-next-line @typescript-eslint/no-explicit-any
tailwindcss() as any,
// only used in dev server, not build because nitro sucks // only used in dev server, not build because nitro sucks
// see build hook below // see build hook below
viteStaticCopy({ viteStaticCopy({
@ -79,7 +85,8 @@ export default defineNuxtConfig({
dest: "twemoji", dest: "twemoji",
}, },
], ],
}), // eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
], ],
}, },
@ -133,6 +140,7 @@ export default defineNuxtConfig({
scheduledTasks: { scheduledTasks: {
"0 * * * *": ["dailyTasks"], "0 * * * *": ["dailyTasks"],
"*/30 * * * *": ["downloadCleanup"],
}, },
storage: { storage: {
@ -151,7 +159,7 @@ export default defineNuxtConfig({
}, },
typescript: { typescript: {
typeCheck: true, //typeCheck: true,
tsConfig: { tsConfig: {
compilerOptions: { compilerOptions: {
@ -235,6 +243,9 @@ export default defineNuxtConfig({
file: "zh_tw.json", file: "zh_tw.json",
}, },
], ],
bundle: {
optimizeTranslationDirective: false,
},
}, },
security: { security: {
@ -245,6 +256,7 @@ export default defineNuxtConfig({
"img-src": [ "img-src": [
"'self'", "'self'",
"data:", "data:",
"blob:",
"https://www.giantbomb.com", "https://www.giantbomb.com",
"https://images.pcgamingwiki.com", "https://images.pcgamingwiki.com",
"https://images.igdb.com", "https://images.igdb.com",
@ -257,3 +269,41 @@ export default defineNuxtConfig({
requestSizeLimiter: false, requestSizeLimiter: false,
}, },
}); });
/**
* Gets the drop version from the environment variable or package.json
* @returns {string} The drop version
*/
function getDropVersion(): string {
// get drop version from environment variable
if (process.env.BUILD_DROP_VERSION) {
return process.env.BUILD_DROP_VERSION;
}
// example nightly: "v0.3.0-nightly.2025.05.28"
const defaultVersion = "v0.0.0-alpha.0";
// get path
const packageJsonPath = path.join(
path.dirname(import.meta.url.replace("file://", "")),
"package.json",
);
if (!existsSync(packageJsonPath)) {
console.error("Could not find package.json, using default version.");
return defaultVersion;
}
// parse package.json
const raw = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const packageJson = packageJsonSchema(raw);
if (packageJson instanceof type.errors) {
console.error("Failed to parse package.json", packageJson.summary);
return defaultVersion;
}
// ensure version starts with 'v'
if (packageJson.version.startsWith("v")) {
return packageJson.version;
}
return `v${packageJson.version}`;
}

View File

@ -1,9 +1,12 @@
{ {
"name": "drop", "name": "drop",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"engines": {
"node": ">=22.16.0"
},
"scripts": { "scripts": {
"build": "nuxt build", "build": "nuxt build",
"dev": "nuxt dev", "dev": "nuxt dev",
@ -11,23 +14,24 @@
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare && prisma generate", "postinstall": "nuxt prepare && prisma generate",
"typecheck": "nuxt typecheck", "typecheck": "nuxt typecheck",
"lint": "yarn lint:eslint && yarn lint:prettier", "lint": "pnpm run lint:eslint && pnpm run lint:prettier",
"lint:eslint": "eslint .", "lint:eslint": "eslint .",
"lint:prettier": "prettier . --check", "lint:prettier": "prettier . --check",
"lint:fix": "eslint . --fix && prettier --write --list-different ." "lint:fix": "eslint . --fix && prettier --write --list-different ."
}, },
"dependencies": { "dependencies": {
"@discordapp/twemoji": "^15.1.0", "@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "^1.3.1", "@drop-oss/droplet": "3.0.1",
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5", "@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3", "@lobomfz/prismark": "0.0.3",
"@nuxt/fonts": "^0.11.0", "@nuxt/fonts": "^0.11.0",
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxtjs/i18n": "^9.5.5", "@nuxtjs/i18n": "^9.5.5",
"@prisma/client": "^6.7.0", "@prisma/client": "^6.11.1",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"argon2": "^0.41.1", "@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.0",
"arktype": "^2.1.10", "arktype": "^2.1.10",
"axios": "^1.7.7", "axios": "^1.7.7",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
@ -36,20 +40,24 @@
"fast-fuzzy": "^1.12.0", "fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3", "file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"jsdom": "^26.1.0",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"micromark": "^4.0.1", "micromark": "^4.0.1",
"normalize-url": "^8.0.2",
"nuxt": "^3.17.4", "nuxt": "^3.17.4",
"nuxt-security": "2.2.0", "nuxt-security": "2.2.0",
"prisma": "^6.7.0", "pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"prisma": "^6.14.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"semver": "^7.7.1", "semver": "^7.7.1",
"stream-mime-type": "^2.0.0", "stream-mime-type": "^2.0.0",
"turndown": "^7.2.0", "turndown": "^7.2.0",
"unstorage": "^1.15.0", "unstorage": "^1.15.0",
"vite-plugin-static-copy": "^3.0.0", "vite-plugin-static-copy": "^3.1.2",
"vue": "latest", "vue": "latest",
"vue-router": "latest", "vue-router": "latest",
"vue3-carousel": "^0.15.0", "vue3-carousel": "^0.16.0",
"vue3-carousel-nuxt": "^1.1.5", "vue3-carousel-nuxt": "^1.1.5",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
@ -58,7 +66,7 @@
"@nuxt/eslint": "^1.3.0", "@nuxt/eslint": "^1.3.0",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/bcryptjs": "^3.0.0", "@types/jsdom": "^21.1.7",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/node": "^22.13.16", "@types/node": "^22.13.16",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
@ -69,20 +77,19 @@
"h3": "^1.15.3", "h3": "^1.15.3",
"nitropack": "^2.11.12", "nitropack": "^2.11.12",
"ofetch": "^1.4.1", "ofetch": "^1.4.1",
"postcss": "^8.4.47",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"sass": "^1.79.4", "sass": "^1.79.4",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vue-tsc": "^2.2.8" "vue-tsc": "^3.0.1"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"overrides": { "overrides": {
"vue3-carousel-nuxt": { "vue3-carousel-nuxt": {
"vue3-carousel": "^0.15.0" "vue3-carousel": "^0.16.0"
} }
}, },
"prisma": { "prisma": {
"schema": "./prisma" "schema": "./prisma"
} },
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
} }

View File

@ -24,7 +24,7 @@
scope="col" scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
> >
{{ $t("name") }} {{ $t("common.name") }}
</th> </th>
<th <th
scope="col" scope="col"

View File

@ -40,14 +40,16 @@
{{ notification.description }} {{ notification.description }}
</p> </p>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<a <NuxtLink
v-for="action in notification.actions" v-for="[name, href] in notification.actions.map((v) =>
:key="action" v.split('|'),
:href="action.split('|')[1]" )"
:key="href"
:href="href"
class="inline-flex items-center rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20 transition-all duration-200 hover:bg-blue-400/20 hover:scale-105 active:scale-95" class="inline-flex items-center rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20 transition-all duration-200 hover:bg-blue-400/20 hover:scale-105 active:scale-95"
> >
{{ action.split("|")[0] }} {{ name }}
</a> </NuxtLink>
</div> </div>
</div> </div>
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2"> <div class="ml-4 flex flex-shrink-0 items-center gap-x-2">
@ -57,7 +59,7 @@
<button <button
v-if="!notification.read" v-if="!notification.read"
type="button" type="button"
class="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20 transition-all duration-200 hover:bg-zinc-400/20 hover:scale-105 active:scale-95" class="inline-flex gap-x-1 items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20 transition-all duration-200 hover:bg-zinc-400/20 hover:scale-105 active:scale-95"
@click="markAsRead(notification.id)" @click="markAsRead(notification.id)"
> >
<CheckIcon class="size-3" /> <CheckIcon class="size-3" />
@ -65,7 +67,7 @@
</button> </button>
<button <button
type="button" type="button"
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95" class="inline-flex gap-x-1 items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="deleteNotification(notification.id)" @click="deleteNotification(notification.id)"
> >
<TrashIcon class="size-3" /> <TrashIcon class="size-3" />
@ -90,7 +92,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline"; import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
import type { Notification } from "~/prisma/client"; import type { NotificationModel } from "~/prisma/client/models";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
definePageMeta({ definePageMeta({
@ -104,7 +106,7 @@ useHead({
}); });
// Fetch notifications // Fetch notifications
const notifications = ref<SerializeObject<Notification>[]>([]); const notifications = ref<SerializeObject<NotificationModel>[]>([]);
async function fetchNotifications() { async function fetchNotifications() {
const { data } = await useFetch("/api/v1/notifications"); const { data } = await useFetch("/api/v1/notifications");
@ -118,7 +120,7 @@ await fetchNotifications();
async function markAsRead(id: string) { async function markAsRead(id: string) {
await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" }); await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" });
const notification = notifications.value.find( const notification = notifications.value.find(
(n: SerializeObject<Notification>) => n.id === id, (n: SerializeObject<NotificationModel>) => n.id === id,
); );
if (notification) { if (notification) {
notification.read = true; notification.read = true;
@ -128,16 +130,18 @@ async function markAsRead(id: string) {
// Mark all notifications as read // Mark all notifications as read
async function markAllAsRead() { async function markAllAsRead() {
await $dropFetch("/api/v1/notifications/readall", { method: "POST" }); await $dropFetch("/api/v1/notifications/readall", { method: "POST" });
notifications.value.forEach((notification: SerializeObject<Notification>) => { notifications.value.forEach(
notification.read = true; (notification: SerializeObject<NotificationModel>) => {
}); notification.read = true;
},
);
} }
// Delete a notification // Delete a notification
async function deleteNotification(id: string) { async function deleteNotification(id: string) {
await $dropFetch(`/api/v1/notifications/${id}`, { method: "DELETE" }); await $dropFetch(`/api/v1/notifications/${id}`, { method: "DELETE" });
const index = notifications.value.findIndex( const index = notifications.value.findIndex(
(n: SerializeObject<Notification>) => n.id === id, (n: SerializeObject<NotificationModel>) => n.id === id,
); );
if (index !== -1) { if (index !== -1) {
notifications.value.splice(index, 1); notifications.value.splice(index, 1);

229
pages/account/tokens.vue Normal file
View File

@ -0,0 +1,229 @@
<template>
<div>
<div class="w-full flex justify-between items-center">
<div>
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.token.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.token.subheader") }}
</p>
</div>
<div>
<LoadingButton :loading="false" @click="() => (createOpen = true)">
{{ $t("common.create") }}
</LoadingButton>
</div>
</div>
<div
v-if="newToken"
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
>
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-300">
{{ $t("account.token.success") }}
</p>
<p class="text-xs text-green-300/70">
{{ $t("account.token.successNote") }}
</p>
<p
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
>
{{ newToken }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
@click="() => (newToken = undefined)"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.acls") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.expiry") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="(token, tokenIdx) in tokens"
:key="token.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ token.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="acl in token.acls"
:key="acl"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
{{ acl }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
<span v-else>{{ $t("account.token.noExpiry") }}</span>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeToken(tokenIdx)"
>
{{ $t("account.token.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [token.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="tokens.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.token.noTokens") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ModalCreateToken
v-model="createOpen"
:acls="acls"
:loading="createLoading"
:suggested-name="suggestedName"
:suggested-acls="suggestedAcls"
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
/>
</div>
</template>
<script setup lang="ts">
import { ArkErrors, type } from "arktype";
import { DateTime, type DurationLike } from "luxon";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
const tokens = ref(await $dropFetch("/api/v1/user/token"));
const acls = await $dropFetch("/api/v1/user/token/acls");
const createOpen = ref(false);
const createLoading = ref(false);
const newToken = ref<string | undefined>();
const suggestedName = ref();
const suggestedAcls = ref<string[]>([]);
const payloadParser = type({
name: "string?",
acls: "string[]?",
});
const route = useRoute();
if (route.query.payload) {
try {
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
const payload = payloadParser(rawPayload);
if (payload instanceof ArkErrors) throw payload;
suggestedName.value = payload.name;
suggestedAcls.value = payload.acls ?? [];
createOpen.value = true;
} catch {
throw createError({
statusCode: 400,
message: "Failed to parse the token create payload.",
fatal: true,
});
}
}
async function createToken(
name: string,
acls: string[],
expiry: DurationLike | undefined,
) {
createLoading.value = true;
try {
const result = await $dropFetch("/api/v1/user/token", {
method: "POST",
body: {
name,
acls,
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} finally {
/* empty */
}
createOpen.value = false;
createLoading.value = false;
}
async function revokeToken(index: number) {
const token = tokens.value[index];
if (!token) return;
await $dropFetch("/api/v1/user/token/:id", {
method: "DELETE",
params: {
id: token.id,
},
failTitle: "Failed to revoke token.",
});
tokens.value.splice(index, 1);
}
</script>

View File

@ -1,163 +0,0 @@
<template>
<div
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
>
<div
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
>
<!--start-->
<div>
<Listbox v-if="false" v-model="currentMode" as="div">
<div class="relative mt-2">
<ListboxButton
class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
>
<span class="col-start-1 row-start-1 truncate">{{
currentMode
}}</span>
<PencilIcon class="ml-auto size-5" />
<ChevronUpDownIcon
class="text-gray-500 size-5"
aria-hidden="true"
/>
</ListboxButton>
<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-white/5 focus:outline-hidden sm:text-sm"
>
<ListboxOption
v-for="[value] in Object.entries(components)"
v-slot="{ active, selected }"
:key="value"
as="template"
:value="value"
>
<li
:class="[
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ value }}</span
>
<span
v-if="selected"
class="text-white absolute inset-y-0 right-0 flex items-center pr-4"
>
<PencilIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="pt-4 inline-flex gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
>
<button
:class="[
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
value == currentMode
? 'bg-zinc-900 text-zinc-100'
: 'bg-transparent text-zinc-500',
]"
@click="() => (currentMode = value as GameEditorMode)"
>
<component :is="icon" class="size-4" />
{{ value }}
</button>
</div>
</div>
</div>
<div>
<!-- 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"
>
{{ $t("library.admin.openStore") }}
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
</div>
</div>
<component
:is="components[currentMode].editor"
v-model="game"
:unimported-versions="unimportedVersions"
/>
</div>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { GameEditorMetadata, GameEditorVersion } from "#components";
import {
ArrowTopRightOnSquareIcon,
DocumentIcon,
PencilIcon,
ServerStackIcon,
} from "@heroicons/vue/24/outline";
import type { Component } from "vue";
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);
definePageMeta({
layout: "admin",
});
useHead({
// To do a title with the game name in it, we need some sort of watch
title: "Game Editor",
});
enum GameEditorMode {
Metadata = "Metadata",
Versions = "Versions",
}
const components: {
[key in GameEditorMode]: { editor: Component; icon: Component };
} = {
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
[GameEditorMode.Versions]: {
editor: GameEditorVersion,
icon: ServerStackIcon,
},
};
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
</script>

View File

@ -1,8 +1,9 @@
<template> <template>
<div class="flex flex-col gap-y-4 max-w-lg"> <div class="flex flex-col gap-y-4">
<Listbox <Listbox
as="div" as="div"
:model-value="currentlySelectedVersion" :model-value="currentlySelectedVersion"
class="max-w-lg"
@update:model-value="(value) => updateCurrentlySelectedVersion(value)" @update:model-value="(value) => updateCurrentlySelectedVersion(value)"
> >
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{ <ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{
@ -73,9 +74,32 @@
</div> </div>
</Listbox> </Listbox>
<div v-if="versionGuesses" class="flex flex-col gap-8"> <div v-if="versionGuesses" class="flex flex-col gap-4">
<!-- setup executable --> <!-- version name -->
<div> <div class="max-w-lg">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>Version name</label
>
<p class="text-zinc-400 text-xs">
Shown to users when selecting what version to install.
</p>
<div class="mt-2">
<input
id="name"
v-model="versionSettings.name"
name="name"
type="text"
required
placeholder="my version name"
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-800 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>
</div>
<!-- install command -->
<div class="max-w-lg">
<label <label
for="startup" for="startup"
class="block text-sm font-medium leading-6 text-zinc-100" class="block text-sm font-medium leading-6 text-zinc-100"
@ -93,109 +117,14 @@
> >
{{ $t("library.admin.import.version.installDir") }} {{ $t("library.admin.import.version.installDir") }}
</span> </span>
<Combobox <PreloadSelector
as="div" :value="versionSettings.install"
:value="versionSettings.setup" :guesses="versionGuesses"
nullable @update="(v) => updateInstallCommand(v)"
@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="
$t('library.admin.import.version.setupPlaceholder')
"
@change="setupProcessQuery = $event.target.value"
@blur="setupProcessQuery = ''"
/>
<ComboboxButton
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
class="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
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-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-for="guess in setupFilteredVersionGuesses"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<component
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
class="size-5"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
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-zinc-100',
]"
>
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ $t("chars.quoted", { text: setupProcessQuery }) }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<input <input
id="startup" id="startup"
v-model="versionSettings.setupArgs" v-model="versionSettings.installArgs"
type="text" type="text"
name="startup" 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" 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"
@ -205,7 +134,7 @@
</div> </div>
</div> </div>
<!-- setup mode --> <!-- setup mode -->
<SwitchGroup as="div" class="flex items-center justify-between"> <SwitchGroup as="div" class="max-w-lg flex items-center justify-between">
<span class="flex flex-grow flex-col"> <span class="flex flex-grow flex-col">
<SwitchLabel <SwitchLabel
as="span" as="span"
@ -233,7 +162,8 @@
/> />
</Switch> </Switch>
</SwitchGroup> </SwitchGroup>
<div class="relative"> <!-- launch commands -->
<div class="relative max-w-3xl">
<label <label
for="startup" for="startup"
class="block text-sm font-medium leading-6 text-zinc-100" class="block text-sm font-medium leading-6 text-zinc-100"
@ -242,134 +172,122 @@
<p class="text-zinc-400 text-xs"> <p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.launchDesc") }} {{ $t("library.admin.import.version.launchDesc") }}
</p> </p>
<div class="mt-2"> <div class="mt-2 ml-4 flex flex-col gap-y-2 items-start">
<div <div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600" v-for="(launch, launchIdx) in versionSettings.launches"
:key="launchIdx"
class="inline-flex items-center gap-x-2"
> >
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>{{ $t("library.admin.import.version.installDir") }}</span
>
<Combobox
as="div"
:value="versionSettings.launch"
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="
$t('library.admin.import.version.launchPlaceholder')
"
@change="launchProcessQuery = $event.target.value"
@blur="launchProcessQuery = ''"
/>
<ComboboxButton
v-if="launchFilteredVersionGuesses?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon
class="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
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-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-for="guess in launchFilteredVersionGuesses"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<component
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
class="size-5"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
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-zinc-100',
]"
>
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ $t("chars.quoted", { text: launchProcessQuery }) }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<input <input
id="startup" id="launch-name"
v-model="versionSettings.launchArgs" v-model="launch.name"
type="text" type="text"
name="startup" name="launch-name"
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" 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-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
placeholder="--launch" placeholder="My Launch Command"
/> />
<div
class="flex w-full rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>{{ $t("library.admin.import.version.installDir") }}</span
>
<PreloadSelector
:value="launch.launchCommand"
:guesses="versionGuesses"
@update="(v) => updateLaunchCommand(launchIdx, v)"
/>
<input
id="startup"
v-model="launch.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>
<button
class="transition bg-zinc-800 rounded-sm aspect-square p-1 text-zinc-600 hover:text-red-600 hover:bg-red-600/20"
@click="() => versionSettings.launches!.splice(launchIdx, 1)"
>
<TrashIcon class="size-5" />
</button>
</div> </div>
<p
v-if="versionSettings.launches!.length == 0"
class="uppercase font-display font-bold text-zinc-500 text-xs"
>
No launch commands
</p>
<LoadingButton
:loading="false"
class="inline-flex items-center gap-x-4"
@click="
() =>
versionSettings.launches!.push({
name: '',
description: '',
launchCommand: '',
launchArgs: '',
})
"
>
Add new <PlusIcon class="size-5" />
</LoadingButton>
</div> </div>
<div <div
v-if="versionSettings.onlySetup" v-if="versionSettings.onlySetup"
class="absolute inset-0 bg-zinc-900/50" class="absolute inset-0 bg-zinc-900/50"
/> />
</div> </div>
<!-- uninstall command -->
<div class="max-w-lg">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>Uninstall command</label
>
<p class="text-zinc-400 text-xs">
Executable to be run on uninstalling a game. Useful for installer-only games.
</p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>
{{ $t("library.admin.import.version.installDir") }}
</span>
<PreloadSelector
:value="versionSettings.uninstall"
:guesses="versionGuesses"
@update="(v) => updateUninstallCommand(v)"
/>
<input
id="startup"
v-model="versionSettings.uninstallArgs"
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="--uninstall"
/>
</div>
</div>
</div>
<PlatformSelector v-model="versionSettings.platform"> <PlatformSelector
v-model="versionSettings.platform"
class="max-w-lg"
:platforms="allPlatforms"
>
{{ $t("library.admin.import.version.platform") }} {{ $t("library.admin.import.version.platform") }}
</PlatformSelector> </PlatformSelector>
<SwitchGroup as="div" class="flex items-center justify-between"> <SwitchGroup as="div" class="flex items-center justify-between max-w-lg">
<span class="flex flex-grow flex-col"> <span class="flex flex-grow flex-col">
<SwitchLabel <SwitchLabel
as="span" as="span"
@ -398,7 +316,7 @@
/> />
</Switch> </Switch>
</SwitchGroup> </SwitchGroup>
<Disclosure v-slot="{ open }" as="div" class="py-2"> <Disclosure v-slot="{ open }" as="div" class="py-2 max-w-lg">
<dt> <dt>
<DisclosureButton <DisclosureButton
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100" class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
@ -418,7 +336,7 @@
> >
<!-- UMU launcher configuration --> <!-- UMU launcher configuration -->
<div <div
v-if="versionSettings.platform == PlatformClient.Windows" v-if="versionSettings.platform == 'Linux'"
class="flex flex-col gap-y-4" class="flex flex-col gap-y-4"
> >
<SwitchGroup as="div" class="flex items-center justify-between"> <SwitchGroup as="div" class="flex items-center justify-between">
@ -467,7 +385,7 @@
required required
:disabled="!umuIdEnabled" :disabled="!umuIdEnabled"
placeholder="umu-starcitizen" 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" 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-800 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> </div>
</div> </div>
@ -539,15 +457,13 @@ import {
Disclosure, Disclosure,
DisclosureButton, DisclosureButton,
DisclosurePanel, DisclosurePanel,
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue"; } from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid"; import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { PlusIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid"; import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
@ -560,52 +476,32 @@ const gameId = route.params.id.toString();
const versions = await $dropFetch( const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`, `/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
); );
const userPlatforms = await useAdminPlatforms();
const allPlatforms = renderPlatforms(userPlatforms);
const currentlySelectedVersion = ref(-1); const currentlySelectedVersion = ref(-1);
const versionSettings = ref<{ const versionSettings = ref<Partial<typeof ImportVersion.infer>>({
platform: PlatformClient | undefined; id: gameId,
launches: [],
onlySetup: boolean;
launch: string;
launchArgs: string;
setup: string;
setupArgs: string;
delta: boolean;
umuId: string;
}>({
platform: undefined,
launch: "",
launchArgs: "",
setup: "",
setupArgs: "",
delta: false,
onlySetup: false,
umuId: "",
}); });
const versionGuesses = const versionGuesses =
ref<Array<{ platform: PlatformClient; filename: string }>>(); ref<
const launchProcessQuery = ref(""); Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
const setupProcessQuery = ref(""); >();
const launchFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
),
);
const setupFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase()),
),
);
function updateLaunchCommand(value: string) { function updateLaunchCommand(idx: number, value: string) {
versionSettings.value.launch = value; versionSettings.value.launches![idx].launchCommand = value;
autosetPlatform(value); autosetPlatform(value);
} }
function updateSetupCommand(value: string) { function updateInstallCommand(value: string) {
versionSettings.value.setup = value; versionSettings.value.install = value;
autosetPlatform(value);
}
function updateUninstallCommand(value: string) {
versionSettings.value.uninstall = value;
autosetPlatform(value); autosetPlatform(value);
} }
@ -616,7 +512,8 @@ function autosetPlatform(value: string) {
(e) => e.filename === value, (e) => e.filename === value,
); );
if (guessIndex == -1) return; if (guessIndex == -1) return;
versionSettings.value.platform = versionGuesses.value[guessIndex].platform; versionSettings.value.platform =
versionGuesses.value[guessIndex].platform.param;
} }
const umuIdEnabled = ref(false); const umuIdEnabled = ref(false);
@ -639,15 +536,16 @@ async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return; if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value; currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value]; const version = versions[currentlySelectedVersion.value];
const results = await $dropFetch( const options = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent( `/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId, gameId,
)}&version=${encodeURIComponent(version)}`, )}&version=${encodeURIComponent(version)}`,
); );
versionGuesses.value = results.map((e) => ({ versionGuesses.value = options.map((e) => ({
...e, ...e,
platform: e.platform as PlatformClient, platform: allPlatforms.find((v) => v.param === e.platform)!,
})); }));
versionSettings.value.name = version;
} }
async function startImport() { async function startImport() {
@ -667,7 +565,7 @@ function startImport_wrapper() {
importLoading.value = true; importLoading.value = true;
startImport() startImport()
.catch((error) => { .catch((error) => {
importError.value = error.statusMessage ?? t("errors.unknown"); importError.value = error.message ?? t("errors.unknown");
}) })
.finally(() => { .finally(() => {
importLoading.value = false; importLoading.value = false;

View File

@ -0,0 +1,101 @@
<template>
<div
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
>
<div
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
>
<!--start-->
<div>
<div class="pt-4 inline-flex gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
>
<button
:class="[
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
value == currentMode
? 'bg-zinc-900 text-zinc-100'
: 'bg-transparent text-zinc-500',
]"
@click="() => (currentMode = value as GameEditorMode)"
>
<component :is="icon" class="size-4" />
{{ value }}
</button>
</div>
</div>
</div>
<div>
<!-- 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"
>
{{ $t("library.admin.openStore") }}
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
</div>
</div>
<component
:is="components[currentMode].editor"
v-model="game"
:unimported-versions="unimportedVersions"
/>
</div>
</template>
<script setup lang="ts">
import { GameEditorMetadata, GameEditorVersion } from "#components";
import {
ArrowTopRightOnSquareIcon,
DocumentIcon,
ServerStackIcon,
} from "@heroicons/vue/24/outline";
import type { Component } from "vue";
const route = useRoute();
const gameId = route.params.id.toString();
const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game/:id`,
{ params: { id: gameId } },
);
const game = ref(rawGame);
definePageMeta({
layout: "admin",
});
enum GameEditorMode {
Metadata = "Metadata",
Versions = "Versions",
}
const components: {
[key in GameEditorMode]: { editor: Component; icon: Component };
} = {
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
[GameEditorMode.Versions]: {
editor: GameEditorVersion,
icon: ServerStackIcon,
},
};
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
useHead({
title: `${currentMode.value} - ${game.value.mName}`,
});
watch(currentMode, (v) => {
useHead({
title: `${v} - ${game.value.mName}`,
});
});
</script>

View File

@ -3,7 +3,7 @@
<Listbox <Listbox
as="div" as="div"
:model="currentlySelectedGame" :model="currentlySelectedGame"
@update:model-value="(value) => updateSelectedGame_wrapper(value)" @update:model-value="(value) => updateSelectedGame(value)"
> >
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"> <ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.import.selectGame") }} {{ $t("library.admin.import.selectGame") }}
@ -12,9 +12,15 @@
<ListboxButton <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">{{ <span v-if="currentlySelectedGame != -1" class="block truncate"
games.unimportedGames[currentlySelectedGame].game >{{ games.unimportedGames[currentlySelectedGame].game }}
}}</span> <span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{
games.unimportedGames[currentlySelectedGame].library.name
}}</span
></span
>
<span v-else class="block truncate text-zinc-400">{{ <span v-else class="block truncate text-zinc-400">{{
$t("library.admin.import.selectDir") $t("library.admin.import.selectDir")
}}</span> }}</span>
@ -37,9 +43,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-zinc-800 focus:outline-none sm:text-sm" 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 <ListboxOption
v-for="({ game }, gameIdx) in games.unimportedGames" v-for="({ game, library }, gameIdx) in games.unimportedGames"
:key="game" :key="game"
v-slot="{ active, selected }" v-slot="{ active }"
as="template" as="template"
:value="gameIdx" :value="gameIdx"
> >
@ -51,14 +57,20 @@
> >
<span <span
:class="[ :class="[
selected ? 'font-semibold' : 'font-normal', gameIdx === currentlySelectedGame
'block truncate', ? 'font-semibold'
: 'font-normal',
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
]" ]"
>{{ game }}</span >{{ game }}
<span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{ library.name }}</span
></span
> >
<span <span
v-if="selected" v-if="gameIdx === currentlySelectedGame"
:class="[ :class="[
active ? 'text-white' : 'text-blue-600', active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4', 'absolute inset-y-0 right-0 flex items-center pr-4',
@ -68,197 +80,135 @@
</span> </span>
</li> </li>
</ListboxOption> </ListboxOption>
<div
v-if="games.unimportedGames.length == 0"
class="w-full uppercase font-display font-bold text-zinc-600 p-2 text-center"
>
Nothing to import
</div>
</ListboxOptions>
</transition>
</div>
</Listbox>
<Listbox v-model="currentImportMode" as="div">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
Import as
</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative inline-flex items-center 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 class="inline-flex items-top gap-x-2">
<component
:is="importModes[currentImportMode].icon"
class="text-blue-600 size-8 p-1 bg-zinc-800 rounded-sm mt-1"
/>
<div>
<h1 class="text-sm font-bold text-zinc-200">
{{ importModes[currentImportMode].name }}
</h1>
<p class="text-xs text-zinc-400 max-w-xs">
{{ importModes[currentImportMode].description }}
</p>
</div>
</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"
>
<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
v-for="[mode, metadata] in Object.entries(importModes)"
:key="mode"
v-slot="{ active }"
as="template"
:value="mode"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
mode == currentImportMode ? 'font-semibold' : 'font-normal',
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
]"
>{{ metadata.name }}
<span
v-if="mode == currentImportMode"
: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>
</span>
</li>
</ListboxOption>
</ListboxOptions> </ListboxOptions>
</transition> </transition>
</div> </div>
</Listbox> </Listbox>
<div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4"> <div class="flex items-center justify-between gap-x-8">
<!-- without metadata option --> <span class="flex grow flex-col">
<div> <label
<LoadingButton id="bulkImport-label"
class="w-fit" class="text-sm/6 font-medium text-zinc-100"
:loading="importLoading" >{{ $t("library.admin.import.bulkImportTitle") }}</label
@click="() => importGame_wrapper(false)"
> >
{{ $t("library.admin.import.withoutMetadata") }} <span id="bulkImport-description" class="text-sm text-zinc-400">{{
</LoadingButton> $t("library.admin.import.bulkImportDescription")
</div> }}</span>
</span>
<!-- divider -->
<div <div
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold" class="group relative inline-flex w-11 shrink-0 rounded-full bg-zinc-800 p-0.5 inset-ring inset-ring-zinc-100/5 outline-offset-2 outline-blue-600 transition-colors duration-200 ease-in-out has-checked:bg-blue-600 has-focus-visible:outline-2"
> >
<div class="h-[1px] grow bg-zinc-800" /> <span
{{ $t("auth.signin.or") }} class="size-5 rounded-full bg-white shadow-xs ring-1 ring-zinc-100/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5"
<div class="h-[1px] grow bg-zinc-800" /> />
</div> <input
id="bulkImport"
<!-- with metadata option --> v-model="bulkImportMode"
type="checkbox"
<div class="flex flex-col gap-y-4"> class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
<form @submit.prevent="() => searchGame()"> name="bulkImport"
<label aria-labelledby="bulkImport-label"
for="searchTerm" aria-describedby="bulkImport-description"
class="block text-sm/6 font-medium text-zinc-100" />
>{{ $t("library.admin.import.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="$t('library.admin.import.searchPlaceholder')"
/>
</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"
/>
{{ $t("library.admin.import.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"
>{{ $t("library.admin.import.selectGameSearch") }}</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">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</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"
>
<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
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>
</ListboxOptions>
</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"
>
{{ $t("library.admin.import.loading") }}
<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"
/>
<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>
</div>
<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" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ gameSearchResultsError }}
</h3>
</div>
</div>
</div>
<div>
<LoadingButton
class="w-fit"
:loading="importLoading"
:disabled="currentlySelectedMetadata === -1"
@click="() => importGame_wrapper()"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<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" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ importError }}
</h3>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<component
:is="importModes[currentImportMode].component"
v-if="currentlySelectedGame !== -1"
:game-name="games.unimportedGames[currentlySelectedGame].game"
:loading="importLoading"
:error="importError"
@import="(...v: unknown[]) => importModes[currentImportMode].import(...v)"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ImportGame, ImportRedist } from "#components";
import { import {
Listbox, Listbox,
ListboxButton, ListboxButton,
@ -266,94 +216,142 @@ import {
ListboxOption, ListboxOption,
ListboxOptions, ListboxOptions,
} from "@headlessui/vue"; } from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline"; import { PuzzlePieceIcon, ArchiveBoxIcon } from "@heroicons/vue/24/solid";
import type { FetchError } from "ofetch";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
}); });
type ImportMode = "Game" | "Redist";
const importModes = shallowRef<{
[key in ImportMode]: {
name: string;
description: string;
icon: Component;
component: Component;
import: (...v: unknown[]) => void;
};
}>({
Game: {
name: "Game",
description: "Games can be added to user libraries, installed, and played.",
icon: PuzzlePieceIcon,
component: ImportGame,
import: importGame_wrapper as (v: unknown) => void,
},
Redist: {
name: "Redistributable",
description:
"Redistributables are packaged dependencies for games, that are installed alongside and required to play certain games.",
icon: ArchiveBoxIcon,
component: ImportRedist,
import: importRedist as (v: unknown, k: unknown) => void,
},
});
const currentImportMode = ref<ImportMode>("Game");
const { t } = useI18n(); const { t } = useI18n();
const games = await $dropFetch("/api/v1/admin/import/game"); const rawGames = await $dropFetch("/api/v1/admin/import/game");
const games = ref(rawGames);
const currentlySelectedGame = ref(-1); const currentlySelectedGame = ref(-1);
const gameSearchResultsLoading = ref(false); const bulkImportMode = ref(false);
const gameSearchResultsError = ref<string | undefined>();
const gameSearchTerm = ref("");
const gameSearchLoading = ref(false);
async function updateSelectedGame(value: number) { async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value) return; if (currentlySelectedGame.value == value || value == -1) return;
currentlySelectedGame.value = value; const option = games.value.unimportedGames[value];
if (currentlySelectedGame.value == -1) return;
const option = games.unimportedGames[currentlySelectedGame.value];
if (!option) return; if (!option) return;
metadataResults.value = undefined; currentlySelectedGame.value = value;
currentlySelectedMetadata.value = -1;
gameSearchTerm.value = option.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 || t("errors.unknown");
})
.finally(() => {
gameSearchResultsLoading.value = false;
});
}
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
const currentlySelectedMetadata = ref(-1);
const router = useRouter(); const router = useRouter();
const importLoading = ref(false); const importLoading = ref(false);
const importError = ref<string | undefined>(); const importError = ref<string | undefined>();
async function importGame(useMetadata: boolean) { async function importGame(metadata: GameMetadataSearchResult | undefined) {
if (!metadataResults.value && useMetadata) return; const option = games.value.unimportedGames[currentlySelectedGame.value];
const metadata =
useMetadata && metadataResults.value
? metadataResults.value[currentlySelectedMetadata.value]
: undefined;
const option = games.unimportedGames[currentlySelectedGame.value];
const { taskId } = await $dropFetch("/api/v1/admin/import/game", { const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
method: "POST", method: "POST",
body: { body: {
path: option.game, path: option.game,
library: option.library, library: option.library.id,
metadata, metadata,
}, },
}); });
router.push(`/admin/task/${taskId}`); if (!bulkImportMode.value) {
router.push(`/admin/task/${taskId}`);
} else {
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
currentlySelectedGame.value = -1;
}
} }
function importGame_wrapper(metadata = true) { async function importGame_wrapper(
metadata: GameMetadataSearchResult | undefined,
) {
importLoading.value = true; importLoading.value = true;
importError.value = undefined; importError.value = undefined;
importGame(metadata) try {
.catch((error) => { await importGame(metadata);
importError.value = error?.statusMessage || t("errors.unknown"); } catch (error) {
}) console.warn(error);
.finally(() => { importError.value =
importLoading.value = false; (error as FetchError)?.message || t("errors.unknown");
}
importLoading.value = false;
}
async function importRedist(data: object, platform: object | undefined) {
importLoading.value = true;
importError.value = undefined;
try {
const option = games.value.unimportedGames[currentlySelectedGame.value];
const formData = new FormData();
for (const [key, value] of Object.entries(data)) {
formData.append(
key,
value,
);
}
if (platform) {
for (const [key, value] of Object.entries(platform)) {
// Because we know there will be no file, and we need to handle more complex objects for
// the platform, we do this.
// Maybe we shouldn't.
formData.append(
`platform.${key}`,
typeof value === "object" ? JSON.stringify(value) : value,
);
}
}
formData.append("library", option.library.id);
formData.append("path", option.game);
const redist = await $dropFetch("/api/v1/admin/import/redist", {
body: formData,
method: "POST",
}); });
if (!bulkImportMode.value) {
router.push(`/admin/library/r/${redist.id}`);
} else {
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
currentlySelectedGame.value = -1;
}
} catch (error) {
console.warn(error);
importError.value =
(error as FetchError)?.message || t("errors.unknown");
}
importLoading.value = false;
} }
</script> </script>

View File

@ -71,33 +71,86 @@
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
<div class="flex gap-x-4 text-zinc-300 font-bold uppercase font-display text-sm">
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-blue-600" />
Game</span
>
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-emerald-600" />
Redistributable</span
>
</div>
<ul <ul
role="list" role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4" class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
> >
<li <li
v-for="game in filteredLibraryGames" v-for="entry in filteredLibrary"
:key="game.id" :key="entry.id"
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border border-zinc-800 transition-all duration-200 hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 group" class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
> >
<div class="flex flex-1 flex-row p-4 gap-x-4"> <div
v-if="entry.type === 'game'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div
class="absolute top-0 right-0 w-10 bg-blue-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img <img
class="h-20 w-20 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800" class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(game.mIconObjectId)" :src="useObject(entry.mIconObjectId)"
alt="" alt=""
/> />
<div class="flex flex-col"> <div class="flex flex-col">
<h3 class="text-sm font-medium text-zinc-100 font-display"> <h3
{{ game.mName }} class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ entry.mName }}
<button
type="button"
:class="[
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
entry.featured
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
]"
@click="() => featureGame(entry.id)"
>
<svg
v-if="gameFeatureLoading[entry.id]"
aria-hidden="true"
:class="[
entry.featured ? ' fill-zinc-900' : 'fill-zinc-100',
'size-3 text-transparent animate-spin',
]"
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>
<StarIcon v-else class="size-3" aria-hidden="true" />
</button>
<span <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" class="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 >{{ entry.library.name }}</span
> >
</h3> </h3>
<dl class="mt-1 flex flex-col justify-between"> <dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt> <dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400"> <dd class="text-sm text-zinc-400">
{{ game.mShortDescription }} {{ entry.mShortDescription }}
</dd> </dd>
<dt class="sr-only"> <dt class="sr-only">
{{ $t("library.admin.metadataProvider") }} {{ $t("library.admin.metadataProvider") }}
@ -105,7 +158,7 @@
</dl> </dl>
<div class="mt-4 flex flex-col gap-y-1"> <div class="mt-4 flex flex-col gap-y-1">
<NuxtLink <NuxtLink
:href="`/admin/library/${game.id}`" :href="`/admin/library/g/${entry.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" 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"
> >
<i18n-t <i18n-t
@ -120,16 +173,79 @@
</NuxtLink> </NuxtLink>
<button <button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteGame(game.id)" @click="() => deleteGame(entry.id)"
> >
{{ $t("delete") }} {{ $t("delete") }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div v-if="game.hasNotifications" class="flex flex-col gap-y-2 p-2"> <div
v-else-if="entry.type === 'redist'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div <div
v-if="game.notifications.toImport" class="absolute top-0 right-0 w-10 bg-emerald-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(entry.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ entry.mName }}
<span
class="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"
>{{ entry.library.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ entry.mShortDescription }}
</dd>
</dl>
<dl
v-if="entry.platform"
class="mt-2 flex items-center text-zinc-200 font-semibold text-sm gap-x-1 p-2 bg-zinc-800 rounded-xl"
>
<IconsPlatform
:platform="entry.platform.id"
:fallback="entry.platform.iconSvg"
class="size-6 text-blue-600"
/>
<span>{{ entry.platform.platformName }}</span>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/r/${entry.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"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteRedist(entry.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
<div v-if="entry.hasNotifications" class="flex flex-col gap-y-2 p-2">
<div
v-if="entry.notifications.toImport"
class="rounded-md bg-blue-600/10 p-4" class="rounded-md bg-blue-600/10 p-4"
> >
<div class="flex"> <div class="flex">
@ -145,7 +261,7 @@
</p> </p>
<p class="mt-3 text-sm md:ml-6 md:mt-0"> <p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink <NuxtLink
:href="`/admin/library/${game.id}/import`" :href="`/admin/library/g/${entry.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500" class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
> >
<i18n-t <i18n-t
@ -163,7 +279,7 @@
</div> </div>
</div> </div>
<div <div
v-if="game.notifications.noVersions" v-if="entry.notifications.noVersions"
class="rounded-md bg-yellow-600/10 p-4" class="rounded-md bg-yellow-600/10 p-4"
> >
<div class="flex"> <div class="flex">
@ -180,27 +296,81 @@
</div> </div>
</div> </div>
</div> </div>
<div
v-if="entry.notifications.offline"
class="rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<ExclamationCircleIcon
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">
{{ $t("library.admin.offline") }}
</h3>
</div>
</div>
</div>
</div> </div>
</li> </li>
<p <p
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0" v-if="filteredLibrary.length == 0 && libraryGames.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4" class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
> >
{{ $t("common.noResults") }} {{ $t("common.noResults") }}
</p> </p>
<p <p
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0" v-if="
filteredLibrary.length == 0 &&
libraryGames.length == 0 &&
libraryState.hasLibraries
"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4" class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
> >
{{ $t("library.admin.noGames") }} {{ $t("library.admin.noGames") }}
</p> </p>
<p
v-else-if="!libraryState.hasLibraries"
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
>
<span class="text-sm font-display font-bold uppercase">{{
$t("library.admin.libraryHint")
}}</span>
<NuxtLink
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
href="https://docs.droposs.org/docs/library"
target="_blank"
>
<i18n-t
keypath="library.admin.libraryHintDocsLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</p>
</ul> </ul>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid"; import {
import { InformationCircleIcon } from "@heroicons/vue/20/solid"; ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import {
ArrowTopRightOnSquareIcon,
InformationCircleIcon,
StarIcon,
} from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n(); const { t } = useI18n();
@ -217,32 +387,65 @@ const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library"); const libraryState = await $dropFetch("/api/v1/admin/library");
console.log(Object.values(libraryState.unimportedGames));
const toImport = ref( const toImport = ref(
Object.values(libraryState.unimportedGames).flat().length > 0, Object.values(libraryState.unimportedGames).flat().length > 0,
); );
const libraryGames = ref( // Potentially make a server-side transformation to make the client lighter
libraryState.games.map((e) => { function clientSideTransformation<T, V extends keyof T, K extends string>(
values: Array<T & { status: (typeof libraryState.games)[number]["status"] }>,
expand: V,
type: K,
): Array<
T[V] & {
status: "online" | "offline";
type: K;
hasNotifications?: boolean;
notifications: {
noVersions?: boolean;
toImport?: boolean;
offline?: boolean;
};
}
> {
return values.map((e) => {
if (e.status == "offline") {
return {
...e[expand],
type: type,
status: "offline" as const,
hasNotifications: true,
notifications: {
offline: true,
},
};
}
const noVersions = e.status.noVersions; const noVersions = e.status.noVersions;
const toImport = e.status.unimportedVersions.length > 0; const toImport = e.status.unimportedVersions.length > 0;
return { return {
...e.game, ...e[expand],
type: type,
notifications: { notifications: {
noVersions, noVersions,
toImport, toImport,
}, },
hasNotifications: noVersions || toImport, hasNotifications: noVersions || toImport,
status: "online" as const,
}; };
}), });
}
const libraryGames = ref(
clientSideTransformation(libraryState.games, "value", "game"),
);
const libraryRedists = ref(
clientSideTransformation(libraryState.redists, "value", "redist"),
); );
const filteredLibraryGames = computed(() => const filteredLibrary = computed(() =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment [...libraryGames.value, ...libraryRedists.value].filter((e) => {
// @ts-ignore excessively deep ts
libraryGames.value.filter((e) => {
if (!searchQuery.value) return true; if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase(); const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true; if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
@ -253,9 +456,41 @@ const filteredLibraryGames = computed(() =>
); );
async function deleteGame(id: string) { async function deleteGame(id: string) {
await $dropFetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" }); await $dropFetch(`/api/v1/admin/game/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryGames.value.findIndex((e) => e.id === id); const index = libraryGames.value.findIndex((e) => e.id === id);
libraryGames.value.splice(index, 1); libraryGames.value.splice(index, 1);
toImport.value = true; toImport.value = true;
} }
async function deleteRedist(id: string) {
await $dropFetch(`/api/v1/admin/redist/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryRedists.value.findIndex((e) => e.id === id);
libraryRedists.value.splice(index, 1);
toImport.value = true;
}
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
async function featureGame(id: string) {
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);
const game = libraryGames.value[gameIndex];
gameFeatureLoading.value[game.id] = true;
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.id,
},
body: { featured: !game.featured },
failTitle: "Failed to feature/unfeature game",
});
libraryGames.value[gameIndex].featured = !game.featured;
gameFeatureLoading.value[game.id] = false;
}
</script> </script>

View File

@ -0,0 +1,85 @@
<template>
<div
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
>
<div
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
>
<!--start-->
<div>
<div class="pt-4 inline-flex gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
>
<button
:class="[
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
value == currentMode
? 'bg-zinc-900 text-zinc-100'
: 'bg-transparent text-zinc-500',
]"
@click="() => (currentMode = value as RedistEditorMode)"
>
<component :is="icon" class="size-4" />
{{ value }}
</button>
</div>
</div>
</div>
<div>
<!-- open in store button -->
</div>
</div>
<component
:is="components[currentMode].editor"
v-model="redist"
:unimported-versions="unimportedVersions"
/>
</div>
</template>
<script setup lang="ts">
import { GameEditorVersion, RedistEditorMetadata } from "#components";
import { DocumentIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
import type { Component } from "vue";
const route = useRoute();
const redistId = route.params.id.toString();
const { redist: rawRedist, unimportedVersions } = await $dropFetch(
`/api/v1/admin/redist/:id`,
{ params: { id: redistId } },
);
const redist = ref(rawRedist);
definePageMeta({
layout: "admin",
});
enum RedistEditorMode {
Metadata = "Metadata",
Versions = "Versions",
}
const components: {
[key in RedistEditorMode]: { editor: Component; icon: Component };
} = {
[RedistEditorMode.Metadata]: { editor: RedistEditorMetadata, icon: DocumentIcon },
[RedistEditorMode.Versions]: {
editor: GameEditorVersion,
icon: ServerStackIcon,
},
};
const currentMode = ref<RedistEditorMode>(RedistEditorMode.Metadata);
useHead({
title: `${currentMode.value} - ${redist.value.mName}`,
});
watch(currentMode, (v) => {
useHead({
title: `${v} - ${redist.value.mName}`,
});
});
</script>

View File

@ -14,7 +14,7 @@
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" 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"
@click="() => (actionSourceOpen = true)" @click="() => (actionSourceOpen = true)"
> >
{{ $t("create") }} {{ $t("common.create") }}
</button> </button>
</div> </div>
</div> </div>
@ -28,7 +28,7 @@
scope="col" scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
> >
{{ $t("name") }} {{ $t("common.name") }}
</th> </th>
<th <th
scope="col" scope="col"
@ -49,7 +49,7 @@
{{ $t("options") }} {{ $t("options") }}
</th> </th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3"> <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">{{ $t("edit") }}</span> <span class="sr-only">{{ $t("common.edit") }}</span>
</th> </th>
</tr> </tr>
</thead> </thead>
@ -64,8 +64,14 @@
> >
{{ source.name }} {{ source.name }}
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400"> <td
{{ source.backend }} class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/>
{{ optionsMetadata[source.backend].title }}
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400"> <td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon <CheckIcon
@ -84,7 +90,7 @@
class="text-blue-500 hover:text-blue-400" class="text-blue-500 hover:text-blue-400"
@click="() => edit(sourceIdx)" @click="() => edit(sourceIdx)"
> >
{{ $t("edit") }} {{ $t("common.edit") }}
<span class="sr-only"> <span class="sr-only">
{{ $t("chars.srComma", [source.name]) }} {{ $t("chars.srComma", [source.name]) }}
</span> </span>
@ -110,9 +116,20 @@
<ModalTemplate v-model="actionSourceOpen"> <ModalTemplate v-model="actionSourceOpen">
<template #default> <template #default>
<div> <div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white"> <DialogTitle
v-if="createMode"
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.create") }} {{ $t("library.admin.sources.create") }}
</DialogTitle> </DialogTitle>
<DialogTitle
v-else
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.edit") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm"> <p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.sources.createDesc") }} {{ $t("library.admin.sources.createDesc") }}
</p> </p>
@ -125,7 +142,7 @@
<label <label
for="name" for="name"
class="block text-sm font-medium leading-6 text-zinc-100" class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("name") }}</label >{{ $t("common.name") }}</label
> >
<p class="text-zinc-400 block text-xs font-medium leading-6"> <p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.nameDesc") }} {{ $t("library.admin.sources.nameDesc") }}
@ -167,7 +184,7 @@
'relative block cursor-pointer bg-zinc-800 rounded-lg border border-zinc-900 px-2 py-2 shadow-sm focus:outline-none sm:flex sm:justify-between', 'relative block cursor-pointer bg-zinc-800 rounded-lg border border-zinc-900 px-2 py-2 shadow-sm focus:outline-none sm:flex sm:justify-between',
]" ]"
> >
<span class="flex items-center gap-x-2"> <span class="flex items-center gap-x-4">
<div> <div>
<component <component
:is="metadata.icon" :is="metadata.icon"
@ -178,11 +195,34 @@
<RadioGroupLabel <RadioGroupLabel
as="span" as="span"
class="font-semibold text-zinc-100" class="font-semibold text-zinc-100"
>{{ source }}</RadioGroupLabel >{{ metadata.title }}
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
source
}}</span>
</RadioGroupLabel>
<RadioGroupDescription
as="span"
class="text-zinc-400 text-xs"
> >
<RadioGroupDescription as="span" class="text-zinc-400">
{{ metadata.description }} {{ metadata.description }}
</RadioGroupDescription> </RadioGroupDescription>
<NuxtLink
:href="metadata.docsLink"
:external="true"
target="_blank"
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs 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"
>
<i18n-t
keypath="library.admin.sources.documentationLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</span> </span>
</span> </span>
<span <span
@ -228,7 +268,7 @@
class="w-full sm:w-fit" class="w-full sm:w-fit"
@click="() => performActionSource_wrapper()" @click="() => performActionSource_wrapper()"
> >
{{ createMode ? $t("create") : $t("save") }} {{ createMode ? $t("common.create") : $t("common.save") }}
</LoadingButton> </LoadingButton>
<button <button
ref="cancelButtonRef" ref="cancelButtonRef"
@ -257,7 +297,11 @@
* between 'create' and 'edit' * between 'create' and 'edit'
*/ */
import { SourceOptionsFilesystem } from "#components"; import {
DropLogo,
SourceOptionsFilesystem,
SourceOptionsFlatFilesystem,
} from "#components";
import { import {
DialogTitle, DialogTitle,
RadioGroup, RadioGroup,
@ -265,21 +309,35 @@ import {
RadioGroupLabel, RadioGroupLabel,
RadioGroupOption, RadioGroupOption,
} from "@headlessui/vue"; } from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/20/solid"; import {
import { CheckIcon, DocumentIcon, XMarkIcon } from "@heroicons/vue/24/outline"; XCircleIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/vue/20/solid";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { FetchError } from "ofetch"; import { FetchError } from "ofetch";
import type { Component } from "vue"; import type { Component } from "vue";
import type { LibraryBackend } from "~/prisma/client"; import type { LibraryBackend } from "~/prisma/client/enums";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get"; import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
}); });
useHead({
title: "Library Sources",
});
const { t } = useI18n(); const { t } = useI18n();
// Optional token for setup wizard
const { token = undefined } = defineProps<{ token?: string }>();
const headers = token ? { Authorization: token } : undefined;
const sources = ref( const sources = ref(
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources"), await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources", {
headers,
}),
); );
const editIndex = ref<undefined | number>(undefined); const editIndex = ref<undefined | number>(undefined);
@ -295,16 +353,27 @@ const modalLoading = ref(false);
const optionUIs: { [key in LibraryBackend]: Component } = { const optionUIs: { [key in LibraryBackend]: Component } = {
Filesystem: SourceOptionsFilesystem, Filesystem: SourceOptionsFilesystem,
FlatFilesystem: SourceOptionsFlatFilesystem,
}; };
const optionsMetadata: { const optionsMetadata: {
[key in LibraryBackend]: { [key in LibraryBackend]: {
title: string;
description: string; description: string;
docsLink: string;
icon: Component; icon: Component;
}; };
} = { } = {
Filesystem: { Filesystem: {
title: t("library.admin.sources.fsTitle"),
description: t("library.admin.sources.fsDesc"), description: t("library.admin.sources.fsDesc"),
icon: DocumentIcon, docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
}, },
}; };
const optionsMetadataIter = Object.entries(optionsMetadata); const optionsMetadataIter = Object.entries(optionsMetadata);
@ -322,6 +391,7 @@ async function performActionSource() {
options: sourceConfig.value, options: sourceConfig.value,
}, },
method: createMode ? "POST" : "PATCH", method: createMode ? "POST" : "PATCH",
headers,
}, },
); );
if (createMode) { if (createMode) {
@ -335,18 +405,21 @@ function performActionSource_wrapper() {
modalError.value = undefined; modalError.value = undefined;
modalLoading.value = true; modalLoading.value = true;
performActionSource() performActionSource()
.then(() => { .then(
actionSourceOpen.value = false; () => {
sourceConfig.value = {}; actionSourceOpen.value = false;
sourceName.value = ""; sourceConfig.value = {};
}) sourceName.value = "";
.catch((e) => { },
if (e instanceof FetchError) { (e) => {
modalError.value = e.statusMessage ?? e.message; if (e instanceof FetchError) {
} else { console.log(e.data.message);
modalError.value = e as string; modalError.value = e.message;
} } else {
}) modalError.value = e as string;
}
},
)
.finally(() => { .finally(() => {
modalLoading.value = false; modalLoading.value = false;
}); });
@ -371,6 +444,7 @@ async function deleteSource(index: number) {
await $dropFetch("/api/v1/admin/library/sources", { await $dropFetch("/api/v1/admin/library/sources", {
method: "DELETE", method: "DELETE",
body: { id: source.id }, body: { id: source.id },
headers,
}); });
} catch (e) { } catch (e) {
createModal( createModal(
@ -378,8 +452,8 @@ async function deleteSource(index: number) {
{ {
title: t("errors.library.source.delete.title"), title: t("errors.library.source.delete.title"),
description: t("errors.library.source.delete.desc", [ description: t("errors.library.source.delete.desc", [
// @ts-expect-error attempt to display statusMessage on error // @ts-expect-error attempt to display message on error
e?.statusMessage ?? t("errors.unknown"), e?.message ?? t("errors.unknown"),
]), ]),
}, },
(_, c) => c(), (_, c) => c(),

View File

@ -0,0 +1,422 @@
<template>
<div class="space-y-4">
<div
class="relative overflow-hidden rounded-lg flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2 p-8"
>
<img
:src="useObject(company.mBannerObjectId)"
class="absolute inset-0 w-full h-full object-cover object-center"
/>
<div class="absolute inset-0 bg-zinc-900/80" />
<div class="relative inline-flex items-center gap-4">
<!-- icon image -->
<div class="relative group/iconupload rounded-xl overflow-hidden">
<img :src="useObject(company.mLogoObjectId)" class="size-20" />
<button
class="rounded-xl transition duration-200 absolute inset-0 opacity-0 group-hover/iconupload:opacity-100 focus-visible/iconupload:opacity-100 cursor-pointer bg-zinc-900/80 text-zinc-100 flex flex-col items-center justify-center text-center text-xs font-semibold ring-1 ring-inset ring-zinc-800 px-2"
@click="() => (uploadLogoOpen = true)"
>
<ArrowUpTrayIcon class="size-5" />
<span>{{
$t("library.admin.metadata.companies.editor.uploadIcon")
}}</span>
</button>
</div>
<div class="flex flex-col">
<h1
class="group/name inline-flex items-center gap-x-3 text-5xl font-bold font-display text-zinc-100"
>
{{ company.mName }}
<button @click="() => editName()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/name:opacity-100 size-8"
/>
</button>
</h1>
<p
class="group/description mt-1 inline-flex items-center gap-x-3 text-lg text-zinc-400 max-w-xl"
>
{{
company.mShortDescription ||
$t("library.admin.metadata.companies.editor.noDescription")
}}
<button @click="() => editShortDescription()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/description:opacity-100 size-5"
/>
</button>
</p>
<p
class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3"
>
{{
company.mWebsite ||
$t("library.admin.metadata.companies.editor.websitePlaceholder")
}}
<button @click="() => editWebsite()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/website:opacity-100 size-4"
/>
</button>
</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="() => (uploadBannerOpen = true)"
>
{{ $t("library.admin.metadata.companies.editor.uploadBanner") }}
<ArrowUpTrayIcon class="size-4" />
</button>
</div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.editor.libraryTitle") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.editor.libraryDescription") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (addGameModelOpen = true)"
>
<i18n-t
keypath="library.admin.metadata.companies.editor.action"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #plus>
<PlusIcon class="size-4" />
</template>
</i18n-t>
</button>
</div>
</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="$t('library.admin.metadata.companies.searchGames')"
/>
<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 filteredGames"
:key="game.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border border-zinc-800 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(game.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ game.mName }}
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ game.mShortDescription }}
</dd>
</dl>
<div class="mt-4 flex flex-col gap-y-3">
<div class="flex items-center gap-3">
<div
class="group/published relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-3"
/>
<input
id="published"
v-model="published[game.id]"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="published-label"
/>
</div>
<label
id="published-label"
for="published"
class="font-medium text-xs text-zinc-100"
>{{
$t("library.admin.metadata.companies.editor.published")
}}</label
>
</div>
<div class="flex items-center gap-3">
<div
class="group/developed relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developed:translate-x-3"
/>
<input
id="developed"
v-model="developed[game.id]"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="developed-label"
/>
</div>
<label
id="developed-label"
for="published"
class="font-medium text-xs text-zinc-100"
>{{
$t("library.admin.metadata.companies.editor.developed")
}}</label
>
</div>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => removeGame(game.id)"
>
{{ $t("common.remove") }}
</button>
</div>
</div>
</div>
</li>
<p
v-if="filteredGames.length == 0 && games.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredGames.length == 0 && games.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.metadata.companies.noGames") }}
</p>
</ul>
<ModalAddCompanyGame
v-model="addGameModelOpen"
:exclude="games.map((e) => e.id)"
:company-id="company.id"
@created="appendGame"
/>
<ModalUploadFile
v-model="uploadLogoOpen"
:endpoint="`/api/v1/admin/company/${company.id}/icon`"
accept="image/*"
@upload="updateLogo"
/>
<ModalUploadFile
v-model="uploadBannerOpen"
:endpoint="`/api/v1/admin/company/${company.id}/banner`"
accept="image/*"
@upload="updateBanner"
/>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import { ArrowUpTrayIcon, PencilIcon, PlusIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel } from "~/prisma/client/models";
definePageMeta({
layout: "admin",
});
const route = useRoute();
const companyId = route.params.id!.toString();
const result = await $dropFetch("/api/v1/admin/company/:id", {
params: { id: companyId },
});
const company = ref(result.company);
const games = ref(result.games);
const addGameModelOpen = ref(false);
const uploadLogoOpen = ref(false);
const uploadBannerOpen = ref(false);
const { t } = useI18n();
useHead({
title: `${company.value.mName} - Company`,
});
const searchQuery = ref("");
const filteredGames = computed(() =>
games.value.filter(
(e: SerializeObject<GameModel>) =>
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
e.mShortDescription.includes(searchQuery.value.toLowerCase()),
),
);
function buildToggleProxy(param: "developed" | "published") {
async function tick(id: string, enabled: boolean) {
if (
company.value.developed.length == 0 &&
company.value.published.length == 0
)
return await removeGame(id);
await $dropFetch("/api/v1/admin/company/:id/game", {
method: "PATCH",
params: {
id: company.value.id,
},
body: {
action: param,
enabled,
id,
},
failTitle: `Failed to update ${param} for game`,
});
}
return new Proxy({} as { [key: string]: boolean }, {
get(_target, prop, _reciever) {
return company.value[param].includes(prop.toString());
},
set(_target, prop, value) {
if (typeof value !== "boolean") return false;
const id = prop.toString();
const exists = company.value[param].findIndex((e) => e === id);
if (value && exists == -1) {
company.value[param].push(id);
}
if (!value && exists != -1) {
company.value[param].splice(exists, 1);
}
tick(id, value);
return true;
},
});
}
const published = buildToggleProxy("published");
const developed = buildToggleProxy("developed");
async function removeGame(gameId: string) {
await $dropFetch("/api/v1/admin/company/:id/game", {
params: {
id: company.value.id,
},
body: {
id: gameId,
},
method: "DELETE",
failTitle: "Failed to remove game",
});
const gameIndex = games.value.findIndex((e) => e.id == gameId);
if (gameIndex == -1) return;
games.value.splice(gameIndex, 1);
const publishedIndex = company.value.published.findIndex((e) => e === gameId);
if (publishedIndex != -1) {
company.value.published.splice(publishedIndex, 1);
}
const developedIndex = company.value.developed.findIndex((e) => e === gameId);
if (developedIndex != -1) {
company.value.developed.splice(developedIndex, 1);
}
}
function appendGame(
game: (typeof games.value)[number],
published: boolean,
developed: boolean,
) {
games.value.push(game);
if (published) {
company.value.published.push(game.id);
}
if (developed) {
company.value.developed.push(game.id);
}
}
function buildFieldEditModal(
field: "mName" | "mShortDescription" | "mWebsite",
title: string,
description: string,
) {
function modal() {
createModal(
ModalType.TextInput,
{
title,
description,
dft: company.value[field],
},
async (e, c, s) => {
switch (e) {
case "cancel": {
c();
break;
}
case "submit": {
const result = await $dropFetch("/api/v1/admin/company/:id", {
method: "PATCH",
params: { id: company.value.id },
body: { [field]: s! },
failTitle: "Failed to update company details",
});
company.value[field] = result[field];
c();
break;
}
}
},
);
}
return modal;
}
const editName = buildFieldEditModal(
"mName",
t("library.admin.metadata.companies.modals.nameTitle"),
t("library.admin.metadata.companies.modals.nameDescription"),
);
const editShortDescription = buildFieldEditModal(
"mShortDescription",
t("library.admin.metadata.companies.modals.shortDeckTitle"),
t("library.admin.metadata.companies.modals.shortDeckDescription"),
);
const editWebsite = buildFieldEditModal(
"mWebsite",
t("library.admin.metadata.companies.modals.websiteTitle"),
t("library.admin.metadata.companies.modals.websiteDescription"),
);
function updateLogo(response: { id: string }) {
company.value.mLogoObjectId = response.id;
}
function updateBanner(response: { id: string }) {
company.value.mBannerObjectId = response.id;
}
</script>

View File

@ -0,0 +1,153 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.description") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createCompanyOpen = true)"
>
{{ $t("common.create") }}
</button>
</div>
</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="$t('library.admin.metadata.companies.search')"
/>
<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="company in filteredCompanies"
:key="company.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(company.mLogoObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ company.mName }}
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ company.mShortDescription }}
</dd>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/metadata/companies/${company.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"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteCompany(company.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
</li>
<p
v-if="filteredCompanies.length == 0 && companies.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredCompanies.length == 0 && companies.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.metadata.companies.noCompanies") }}
</p>
</ul>
<ModalCreateCompany
v-model="createCompanyOpen"
@created="(company) => createCompany(company)"
/>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { CompanyModel } from "~/prisma/client/models";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("library.admin.metadata.companies.title"),
});
const createCompanyOpen = ref(false);
const searchQuery = ref("");
const rawCompanies = await $dropFetch("/api/v1/admin/company");
const companies = ref(rawCompanies);
const filteredCompanies = computed(() =>
companies.value.filter((e: CompanyModel) => {
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;
}),
);
async function deleteCompany(id: string) {
await $dropFetch(`/api/v1/admin/company/:id`, {
method: "DELETE",
params: { id },
failTitle: "Failed to delete company",
});
const index = companies.value.findIndex((e) => e.id === id);
companies.value.splice(index, 1);
}
function createCompany(company: (typeof companies.value)[number]) {
companies.value.push(company);
}
</script>

View File

@ -0,0 +1,62 @@
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-16">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.tags.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.tags.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/metadata/tags"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.metadata.tags.action"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/metadata/companies"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.metadata.companies.action"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
</script>

View File

@ -0,0 +1,75 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.tags.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.tags.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createModalOpen = true)"
>
{{ $t("library.admin.metadata.tags.create") }}
</button>
</div>
</div>
<div class="flex flex-wrap gap-3">
<div
v-for="(tag, tagIdx) in tags"
:key="tag.id"
class="py-2 px-3 inline-flex gap-x-3 bg-zinc-950 ring-1 ring-zinc-800 text-zinc-300"
>
{{ tag.name }}
<button @click="() => deleteTag(tagIdx)">
<TrashIcon
class="transition size-4 text-zinc-700 hover:text-red-500"
/>
</button>
</div>
</div>
<ModalCreateTag v-model="createModalOpen" @created="onTagCreate" />
</div>
</template>
<script setup lang="ts">
import { TrashIcon } from "@heroicons/vue/24/outline";
import type { SerializeObject } from "nitropack";
import type { GameTagModel } from "~/prisma/client/models";
definePageMeta({
layout: "admin",
});
const createModalOpen = ref(false);
const tags = ref(
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/admin/tags"),
);
function sort() {
tags.value.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);
}
sort();
async function onTagCreate(tag: GameTagModel) {
tags.value.push(tag);
sort();
}
async function deleteTag(tagIdx: number) {
const tag = tags.value[tagIdx];
await $dropFetch(`/api/v1/admin/tags/${tag.id}`, {
method: "DELETE",
failTitle: "Failed to delete tag",
});
tags.value.splice(tagIdx, 1);
}
</script>

68
pages/admin/settings.vue Normal file
View File

@ -0,0 +1,68 @@
<template>
<div class="flex flex-col">
<!-- tabs-->
<div>
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex gap-x-2" aria-label="Tabs">
<NuxtLink
v-for="(tab, tabIdx) in navigation"
:key="tab.route"
:href="tab.route"
:class="[
currentNavigationIndex == tabIdx
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
]"
:aria-current="tab ? 'page' : undefined"
>
<component
:is="tab.icon"
:class="[
currentNavigationIndex == tabIdx
? 'text-blue-500 dark:text-blue-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
'mr-2 -ml-0.5 size-5',
]"
aria-hidden="true"
/>
<span>{{ tab.label }}</span>
</NuxtLink>
</nav>
</div>
</div>
<!-- content -->
<div class="mt-4 grow flex">
<NuxtPage />
</div>
</div>
</template>
<script setup lang="ts">
import {
BuildingStorefrontIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
const navigation: Array<NavigationItem & { icon: Component }> = [
{
label: $t("header.admin.settings.store"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: BuildingStorefrontIcon,
},
{
label: $t("header.admin.settings.tokens"),
route: "/admin/settings/tokens",
prefix: "/admin/settings/tokens",
icon: CodeBracketIcon,
},
];
// const notifications = useNotifications();
// const unreadNotifications = computed(() =>
// notifications.value.filter((e) => !e.read)
// );
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
</script>

View File

@ -1,12 +1,108 @@
<template> <template>
<div class="text-gray-100">{{ $t("todo") }}</div> <form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="pb-4 border-b border-zinc-700">
<h2 class="text-xl font-semibold text-zinc-100">
{{ $t("settings.admin.store.title") }}
</h2>
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
</h3>
<ul class="flex gap-3">
<li class="inline-block">
<OptionWrapper
:active="showGamePanelTextDecoration"
@click="setShowTitleDescription(true)"
>
<div class="flex">
<GamePanel
:animate="false"
:game="game"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
<li class="inline-block">
<OptionWrapper
:active="!showGamePanelTextDecoration"
@click="setShowTitleDescription(false)"
>
<div class="flex">
<GamePanel
:game="game"
:show-title-description="false"
:animate="false"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
</ul>
</div>
<LoadingButton
type="submit"
class="inline-flex w-full shadow-sm sm:w-auto"
:loading="saving"
:disabled="!allowSave"
>
{{ allowSave ? $t("common.save") : $t("common.saved") }}
</LoadingButton>
</form>
</template> </template>
<script lang="ts" setup>
useHead({ <script setup lang="ts">
title: "Settings", import { FetchError } from "ofetch";
});
const { t } = useI18n();
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
}); });
useHead({
title: t("settings.admin.title"),
});
const settings = await $dropFetch("/api/v1/settings");
const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data");
const allowSave = ref(false);
const showGamePanelTextDecoration = ref<boolean>(
settings.showGamePanelTextDecoration,
);
function setShowTitleDescription(value: boolean) {
showGamePanelTextDecoration.value = value;
allowSave.value = true;
}
const saving = ref<boolean>(false);
async function saveSettings() {
saving.value = true;
try {
await $dropFetch("/api/v1/admin/settings", {
method: "PATCH",
body: {
showGamePanelTextDecoration: showGamePanelTextDecoration.value,
},
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: `Failed to save settings.`,
description:
e instanceof FetchError
? (e.message)
: (e as string).toString(),
},
(_, c) => c(),
);
}
saving.value = false;
allowSave.value = false;
}
</script> </script>

View File

@ -0,0 +1,233 @@
<template>
<div class="w-full">
<div class="w-full flex justify-between items-center">
<div>
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.token.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.token.subheader") }}
</p>
</div>
<div>
<LoadingButton :loading="false" @click="() => (createOpen = true)">
{{ $t("common.create") }}
</LoadingButton>
</div>
</div>
<div
v-if="newToken"
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
>
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-300">
{{ $t("account.token.success") }}
</p>
<p class="text-xs text-green-300/70">
{{ $t("account.token.successNote") }}
</p>
<p
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
>
{{ newToken }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
@click="() => (newToken = undefined)"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.acls") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.expiry") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="(token, tokenIdx) in tokens"
:key="token.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ token.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="acl in token.acls"
:key="acl"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
{{ acl }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
<span v-else>{{ $t("account.token.noExpiry") }}</span>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeToken(tokenIdx)"
>
{{ $t("account.token.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [token.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="tokens.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.token.noTokens") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ModalCreateToken
v-model="createOpen"
:acls="acls"
:loading="createLoading"
:suggested-name="suggestedName"
:suggested-acls="suggestedAcls"
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
/>
</div>
</template>
<script setup lang="ts">
import { ArkErrors, type } from "arktype";
import { DateTime, type DurationLike } from "luxon";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
definePageMeta({
layout: "admin",
});
const tokens = ref(await $dropFetch("/api/v1/admin/token"));
const acls = await $dropFetch("/api/v1/admin/token/acls");
const createOpen = ref(false);
const createLoading = ref(false);
const newToken = ref<string | undefined>();
const suggestedName = ref();
const suggestedAcls = ref<string[]>([]);
const payloadParser = type({
name: "string?",
acls: "string[]?",
});
const route = useRoute();
if (route.query.payload) {
try {
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
const payload = payloadParser(rawPayload);
if (payload instanceof ArkErrors) throw payload;
suggestedName.value = payload.name;
suggestedAcls.value = payload.acls ?? [];
createOpen.value = true;
} catch {
throw createError({
statusCode: 400,
message: "Failed to parse the token create payload.",
fatal: true,
});
}
}
async function createToken(
name: string,
acls: string[],
expiry: DurationLike | undefined,
) {
createLoading.value = true;
try {
const result = await $dropFetch("/api/v1/admin/token", {
method: "POST",
body: {
name,
acls,
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} catch {
/* empty */
}
createOpen.value = false;
createLoading.value = false;
}
async function revokeToken(index: number) {
const token = tokens.value[index];
if (!token) return;
await $dropFetch("/api/v1/admin/token/:id", {
method: "DELETE",
params: {
id: token.id,
},
failTitle: "Failed to revoke token.",
});
tokens.value.splice(index, 1);
}
</script>

View File

@ -44,19 +44,26 @@
</div> </div>
{{ task.name }} {{ task.name }}
</h1> </h1>
<div class="h-2 rounded-full bg-zinc-950 overflow-hidden"> <div
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
>
<LogLine
v-for="(_, idx) in task.log"
:key="idx"
:log="parseTaskLog(task.log.at(-(idx + 1)))"
/>
</div>
<div class="relative h-5 rounded-xl bg-zinc-950 overflow-hidden">
<div <div
:style="{ width: `${task.progress}%` }" :style="{ width: `${task.progress}%` }"
class="transition-all bg-blue-600 h-full" class="transition-all bg-blue-600 h-full"
/> />
</div> <span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
<div >{{
class="relative bg-zinc-950/50 rounded-md p-2 text-zinc-100 h-[80vh] overflow-y-scroll" $t("tasks.admin.progress", [Math.round(task.progress * 10) / 10])
> }}</span
<pre v-for="(line, idx) in task.log" :key="idx">{{ >
formatLine(line)
}}</pre>
</div> </div>
</div> </div>
<div v-else role="status" class="w-full flex items-center justify-center"> <div v-else role="status" class="w-full flex items-center justify-center">
@ -90,11 +97,6 @@ const taskId = route.params.id.toString();
const task = useTask(taskId); const task = useTask(taskId);
function formatLine(line: string): string {
const res = parseTaskLog(line);
return `[${res.timestamp}] ${res.message}`;
}
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
}); });

View File

@ -13,62 +13,7 @@
:key="task.value?.id" :key="task.value?.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm" class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
> >
<div <TaskWidget :task="task.value" :active="true" />
v-if="task.value"
class="flex w-full items-center justify-between space-x-6 p-6"
>
<div class="flex-1 truncate">
<div class="flex items-center space-x-2">
<div>
<CheckIcon
v-if="task.value.success"
class="size-4 text-green-600"
/>
<XMarkIcon
v-else-if="task.value.error"
class="size-4 text-red-600"
/>
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.value.name }}
</h3>
</div>
<p class="text-xs text-zinc-600 mt-0.5 font-mono">
{{ task.value.id }}
</p>
<div class="mt-1 w-full rounded-full overflow-hidden bg-zinc-900">
<div
:style="{ width: `${task.value.progress}%` }"
class="bg-blue-600 h-1.5 transition-all"
/>
</div>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ task.value.log.at(-1) }}
</p>
<NuxtLink
type="button"
:href="`/admin/task/${task.value.id}`"
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t
keypath="tasks.admin.viewTask"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-else>
<!-- renders server side when we don't want to access the current tasks -->
</div>
</li> </li>
</ul> </ul>
<div <div
@ -89,51 +34,7 @@
:key="task.id" :key="task.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm" class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
> >
<div class="flex w-full items-center justify-between space-x-6 p-6"> <TaskWidget :task="task" />
<div class="flex-1 truncate">
<div class="flex items-center space-x-2">
<div>
<CheckIcon
v-if="task.success"
class="size-4 text-green-600"
/>
<XMarkIcon
v-else-if="task.error"
class="size-4 text-red-600"
/>
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.name }}
</h3>
<RelativeTime class="text-zinc-500" :date="task.ended" />
</div>
<p class="text-xs text-zinc-600 mt-0.5 font-mono">
{{ task.id }}
</p>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.log.at(-1) ?? "").message }}
</p>
<NuxtLink
type="button"
:href="`/admin/task/${task.id}`"
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t
keypath="tasks.admin.viewTask"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
</li> </li>
</ul> </ul>
</div> </div>
@ -157,6 +58,21 @@
<p class="mt-1 text-sm text-zinc-400"> <p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }} {{ scheduledTasks[task].description }}
</p> </p>
<button
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
@click="() => startTask(task)"
>
<i18n-t
keypath="tasks.admin.execute"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<PlayIcon class="size-4" aria-hidden="true" />
</template>
</i18n-t>
</button>
</div> </div>
</div> </div>
</li> </li>
@ -180,6 +96,21 @@
<p class="mt-1 text-sm text-zinc-400"> <p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }} {{ scheduledTasks[task].description }}
</p> </p>
<button
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
@click="() => startTask(task)"
>
<i18n-t
keypath="tasks.admin.execute"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<PlayIcon class="size-4" aria-hidden="true" />
</template>
</i18n-t>
</button>
</div> </div>
</div> </div>
</li> </li>
@ -189,7 +120,7 @@
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline"; import { PlayIcon } from "@heroicons/vue/24/outline";
import type { TaskGroup } from "~/server/internal/tasks/group"; import type { TaskGroup } from "~/server/internal/tasks/group";
useHead({ useHead({
@ -205,7 +136,9 @@ const { t } = useI18n();
const { runningTasks, historicalTasks, dailyTasks, weeklyTasks } = const { runningTasks, historicalTasks, dailyTasks, weeklyTasks } =
await $dropFetch("/api/v1/admin/task"); await $dropFetch("/api/v1/admin/task");
const liveRunningTasks = await Promise.all(runningTasks.map((e) => useTask(e))); const liveRunningTasks = ref(
await Promise.all(runningTasks.map((e) => useTask(e))),
);
const scheduledTasks: { const scheduledTasks: {
[key in TaskGroup]: { name: string; description: string }; [key in TaskGroup]: { name: string; description: string };
@ -230,5 +163,19 @@ const scheduledTasks: {
name: "", name: "",
description: "", description: "",
}, },
debug: {
name: "Debug Task",
description: "Does debugging things.",
},
}; };
async function startTask(taskGroup: string) {
const task = await $dropFetch("/api/v1/admin/task", {
method: "POST",
body: { taskGroup },
failTitle: "Failed to start task",
});
const taskRef = await useTask(task.id);
liveRunningTasks.value.push(taskRef);
}
</script> </script>

View File

@ -116,7 +116,7 @@ import { IconsSimpleAuthenticationLogo, IconsSSOLogo } from "#components";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid"; import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid";
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid"; import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import type { AuthMec } from "~/prisma/client"; import type { AuthMec } from "~/prisma/client/enums";
import type { Component } from "vue"; import type { Component } from "vue";
useHead({ useHead({

View File

@ -36,20 +36,14 @@
</div> </div>
<ul role="list" class="divide-y divide-zinc-800"> <ul role="list" class="divide-y divide-zinc-800">
<li <li
v-for="(invitation, invitationIdx) in invitations" v-for="invitation in invitations"
:key="invitation.id" :key="invitation.id"
class="relative flex justify-between gap-x-6 py-5" class="relative flex justify-between gap-x-6 py-5"
> >
<div class="flex min-w-0 gap-x-4"> <div class="flex min-w-0 gap-x-4">
<div class="min-w-0 flex-auto"> <div class="min-w-0 flex-auto">
<div class="text-sm/6 font-semibold text-zinc-100"> <div class="text-sm/6 font-semibold text-zinc-100">
<p v-if="invitationUrls"> <p>{{ invitation.inviteUrl }}</p>
{{ invitationUrls[invitationIdx] }}
</p>
<div
v-else
class="h-4 w-full bg-zinc-800 animate-pulse rounded"
/>
</div> </div>
<p class="mt-1 flex text-xs/5 text-gray-500"> <p class="mt-1 flex text-xs/5 text-gray-500">
@ -400,7 +394,7 @@ import {
} from "@headlessui/vue"; } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { TrashIcon, XCircleIcon } from "@heroicons/vue/24/solid"; import { TrashIcon, XCircleIcon } from "@heroicons/vue/24/solid";
import type { Invitation } from "~/prisma/client"; import type { InvitationModel } from "~/prisma/client/models";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { DurationLike } from "luxon"; import type { DurationLike } from "luxon";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
@ -415,20 +409,11 @@ useHead({
title: "Simple authentication", title: "Simple authentication",
}); });
const data = await $dropFetch<Array<SerializeObject<Invitation>>>( const data = await $dropFetch<
"/api/v1/admin/auth/invitation", Array<SerializeObject<InvitationModel & { inviteUrl: string }>>
); >("/api/v1/admin/auth/invitation");
const invitations = ref(data ?? []); const invitations = ref(data ?? []);
const generateInvitationUrl = (id: string) =>
`${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),
);
});
// Makes username undefined if it's empty // Makes username undefined if it's empty
const _username = ref<undefined | string>(undefined); const _username = ref<undefined | string>(undefined);
const username = computed({ const username = computed({
@ -515,10 +500,9 @@ function invite_wrapper() {
invite() invite()
.then((invitation) => { .then((invitation) => {
invitations.value.push(invitation); invitations.value.push(invitation);
invitationUrls.value?.push(generateInvitationUrl(invitation.id));
}) })
.catch((response) => { .catch((response) => {
const message = response.statusMessage || t("errors.unknown"); const message = response.message || t("errors.unknown");
error.value = message; error.value = message;
}) })
.finally(() => { .finally(() => {
@ -536,7 +520,6 @@ async function deleteInvitation(id: string) {
const index = invitations.value.findIndex((e) => e.id === id); const index = invitations.value.findIndex((e) => e.id === id);
invitations.value.splice(index, 1); invitations.value.splice(index, 1);
invitationUrls.value?.splice(index, 1);
} }
const createModalOpen = ref(false); const createModalOpen = ref(false);

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