Compare commits

..

42 Commits

Author SHA1 Message Date
DecDuck 2d2b815441 Tag connect & disconnect fix (#360)
* fix: tag connect/disconnect

* fix: lint

* fix: oidc typo fix
2026-02-27 15:15:27 +11:00
DecDuck 7fa02c57d1 OIDC & store fixes (#358)
* fix: typos

* fix: platform filtering

* feat: fix tags and create option
2026-02-27 09:15:19 +11:00
DecDuck 768a4e2414 Quick fixes (#355)
* fix: error message on import

* fix: 2fa input size
2026-02-25 23:23:36 +11:00
DecDuck c82822c435 Add display name field to version importer (#354) 2026-02-25 23:05:24 +11:00
DecDuck f97fc25ea3 Fix torrential depot (#353)
Adds a plugin to automatically add/update the torrential depot record
2026-02-25 22:24:15 +11:00
DecDuck dbe34684d8 Paginated admin library & upgrade manifests (#351)
* feat: new page layout + endpoint

* feat: non-parallel mass import

* feat: paginated admin library

* feat: lint and performance improvement

* feat: library filter util

* feat: link frontend features to backend

* fix: lint

* fix: small fixes

* feat: bump torrential

* fix: lint
2026-02-25 02:17:33 +11:00
DecDuck 1ad881721e Fix dev torrential server (#349)
* fix: droplet interface not waiting for torrential

* fix: lint
2026-02-13 13:10:53 +11:00
Husky ed724c7170 migrate to prisma v7 (#345)
* migrate to prisma v7

* fix prisma type imports

* update prisma version in docker

* fix prisma cli breaking things
2026-02-11 01:26:53 +00:00
Husky f8447808dd Series of small fixes (#346)
* migrate bufbuild config to v2

* fix localdir lookup

* fix old oidc redirect path

* rework twemoji path stuff

* fix torrential cmd

* fix broken lock file

* align dev server port with nginx

* bump pnpm

* bump nuxt

* make translations lazy loaded

* format numbers via i18n

* fix prisma breaking if extension already exists
2026-02-09 16:27:31 +00:00
Paco ca845467e1 Refactoring and UI improvements on the admin home page (#348) 2026-02-09 18:40:45 +11:00
DecDuck 795fd5966d Regenerate lockfile 2026-02-09 15:36:10 +11:00
dependabot[bot] 5316ef706f chore(deps): bump seroval from 1.4.0 to 1.5.0 (#338)
Bumps [seroval](https://github.com/lxsmnsyc/seroval) from 1.4.0 to 1.5.0.
- [Release notes](https://github.com/lxsmnsyc/seroval/releases)
- [Commits](https://github.com/lxsmnsyc/seroval/commits/1.5.0)

---
updated-dependencies:
- dependency-name: seroval
  dependency-version: 1.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-07 13:41:19 +11:00
dependabot[bot] e0e4a551a3 chore(deps): bump lodash from 4.17.21 to 4.17.23 (#337)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-07 13:41:05 +11:00
dependabot[bot] e9fee7d5bc chore(deps): bump lodash-es from 4.17.21 to 4.17.23 (#340)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-07 13:33:27 +11:00
DecDuck 9b0f9994f6 API optimisations (#343)
* feat: api optimisation

* feat: emulator rename
2026-02-06 23:12:03 +11:00
Paco 276f4f6389 Fixes wrong values used in RAM usage section (#344) 2026-02-06 13:42:00 +11:00
Paco 965cbff8ff Make application and logo configurable (#336)
* Adds settings for server name and logo

* Implements ApplicationLogo and replaces site name based on settings

* Refactors component for changing the company logo

* Removes unused variable

* Uses message instead of statusMessage

* Replaces favicon with logo if set
2026-02-06 11:43:21 +11:00
DecDuck d80c1e5b91 Use native runners for CI (#342)
* feat: use platform-specific runners

* feat: replace with template

* fix: image name

* fix: registry image name

* fix: checkout repo

* fix: add drop version

* fix: add sha env

* fix: permissions
2026-02-06 09:55:22 +11:00
DecDuck d582202a8d Use self-hosted runner 2026-02-06 01:21:54 +11:00
DecDuck e05ba853f6 Fix Docker build 2026-02-06 00:53:50 +11:00
DecDuck 9d2c4465f8 Translated using Weblate (German) (#328)
Currently translated at 100.0% (522 of 522 strings)

Translated using Weblate (German)

Currently translated at 100.0% (522 of 522 strings)

Translated using Weblate (German)

Currently translated at 100.0% (522 of 522 strings)

Translated using Weblate (French)

Currently translated at 100.0% (522 of 522 strings)

Translated using Weblate (French)

Currently translated at 100.0% (518 of 518 strings)






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

Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Hicks <hicksgaming99+weblate@gmail.com>
Co-authored-by: Ribemont Francois <ribemont.francois+weblate@gmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org>
2026-02-06 00:34:11 +11:00
DecDuck d234f8df33 In-app store, torrential backend, locales (#332)
* feat: add store nav and fixes

* fix: reduce password requirement & new task error ui

* fix: client webtoken fix

* fix: delta versions and dockerfile

* fix: use setup platforms for filter & display

* fix: setup not accounted when returning valid options

* feat: tighter delta version support

* feat: dl/disk size

* feat: offload manifest generation to torrential

* fix: bump torrential

* feat: remove droplet

* feat: bump torrential

* feat: convert locales
2026-02-06 00:12:24 +11:00
DecDuck 837bc6eb1d Fix console.log and NGINX port 2026-01-29 16:34:45 +11:00
DecDuck 00adab21c2 Game specialisation & delta versions (#323)
* feat: game specialisation, auto-guess extensions

* fix: enforce specialisation specific schema at API level

* fix: lint

* feat: partial work on depot endpoints

* feat: bump torrential

* feat: dummy version creation for depot uploads

* fix: lint

* fix: types

* fix: lint

* feat: depot version import

* fix: lint

* fix: remove any type

* fix: lint

* fix: push update interval

* fix: cpu usage calculation

* feat: delta version support

* feat: style tweaks for selectlaunch.vue

* fix: lint
2026-01-23 16:04:38 +11:00
Paco d8db5b5b85 Adds new tile on the admin home page with system data. (#301)
* Adds new tile on the admin home page with system data. Also fixes the active users bug in the pie chart

* Fixes missing parentheses

* Updates user stats cache when signing in

* Reads active number of users from session provider

* Removes unused variable

* Small improvements

* Removes acl properties from system data websocket and performs initial push of data

* fix: remove acl fetch

---------

Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
2026-01-22 10:58:21 +11:00
dependabot[bot] 82cdc1e1aa chore(deps): bump diff from 8.0.2 to 8.0.3 (#326)
Bumps [diff](https://github.com/kpdecker/jsdiff) from 8.0.2 to 8.0.3.
- [Changelog](https://github.com/kpdecker/jsdiff/blob/master/release-notes.md)
- [Commits](https://github.com/kpdecker/jsdiff/compare/v8.0.2...v8.0.3)

---
updated-dependencies:
- dependency-name: diff
  dependency-version: 8.0.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 21:41:17 +11:00
Paco bb858917ce Customisable OIDC label #325 (#327) 2026-01-21 08:15:01 +00:00
Husky f04daf0388 Add ODIC Back-Channel Logout (#304)
* prevent returning expired sessions

* add issuer to ODIC creds

* get id token in ODIC

* make session signin return session

* working backchannel logout?

* require https for ODIC provider

* handle wellknown not being https

* find session api progress

* fix windows build

* return session token on session

* switch OIDC to #searchSessions

* update pnpm

* switch to using message on error obj

* move odic callback

* fix type errors

* redirect old oidc callback

* make redirect url a URL

* remove scheduled task downloadCleanup

* fix session search for oidc

* fix signin result

* cleanup code

* ignore data dir

* fix lint error
2026-01-20 09:50:04 +11:00
DecDuck 2967e433ca Fix sign-in page errors, etc (#322)
* fix: store page redirect

* fix: silent fail passkey + error display

* fix: remove console log modal
2026-01-19 17:34:03 +11:00
dependabot[bot] 61355e1da2 chore(deps): bump undici from 7.13.0 to 7.18.2 (#319)
Bumps [undici](https://github.com/nodejs/undici) from 7.13.0 to 7.18.2.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.13.0...v7.18.2)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.18.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 17:16:21 +11:00
dependabot[bot] 690c7e0163 chore(deps): bump node-forge from 1.3.1 to 1.3.2 (#295)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.1 to 1.3.2.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 17:16:09 +11:00
dependabot[bot] 0b2a8faeca chore(deps): bump diff from 8.0.2 to 8.0.3 (#321)
Bumps [diff](https://github.com/kpdecker/jsdiff) from 8.0.2 to 8.0.3.
- [Changelog](https://github.com/kpdecker/jsdiff/blob/master/release-notes.md)
- [Commits](https://github.com/kpdecker/jsdiff/compare/v8.0.2...v8.0.3)

---
updated-dependencies:
- dependency-name: diff
  dependency-version: 8.0.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 17:15:52 +11:00
dependabot[bot] 8b92c9e0b9 chore(deps): bump tar from 7.4.3 to 7.5.3 (#320)
Bumps [tar](https://github.com/isaacs/node-tar) from 7.4.3 to 7.5.3.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.4.3...v7.5.3)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 17:15:37 +11:00
dependabot[bot] 29b9530945 chore(deps): bump devalue from 5.5.0 to 5.6.2 (#318)
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.5.0 to 5.6.2.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.5.0...v5.6.2)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.6.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 17:15:24 +11:00
dependabot[bot] fb37d291a4 chore(deps-dev): bump h3 from 1.15.3 to 1.15.5 (#316)
Bumps [h3](https://github.com/h3js/h3) from 1.15.3 to 1.15.5.
- [Release notes](https://github.com/h3js/h3/releases)
- [Changelog](https://github.com/h3js/h3/blob/v1.15.5/CHANGELOG.md)
- [Commits](https://github.com/h3js/h3/compare/v1.15.3...v1.15.5)

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 1.15.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 17:13:30 +11:00
DecDuck d5b4f9760b Translated using Weblate (French) (#297)
Currently translated at 100.0% (529 of 529 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (529 of 529 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (523 of 523 strings)

Translated using Weblate (Polish)

Currently translated at 24.4% (128 of 523 strings)

Translated using Weblate (Polish)

Currently translated at 22.7% (119 of 523 strings)

Added translation using Weblate (Polish)

Translated using Weblate (German)

Currently translated at 100.0% (523 of 523 strings)

Translated using Weblate (English (en_PIRATE))

Currently translated at 73.8% (386 of 523 strings)







Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
Translate-URL: http://translate.droposs.org/projects/drop/drop/pl/
Translate-URL: https://translate.droposs.org/projects/drop/drop/fr/
Translation: Drop/Drop

Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Andus <resres2007@gmail.com>
Co-authored-by: Hicks <hicksgaming99+weblate@gmail.com>
Co-authored-by: Ribemont Francois <ribemont.francois+weblate@gmail.com>
2026-01-15 15:37:21 +11:00
DecDuck 99a60b8fa0 Fix MFA superlevel redirect & ViewTransition (#314)
* feat: fix mfa superlevel & viewtransition

* fix: lint
2026-01-15 15:34:17 +11:00
DecDuck 833b5fbcfa Auto-run torrential in development (#313)
* Auto-run torrential in development

* fix: lint
2026-01-14 15:10:48 +11:00
Husky 1eaec4c3e8 Switch to nuxt assets for emojis (#311)
* switch to nuxt assets for emojis

* add auth to emoji endpoint

* fix cache control header

* fix type error
2026-01-14 14:49:58 +11:00
DecDuck 63ac2b8ffc Depot API & v4 (#298)
* feat: nginx + torrential basics & services system

* fix: lint + i18n

* fix: update torrential to remove openssl

* feat: add torrential to Docker build

* feat: move to self hosted runner

* fix: move off self-hosted runner

* fix: update nginx.conf

* feat: torrential cache invalidation

* fix: update torrential for cache invalidation

* feat: integrity check task

* fix: lint

* feat: move to version ids

* fix: client fixes and client-side checks

* feat: new depot apis and version id fixes

* feat: update torrential

* feat: droplet bump and remove unsafe update functions

* fix: lint

* feat: v4 featureset: emulators, multi-launch commands

* fix: lint

* fix: mobile ui for game editor

* feat: launch options

* fix: lint

* fix: remove axios, use $fetch

* feat: metadata and task api improvements

* feat: task actions

* fix: slight styling issue

* feat: fix style and lints

* feat: totp backend routes

* feat: oidc groups

* fix: update drop-base

* feat: creation of passkeys & totp

* feat: totp signin

* feat: webauthn mfa/signin

* feat: launch selecting ui

* fix: manually running tasks

* feat: update add company game modal to use new SelectorGame

* feat: executor selector

* fix(docker): update rust to rust nightly for torrential build (#305)

* feat: new version ui

* feat: move package lookup to build time to allow for deno dev

* fix: lint

* feat: localisation cleanup

* feat: apply localisation cleanup

* feat: potential i18n refactor logic

* feat: remove args from commands

* fix: lint

* fix: lockfile

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
2026-01-13 15:32:39 +11:00
dependabot[bot] 8ef983304c chore(deps): bump qs from 6.14.0 to 6.14.1 (#303)
Bumps [qs](https://github.com/ljharb/qs) from 6.14.0 to 6.14.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-04 18:34:00 +11:00
Andus 8f5d8a43c5 Add user profile page (#302)
* Add user page and API endpoint

* add: /user/[id] page
* add: /api/v1/user/[id] API endpoint

* Change loading message in user profile page

* Fix build errors, prettier code
2026-01-04 13:45:20 +11:00
290 changed files with 14538 additions and 6844 deletions
+2
View File
@@ -3,3 +3,5 @@ DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
GIANT_BOMB_API_KEY=""
EXTERNAL_URL="http://localhost:3000"
NUXT_PORT=4000
+89 -40
View File
@@ -8,10 +8,20 @@ on:
schedule:
- cron: "0 2 * * *" # run at 2 AM UTC
env:
REGISTRY_IMAGE: ghcr.io/drop-oss/drop
jobs:
web:
name: Push website Docker image to registry
runs-on: ubuntu-latest
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
packages: write
contents: read
@@ -25,6 +35,30 @@ jobs:
ref: ${{ github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Determine final version
id: get_final_ver
run: |
@@ -43,22 +77,58 @@ jobs:
echo "Drop's release tag will be: $FINAL_VER"
echo "final_ver=$FINAL_VER" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
buildkitd-flags: --debug
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.REGISTRY_IMAGE }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
provenance: mode=max
sbom: true
build-args: |
BUILD_DROP_VERSION=${{ steps.get_final_ver.outputs.final_ver }}
BUILD_GIT_REF=${{ github.sha }}
- name: Log in to the Container registry
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
permissions:
packages: write
contents: read
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
@@ -77,33 +147,12 @@ jobs:
# set latest tag for stable releases
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: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- 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
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_DROP_VERSION=${{ steps.get_final_ver.outputs.final_ver }}
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
+3 -1
View File
@@ -34,4 +34,6 @@ deploy-template/*
# generated prisma client
/prisma/client
/prisma/validate
/prisma/validate
/server/internal/proto
+3
View File
@@ -1,3 +1,6 @@
[submodule "drop-base"]
path = drop-base
url = https://github.com/Drop-OSS/drop-base.git
[submodule "torrential"]
path = torrential
url = https://github.com/Drop-OSS/torrential.git
+4
View File
@@ -1,3 +1,7 @@
drop-base/
# file is fully managed by pnpm, no reason to break it
pnpm-lock.yaml
/torrential/
.data/**
**/.data/**
+5
View File
@@ -0,0 +1,5 @@
{
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"]
}
+26 -28
View File
@@ -1,37 +1,35 @@
{
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"driver": "PostgreSQL",
"name": "drop",
"database": "drop",
"username": "drop",
"password": "drop"
}
],
// allow autocomplete for ArkType expressions like "string | num"
"editor.quickSuggestions": {
"strings": "on"
},
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"],
"i18n-ally.extract.autoDetect": true,
"i18n-ally.extract.ignored": ["string >= 14", "string.alphanumeric >= 5"],
"i18n-ally.extract.ignoredByFiles": {
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
"pages/admin/library/sources/index.vue": ["Filesystem"],
"server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"]
},
"i18n-ally.keepFulfilled": true,
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
// i18n Ally settings
"i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true,
"i18n-ally.extract.autoDetect": true,
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.extract.ignored": [
"string >= 14",
"string.alphanumeric >= 5",
"/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}"
"prisma.pinToPrisma6": false,
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
"sqltools.connections": [
{
"database": "drop",
"driver": "PostgreSQL",
"name": "drop",
"password": "drop",
"port": 5432,
"previewLimit": 50,
"server": "localhost",
"username": "drop"
}
],
"i18n-ally.extract.ignoredByFiles": {
"pages/admin/library/sources/index.vue": ["Filesystem"],
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
"server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"]
}
"typescript.experimental.useTsgo": false,
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
}
+26 -12
View File
@@ -6,47 +6,56 @@ ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
# so corepack knows pnpm's version
## so corepack knows pnpm's version
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# prevent prompt to download
## prevent prompt to download
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# setup for offline
## setup for offline
RUN corepack pack
# don't call out to network anymore
## don't call out to network anymore
ENV COREPACK_ENABLE_NETWORK=0
### Unified deps builder
### INSTALL DEPS ONCE
FROM base AS deps
RUN pnpm install --frozen-lockfile --ignore-scripts
### Build for app
### BUILD TORRENTIAL
FROM rustlang/rust:nightly-alpine AS torrential-build
RUN apk add musl-dev
WORKDIR /build
COPY torrential .
RUN apk add protoc
RUN cargo build --release
### BUILD APP
FROM base AS build-system
ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
# add git so drop can determine its git ref at build
## add git so drop can determine its git ref at build
RUN apk add --no-cache git
# copy deps and rest of project files
## copy deps and rest of project files
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BUILD_DROP_VERSION
ARG BUILD_GIT_REF
# build
## build
RUN pnpm run postinstall && pnpm run build
### create run environment for Drop
# create run environment for Drop
FROM base AS run-system
ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
# 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 7zip
RUN pnpm install prisma@6.11.1
RUN apk add --no-cache pnpm 7zip nginx
RUN pnpm install prisma@7.3.0
# init prisma to download all required files
RUN pnpm prisma init
@@ -54,8 +63,13 @@ COPY --from=build-system /app/prisma.config.ts ./
COPY --from=build-system /app/.output ./app
COPY --from=build-system /app/prisma ./prisma
COPY --from=build-system /app/build ./startup
COPY --from=build-system /app/build/nginx.conf /nginx.conf
COPY --from=torrential-build /build/target/release/torrential /usr/bin/
ENV LIBRARY="/library"
ENV DATA="/data"
ENV NGINX_CONFIG="/nginx.conf"
# NGINX's port
ENV PORT=4000
CMD ["sh", "/app/startup/launch.sh"]
+2 -13
View File
@@ -29,10 +29,11 @@ await updateUser();
const user = useUser();
const apiDetails = await $dropFetch("/api/v1");
const clientMode = isClientRequest();
const showExternalUrlWarning = ref(false);
function checkExternalUrl() {
if (!import.meta.client) return;
if (!import.meta.client || clientMode) return;
const realOrigin = window.location.origin.trim();
const chosenOrigin = apiDetails.external.trim();
const ignore = window.localStorage.getItem("ignoreExternalUrl");
@@ -51,15 +52,3 @@ if (user.value?.admin) {
});
}
</script>
<style scoped>
/* You can customise the default animation here. */
::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
}
::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
version: v2
plugins:
- local: protoc-gen-es
out: server/internal/proto
opt: target=ts
+41
View File
@@ -0,0 +1,41 @@
worker_processes 1;
events {
worker_connections 1024;
}
pid nginx.pid;
error_log stderr;
daemon off;
http {
default_type application/octet-stream;
sendfile on;
server_tokens off;
access_log nginx_host.access.log;
client_body_temp_path client_body;
fastcgi_temp_path fastcgi_temp;
proxy_temp_path proxy_temp;
scgi_temp_path scgi_temp;
uwsgi_temp_path uwsgi_temp;
server {
listen 3000;
server_name localhost;
location / {
proxy_pass http://localhost:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /api/v1/depot/ {
proxy_pass http://localhost:5000;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
}
+18 -11
View File
@@ -53,10 +53,17 @@ import type { Component } from "vue";
const notifications = useNotifications();
const { t } = useI18n();
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
const navigation: Ref<
(NavigationItem & { icon: Component; count?: number })[]
> = computed(() => [
{
label: t("security"),
label: t("account.home.title"),
route: "/account",
icon: HomeIcon,
prefix: "/account",
},
{
label: t("account.security.title"),
route: "/account/security",
prefix: "/account/security",
icon: LockClosedIcon,
@@ -67,6 +74,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
prefix: "/account/devices",
icon: DevicePhoneMobileIcon,
},
{
label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{
label: t("account.notifications.notifications"),
route: "/account/notifications",
@@ -74,19 +87,13 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
icon: BellIcon,
count: notifications.value.length,
},
{
label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{
label: t("account.settings"),
route: "/account/settings",
prefix: "/account/settings",
icon: WrenchScrewdriverIcon,
},
];
]);
const currentPageIndex = useCurrentNavigationIndex(navigation);
const currentPageIndex = useCurrentNavigationIndex(navigation.value);
</script>
+12
View File
@@ -0,0 +1,12 @@
<template>
<template v-if="!mLogoObjectId">
<DropLogo />
</template>
<template v-else>
<img :src="useObject(mLogoObjectId)" :alt="`${serverName} logo`" />
</template>
</template>
<script setup lang="ts">
const { serverName, mLogoObjectId } = await $dropFetch("/api/v1");
</script>
+10 -1
View File
@@ -4,7 +4,14 @@
:href="`/auth/oidc?redirect=${route.query.redirect ?? '/'}`"
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
>
<i18n-t keypath="auth.signin.externalProvider" tag="span" scope="global">
<i18n-t
keypath="auth.signin.signinWithExternalProvider"
tag="span"
scope="global"
>
<template #externalProvider>{{
providerName || $t("auth.signin.externalProvider")
}}</template>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
@@ -15,4 +22,6 @@
<script setup lang="ts">
const route = useRoute();
const { providerName = undefined } = defineProps<{ providerName?: string }>();
</script>
+64 -18
View File
@@ -12,7 +12,7 @@
v-model="username"
name="username"
type="username"
autocomplete="username"
autocomplete="username webauthn"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
@@ -86,36 +86,78 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { UserModel } from "~/prisma/client/models";
import {
startAuthentication,
browserSupportsWebAuthn,
} from "@simplewebauthn/browser";
import { FetchError } from "ofetch";
const username = ref("");
const password = ref("");
const rememberMe = ref(false);
const loading = ref(false);
async function passkeyAutofill() {
let silentWebauthnOptions;
try {
silentWebauthnOptions = await $dropFetch("/api/v1/auth/passkey/start", {
method: "POST",
});
} catch {
return;
}
const result = await startAuthentication({
optionsJSON: silentWebauthnOptions,
useBrowserAutofill: true,
});
loading.value = true;
await $dropFetch("/api/v1/auth/passkey/finish", {
method: "POST",
body: result,
});
await completeSignin();
}
onMounted(async () => {
if (browserSupportsWebAuthn()) {
try {
await passkeyAutofill();
} catch (response) {
const message = (response as FetchError).message || t("errors.unknown");
error.value = message;
} finally {
loading.value = false;
}
}
});
const error = ref<string | undefined>();
const route = useRoute();
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
function signin_wrapper() {
async function signin_wrapper() {
loading.value = true;
signin()
.then(() => {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => {
const message = response.statusMessage || t("errors.unknown");
error.value = message;
})
.finally(() => {
loading.value = false;
});
try {
await signin();
} catch (e) {
if (e instanceof FetchError) {
error.value = e.data.message || t("errors.unknown");
} else {
error.value = e as string;
}
} finally {
loading.value = false;
}
}
async function signin() {
await $dropFetch("/api/v1/auth/signin/simple", {
const { result } = await $dropFetch("/api/v1/auth/signin/simple", {
method: "POST",
body: {
username: username.value,
@@ -123,7 +165,11 @@ async function signin() {
rememberMe: rememberMe.value,
},
});
const user = useUser();
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
if (result == "2fa") {
router.push({ query: route.query, path: "/auth/mfa" });
return;
}
await completeSignin();
}
</script>
+86
View File
@@ -0,0 +1,86 @@
<template>
<input
v-for="i in length"
ref="codeElements"
:key="i"
v-model="code[i - 1]"
:class="[
size,
'uppercase appearance-none text-center bg-zinc-900 rounded-xl border-zinc-700 focus:border-blue-600 text-bold font-display text-zinc-100',
]"
type="text"
pattern="\d*"
:placeholder="placeholder[i - 1]"
@keydown="(v) => keydown(i - 1, v)"
@input="() => input(i - 1)"
@focusin="() => select(i - 1)"
@paste="(v) => paste(i - 1, v)"
/>
</template>
<script setup lang="ts">
const {
length = 7,
placeholder = "1A2B3C4",
size = "w-16 h-16 text-2xl",
} = defineProps<{
length?: number;
placeholder?: string;
size?: string;
}>();
const emit = defineEmits<{
(e: "complete", code: string): void;
}>();
const codeElements = useTemplateRef("codeElements");
const code = ref<string[]>([]);
function keydown(index: number, event: KeyboardEvent) {
if (event.key === "Backspace" && !code.value[index] && index > 0) {
codeElements.value![index - 1].focus();
}
}
function input(index: number) {
if (codeElements.value === null) return;
const v = code.value[index] ?? "";
if (v.length > 1) code.value[index] = v[0];
if (!(index + 1 >= codeElements.value.length) && v) {
codeElements.value[index + 1].focus();
}
if (!(index - 1 < 0) && !v) {
codeElements.value[index - 1].focus();
}
if (index == length - 1) {
const assembledCode = code.value.join("");
if (assembledCode.length == length) {
complete(assembledCode);
}
}
}
function select(index: number) {
if (!codeElements.value) return;
if (index >= codeElements.value.length) return;
codeElements.value[index].select();
}
function paste(index: number, event: ClipboardEvent) {
const newCode = event.clipboardData!.getData("text/plain");
for (let i = 0; i < newCode.length && i < length; i++) {
code.value[i] = newCode[i];
codeElements.value![i].focus();
if (i + 1 == length) {
complete(code.value.join(""));
}
}
event.preventDefault();
}
async function complete(completedCode: string) {
emit("complete", completedCode);
}
</script>
+11 -2
View File
@@ -10,9 +10,18 @@
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z"
/>
</svg>
<DropLogo aria-hidden="true" class="h-6" />
<ApplicationLogo aria-hidden="true" class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase">
{{ $t("drop.drop") }}
<template v-if="serverName">
{{ serverName }}
</template>
<template v-else>
{{ $t("drop.drop") }}
</template>
</span>
</div>
</template>
<script setup lang="ts">
const { serverName } = await $dropFetch("/api/v1");
</script>
+1 -1
View File
@@ -10,6 +10,6 @@ const props = defineProps<{
}>();
const url = computed(() => {
return `/twemoji/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
return `/api/v1/emoji/${twemoji.convert.toCodePoint(props.emoji)}`;
});
</script>
+41
View File
@@ -0,0 +1,41 @@
<template>
<div
v-if="emulator"
class="flex space-x-4 rounded-md bg-zinc-900/50 px-6 outline -outline-offset-1 outline-white/10 w-fit text-xs font-bold text-zinc-100"
>
<div class="inline-flex gap-x-2 items-center">
<img :src="useObject(emulator.gameIcon)" class="size-6" />
<span>{{ emulator.gameName }}</span>
</div>
<div class="flex items-center">
<svg
class="h-full w-6 shrink-0 text-white/10"
viewBox="0 0 24 44"
preserveAspectRatio="none"
fill="currentColor"
aria-hidden="true"
>
<path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z" />
</svg>
<span class="ml-4">{{ emulator.versionName }}</span>
</div>
<div class="flex items-center">
<svg
class="h-full w-6 shrink-0 text-white/10"
viewBox="0 0 24 44"
preserveAspectRatio="none"
fill="currentColor"
aria-hidden="true"
>
<path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z" />
</svg>
<span class="ml-4 truncate">{{ emulator.launchName }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { EmulatorLaunchObject } from "~/composables/frontend";
defineProps<{ emulator: EmulatorLaunchObject }>();
</script>
+3 -1
View File
@@ -44,7 +44,9 @@ const props = defineProps<{
width?: number;
}>();
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const {
store: { showGamePanelTextDecoration },
} = await $dropFetch(`/api/v1/settings`);
const currentComponent = ref<HTMLDivElement>();
+34 -10
View File
@@ -1,7 +1,7 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game!">
<div class="grow flex flex-row gap-y-8">
<div class="grow flex flex-col xl:flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col">
<div
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
@@ -10,10 +10,12 @@
<!-- icon image -->
<img :src="coreMetadataIconUrl" class="size-20" />
<div>
<h1 class="text-5xl font-bold font-display text-zinc-100">
<h1
class="text-2xl xl:text-5xl font-bold font-display text-zinc-100"
>
{{ game.mName }}
</h1>
<p class="mt-1 text-lg text-zinc-400">
<p class="mt-1 text-sm xl:text-lg text-zinc-400">
{{ game.mShortDescription }}
</p>
</div>
@@ -28,7 +30,11 @@
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" />
<SelectorMultiItem
v-model="currentTags"
:items="tags"
:create="createTag"
/>
<div class="flex flex-col">
<label
for="releaseDate"
@@ -461,7 +467,7 @@
</template>
<script setup lang="ts">
import type { GameModel, GameTagModel } from "~/prisma/client/models";
import type { GameModel } from "~/prisma/client/models";
import { micromark } from "micromark";
import {
CheckIcon,
@@ -471,6 +477,7 @@ import {
} from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get";
const showUploadModal = ref(false);
const showAddCarouselModal = ref(false);
@@ -478,8 +485,9 @@ const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true);
type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
const game = defineModel<ModelType>() as Ref<ModelType>;
const game = defineModel<SerializeObject<AdminFetchGameType>>({
required: true,
});
if (!game.value)
throw createError({
statusCode: 500,
@@ -489,8 +497,9 @@ if (!game.value)
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,
const rawTags = await $dropFetch("/api/v1/admin/tags");
const tags = ref(
rawTags.map((e) => ({ name: e.name, param: e.id }) satisfies StoreSortOption),
);
watch(
@@ -501,7 +510,11 @@ watch(
params: {
id: game.value.id,
},
body: { tags: Object.keys(v) },
body: {
tags: Object.entries(v)
.filter((v) => v[1])
.map((v) => v[0]),
},
failTitle: "Failed to update game tags",
});
},
@@ -812,4 +825,15 @@ async function updateImageCarousel() {
);
}
}
async function createTag(value: string): Promise<string> {
const tag = await $dropFetch(`/api/v1/admin/tags`, {
method: "POST",
body: {
name: value,
},
});
tags.value.push({ name: tag.name, param: tag.id });
return tag.id;
}
</script>
+166 -103
View File
@@ -1,97 +1,155 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game && unimportedVersions">
<div class="grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
<div
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"
>
<!-- version manager -->
<div>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
<div v-if="game && unimportedVersions" class="px-4 sm:px-6 lg:px-8 py-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-white">
{{ $t("library.admin.version.title") }}
</h1>
<p class="mt-2 text-sm text-gray-300">
{{ $t("library.admin.version.description") }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<NuxtLink
:href="canImport ? `/admin/library/${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 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="relative min-w-full divide-y divide-white/15">
<thead>
<tr>
<th></th>
<th
scope="col"
class="py-3 pr-3 pl-4 text-left text-xs font-medium tracking-wide text-gray-400 uppercase sm:pl-0"
>
{{ $t("library.admin.versionPriority") }}
<!-- import games button -->
<NuxtLink
:href="canImport ? `/admin/library/${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>
</h3>
</div>
</div>
<div class="mt-4 text-center w-full text-sm text-zinc-600">
{{ $t("lowest") }}
</div>
{{ $t("library.admin.version.table.name") }}
</th>
<th
scope="col"
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
>
{{ $t("library.admin.version.table.path") }}
</th>
<th
scope="col"
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
>
{{ $t("library.admin.version.table.delta") }}
</th>
<th
scope="col"
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
>
{{ $t("library.admin.version.table.setup") }}
</th>
<th
scope="col"
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
>
{{ $t("library.admin.version.table.launch") }}
</th>
<th scope="col" class="py-3 pr-4 pl-3 sm:pr-0">
<span class="sr-only">{{ $t("common.edit") }}</span>
</th>
</tr>
</thead>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
class="divide-y divide-white/10"
tag="tbody"
@update="() => updateVersionOrder()"
>
<template
#item="{ element: item }: { element: GameVersionModelWithSize }"
>
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between w-full flex"
>
<div class="text-zinc-100 font-semibold flex-none">
{{ item.versionName }}
</div>
<div
class="text-right text-zinc-400 text-xs font-normal flex-auto pr-4"
>
{{ item.size && formatBytes(item.size) }}
</div>
<div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<template #item="{ element: version }: { element: VersionType }">
<tr :key="version.versionId">
<td>
<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" />
</td>
<td class="py-4 pr-3 pl-4 sm:pl-0">
<div class="flex flex-col">
<span
class="text-sm font-medium whitespace-nowrap text-white"
>{{ version.displayName ?? version.versionPath }}</span
>
<span class="text-xs text-zinc-500 mono">{{
version.versionId
}}</span>
</div>
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
{{ version.versionPath }}
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
{{ version.delta }}
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
<ul class="space-y-2">
<GameEditorVersionConfig
v-for="config in version.setups"
:key="config.setupId"
:config="config"
/>
<li
v-if="version.setups.length == 0"
class="text-xs uppercase font-display text-zinc-700 font-semibold"
>
{{ $t("library.admin.version.noSetups") }}
</li>
</ul>
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
<div v-if="version.onlySetup">
{{ $t("library.admin.version.setupOnly") }}
</div>
<ul v-else class="space-y-2">
<GameEditorVersionConfig
v-for="config in version.launches"
:key="config.launchId"
:config="config"
/>
</ul>
</td>
<td
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0 space-x-2"
>
<!--
<button class="text-blue-400 hover:text-blue-300">
Edit<span class="sr-only"
>,
{{ version.displayName ?? version.versionPath }}</span
>
</button>
</div>
</div>
</template>
-->
<button
class="text-red-400 hover:text-red-300"
@click="() => deleteVersion(version.versionId)"
>
{{ $t("common.delete") }}
</button>
</td>
</tr></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>
</table>
</div>
</div>
</div>
@@ -117,12 +175,10 @@
</template>
<script setup lang="ts">
import type { GameModel, GameVersionModel } from "~/prisma/client/models";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
import { formatBytes } from "~/server/internal/utils/files";
import { ExclamationCircleIcon, Bars3Icon } from "@heroicons/vue/24/outline";
import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get";
// TODO implement UI for this page
@@ -136,29 +192,34 @@ const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameVersionModelWithSize = GameVersionModel & { size: number };
type GameAndVersions = GameModel & {
versions: GameVersionModelWithSize[];
};
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions>
>;
const game = defineModel<SerializeObject<AdminFetchGameType>>({
required: true,
});
if (!game.value)
throw createError({
statusCode: 500,
statusMessage: "Game not provided to editor component",
});
type VersionType = (typeof game.value.versions)[number];
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: game.value.id,
versions: game.value.versions.map((e) => e.versionName),
const newVersionOrder = await $dropFetch(
"/api/v1/admin/game/:id/versions",
{
method: "PATCH",
body: {
versions: game.value.versions.map((e) => e.versionId),
},
params: {
id: game.value.id,
},
},
});
);
const newVersions = newVersionOrder.map(
(id) => game.value.versions.find((k) => k.versionId == id)!,
);
game.value.versions = newVersions;
} catch (e) {
createModal(
@@ -175,17 +236,19 @@ async function updateVersionOrder() {
}
}
async function deleteVersion(versionName: string) {
async function deleteVersion(versionId: string) {
try {
await $dropFetch("/api/v1/admin/game/version", {
await $dropFetch("/api/v1/admin/game/:id/versions", {
method: "DELETE",
body: {
version: versionId,
},
params: {
id: game.value.id,
versionName: versionName,
},
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName),
game.value.versions.findIndex((e) => e.versionId === versionId),
1,
);
hasDeleted.value = true;
+55
View File
@@ -0,0 +1,55 @@
<template>
<li class="p-3 bg-zinc-800 ring-1 ring-zinc-700 shadow rounded-lg space-y-2">
<div class="flex justify-between">
<h1
v-if="!isSetup(props.config)"
class="font-semibold text-zinc-300 text-md"
>
{{ props.config.name }}
</h1>
<span class="flex items-center">
<component
:is="PLATFORM_ICONS[props.config.platform]"
alt=""
class="size-5 flex-shrink-0 text-blue-600"
/>
<span class="ml-2 block truncate text-zinc-100 text-sm font-bold">{{
props.config.platform
}}</span>
</span>
</div>
<div
class="inline-flex gap-x-1 items-center bg-zinc-950 text-zinc-400 mono rounded-md p-2"
>
<p>{{ props.config.command }}</p>
</div>
<EmulatorWidget
v-if="!isSetup(props.config) && props.config.emulator"
:emulator="{
launchId: props.config.launchId,
gameName: props.config.emulator.gameVersion.game.mName,
gameIcon: props.config.emulator.gameVersion.game.mIconObjectId,
versionName: (props.config.emulator.gameVersion.displayName ??
props.config.emulator.gameVersion.versionPath)!,
launchName: props.config.emulator.name,
platform: props.config.emulator.platform,
}"
/>
</li>
</template>
<script setup lang="ts">
import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get";
const props = defineProps<{
config:
| AdminFetchGameType["versions"][number]["setups"][number]
| AdminFetchGameType["versions"][number]["launches"][number];
}>();
function isSetup(
v: typeof props.config,
): v is AdminFetchGameType["versions"][number]["setups"][number] {
return Object.prototype.hasOwnProperty.call(v, "setupId");
}
</script>
+6 -2
View File
@@ -1,6 +1,9 @@
<template>
<div class="flex flex-row items-center gap-x-2">
<img :src="game.icon" class="w-12 h-12 rounded-sm object-cover" />
<img
:src="rawIcon ? game.icon : useObject(game.icon)"
class="w-12 h-12 rounded-sm object-cover"
/>
<div class="flex flex-col items-left">
<h1 class="font-semibold font-display text-lg text-zinc-100">
{{ game.name }}
@@ -18,7 +21,8 @@
<script setup lang="ts">
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const { game } = defineProps<{
const { game, rawIcon = true } = defineProps<{
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
rawIcon?: boolean;
}>();
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<div
class="relative group/iconupload rounded-xl overflow-hidden w-20 mx-auto"
>
<img v-if="objectId" :src="useObject(objectId)" :alt="imageAlt" />
<ArrowUpTrayIcon v-else />
<button
type="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="openModal"
>
<ArrowUpTrayIcon class="size-5" />
<span>{{ hoverText }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { ArrowUpTrayIcon } from "@heroicons/vue/24/solid";
const { objectId, openModal, hoverText, imageAlt } = defineProps<{
objectId: string | null;
openModal: () => void;
hoverText: string;
imageAlt: string;
}>();
</script>
+316
View File
@@ -0,0 +1,316 @@
<template>
<div class="w-full">
<div v-if="needsName" class="mb-2">
<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"
>
<input
id="startup"
v-model="launchConfiguration.name"
type="text"
name="startup"
class="block flex-1 border-0 py-1.5 px-3 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="Launch name"
/>
</div>
</div>
<div class="mb-2">
<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 gap-x-0.5 pl-3 text-zinc-500 sm:text-sm"
>
<div class="relative">
<InformationCircleIcon class="peer size-4" />
<div
class="z-50 w-64 transition duration-100 opacity-0 shadow peer-hover:opacity-100 absolute left-0 p-2 bg-zinc-900 rounded text-xs text-zinc-300"
>
{{ $t("library.admin.launchRow.currentDirHint") }}
</div>
</div>
{{ $t("library.admin.import.version.installDir") }}
</span>
<Combobox
as="div"
:value="launchConfiguration.launch"
nullable
class="w-full"
@update:model-value="(v) => updateLaunchCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 w-full 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]"
class="size-5"
/>
<img
v-if="guess.type === 'emulator'"
:src="useObject(guess.icon)"
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 &&
launchConfiguration.launch !== 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']"
>
{{ 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>
</div>
<div
v-if="props.type && props.type === 'Emulator'"
class="ml-1 mt-2 rounded-lg bg-blue-900/10 p-1 outline outline-blue-900"
>
<div class="flex items-center">
<div class="shrink-0">
<InformationCircleIcon
class="size-5 text-blue-500"
aria-hidden="true"
/>
</div>
<div class="ml-2 inline-flex items-center">
<p class="text-sm text-blue-200">
<i18n-t
keypath="library.admin.launchRow.emulatorHint"
tag="span"
scope="global"
>
<template #rom>
<span
class="font-mono bg-zinc-950 text-zinc-100 py-1 px-0.5 rounded-xl"
>{{
// eslint-disable-next-line @intlify/vue-i18n/no-raw-text
"{rom}"
}}</span
>
</template>
</i18n-t>
</p>
</div>
</div>
</div>
</div>
<SelectorPlatform
:model-value="launchConfiguration.platform"
class="mb-2"
@update:model-value="updatePlatform"
>
{{ $t("library.admin.import.version.platform") }}
</SelectorPlatform>
<div v-if="props.type && props.type === 'Game' && props.allowEmulator">
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.launchRow.emulatorTitle") }}
</h1>
<div class="relative mt-2 space-x-1 inline-flex items-center w-full">
<EmulatorWidget v-if="emulator" :emulator="emulator" />
<div
v-else
class="font-bold uppercase font-display text-zinc-500 text-sm"
>
{{ $t("library.admin.launchRow.noEmulatorSelected") }}
</div>
<div class="grow" />
<LoadingButton :loading="false" @click="selectLaunchOpen = true">{{
$t("library.admin.launchRow.emulatorSelect")
}}</LoadingButton>
<button
:disabled="!emulator"
class="transition rounded p-2 bg-zinc-900/30 group hover:enabled:bg-red-600/10 text-zinc-400 hover:enabled:text-red-600 disabled:bg-zinc-900/80 disabled:text-zinc-700"
@click="() => (emulator = undefined)"
>
<TrashIcon class="transition size-5" />
</button>
</div>
</div>
<div v-if="props.type && props.type === 'Emulator'">
<p class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.launchRow.autosuggestHint") }}
</p>
<SelectorFileExtension
v-model="launchConfiguration.suggestions!"
class="mt-2"
/>
</div>
<ModalSelectLaunch
v-model="selectLaunchOpen"
class="-mt-2"
:filter-platform="launchConfiguration.platform"
@select="(v) => (emulator = v)"
/>
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { InformationCircleIcon, TrashIcon } from "@heroicons/vue/24/outline";
import type { EmulatorLaunchObject } from "~/composables/frontend";
import type { GameType, Platform } from "~/prisma/client/enums";
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
import type { VersionGuess } from "~/server/internal/library";
const launchProcessQuery = ref("");
const launchConfiguration = defineModel<
Omit<(typeof ImportVersion.infer)["launches"][number], "name"> & {
name?: string;
}
>({ required: true });
const _emulatorMetadata = ref<EmulatorLaunchObject | undefined>(undefined);
const emulator = computed({
get() {
return _emulatorMetadata.value;
},
set(v) {
_emulatorMetadata.value = v;
if (v) {
launchConfiguration.value.emulatorId = v.launchId;
} else {
launchConfiguration.value.emulatorId = undefined;
}
},
});
function updatePlatform(v: Platform | undefined) {
if (!v) return;
launchConfiguration.value.platform = v;
if (emulator.value) {
if (emulator.value.platform !== v) {
emulator.value = undefined;
}
}
}
const props = defineProps<{
versionGuesses: Array<VersionGuess> | undefined;
needsName: boolean;
allowEmulator?: boolean;
type?: GameType;
}>();
if (props.type && props.type === "Emulator")
launchConfiguration.value.suggestions ??= [];
const selectLaunchOpen = ref(false);
const launchFilteredVersionGuesses = computed(() =>
props.versionGuesses?.filter((e) =>
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
),
);
function updateLaunchCommand(command: string) {
launchConfiguration.value.launch = command;
if (launchConfiguration.value.platform === undefined) {
const autosetGuess = props.versionGuesses?.find(
(v) => v.filename == command,
);
if (autosetGuess) {
if (autosetGuess.type === "platform") {
launchConfiguration.value.platform = autosetGuess.platform;
} else if (autosetGuess.type === "emulator") {
emulator.value = {
launchId: autosetGuess.emulatorId,
gameName: autosetGuess.gameName,
gameIcon: autosetGuess.icon,
versionName: autosetGuess.launchName,
launchName: autosetGuess.launchName,
platform: autosetGuess.platform,
} satisfies EmulatorLaunchObject;
launchConfiguration.value.platform = autosetGuess.platform;
}
}
}
}
</script>
+3 -4
View File
@@ -1,6 +1,6 @@
<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 v-if="!short" class="text-zinc-500">{{ log.time }}</span>
<span
:class="[
colours[log.level] || 'text-green-400',
@@ -8,9 +8,8 @@
]"
>{{ log.level }}</span
>
<pre :class="[short ? 'line-clamp-1' : '', 'mt-[1px]']">{{
log.message
}}</pre>
<span v-if="log.prefix" class="text-zinc-200"> {{ log.prefix }}</span>
<pre :class="[short ? 'line-clamp-1' : '', 'mt-[1px]']">{{ log.msg }}</pre>
</span>
</template>
+28
View File
@@ -0,0 +1,28 @@
<template>
<TileWithLink>
<div class="h-full flex gap-4">
<div class="flex-1 my-auto">
<slot name="icon" />
</div>
<div
class="md:flex-8 flex-6 lg:flex-2 my-auto text-center flex md:flex-row-reverse lg:inline"
>
<div class="md:text-2xl text-3xl flex-1 font-bold self-center">
{{ value }}
</div>
<div
class="text-2xl xl:text-xs flex-1 md:flex-auto text-left md:text-center lg:text-center self-center"
>
{{ label }}
</div>
</div>
</div>
</TileWithLink>
</template>
<script setup lang="ts">
const { label, value } = defineProps<{
label: string;
value: string | number;
}>();
</script>
+10 -85
View File
@@ -11,66 +11,7 @@
</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>
<SelectorGame v-model="currentGame" :search="search" />
<div class="mt-6 flex items-center justify-between gap-3">
<label
id="published-label"
@@ -163,18 +104,11 @@
<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 { DialogTitle } from "@headlessui/vue";
import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const props = defineProps<{
companyId: string;
@@ -189,26 +123,11 @@ const emit = defineEmits<{
];
}>();
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 currentGame = ref<GameMetadataSearchResult>();
const developed = ref(false);
const published = ref(false);
const addGameLoading = ref(false);
@@ -243,4 +162,10 @@ async function addGame() {
open.value = false;
}
}
async function search(query: string) {
return await $dropFetch("/api/v1/admin/search/game?type=Game", {
query: { q: query },
});
}
</script>
+2 -15
View File
@@ -59,7 +59,6 @@ const emit = defineEmits<{
const open = defineModel<boolean>({ required: true });
const { t } = useI18n();
const collectionName = ref("");
const createCollectionLoading = ref(false);
const collections = await useCollections();
@@ -74,6 +73,7 @@ async function createCollection() {
const response = await $dropFetch("/api/v1/collection", {
method: "POST",
body: { name: collectionName.value },
failTitle: "Failed to create collection",
});
// Add the game if provided
@@ -83,6 +83,7 @@ async function createCollection() {
>(`/api/v1/collection/${response.id}/entry`, {
method: "POST",
body: { id: props.gameId },
failTitle: "Failed to add game to collection",
});
response.entries.push(entry);
}
@@ -94,20 +95,6 @@ async function createCollection() {
open.value = false;
emit("created", response.id);
} catch (error) {
console.error("Failed to create collection:", error);
const err = error as { statusMessage?: string };
createModal(
ModalType.Notification,
{
title: t("errors.library.collection.create.title"),
description: t("errors.library.collection.create.desc", [
err?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally {
createCollectionLoading.value = false;
}
+1 -1
View File
@@ -22,7 +22,7 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteCollection()"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
+1 -1
View File
@@ -22,7 +22,7 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteArticle()"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
+1 -1
View File
@@ -22,7 +22,7 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteUser()"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
+229
View File
@@ -0,0 +1,229 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<h1 as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.launchSelector.title") }}
</h1>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.launchSelector.description") }}
</p>
<div
v-if="props.filterPlatform"
class="inline-flex items-center mt-2 gap-x-4"
>
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.launchSelector.platformFilterHint") }}
</h1>
<span class="flex items-center">
<component
:is="PLATFORM_ICONS[props.filterPlatform]"
alt=""
class="size-5 flex-shrink-0 text-blue-600"
/>
<span class="ml-2 block truncate text-zinc-100 text-sm font-bold">{{
props.filterPlatform
}}</span>
</span>
</div>
</div>
<div class="mt-2 space-y-4">
<div>
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.launchSelector.search") }}
</h1>
<SelectorGame
:search="search"
:model-value="game"
class="w-full mt-2"
@update:model-value="(value) => updateGame(value)"
/>
</div>
<div
v-if="versions !== undefined && Object.entries(versions).length == 0"
class="text-zinc-300 text-sm font-bold font-display uppercase text-center w-full"
>
{{ $t("library.admin.launchSelector.noVersions") }}
</div>
<div v-else-if="versions !== undefined">
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.launchSelector.selectVersions") }}
</h1>
<SelectorCombox
:search="
(v) =>
Object.values(versions!)
.filter((k) =>
(k.displayName || k.versionPath)!
.toLowerCase()
.includes(v.toLowerCase()),
)
.map((v) => ({
id: v.versionId,
name: (v.displayName ?? v.versionPath)!,
}))
"
:display="(v) => v.name"
:model-value="version"
class="w-full mt-2"
@update:model-value="updateVersion"
>
<template #default="{ value }">
{{ value.name }}
</template>
</SelectorCombox>
</div>
<div v-if="versions && version">
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.launchSelector.selectCommand") }}
</h1>
<SelectorCombox
:search="
(v) =>
versions![version!.id].launches
.filter(
(k) =>
(k.name || k.command)
.toLowerCase()
.includes(v.toLowerCase()) &&
(props.filterPlatform
? k.platform == props.filterPlatform
: true),
)
.map((v) => ({
id: v.launchId,
...v,
}))
"
:display="(v) => v.name"
:model-value="launchId"
class="w-full mt-2"
@update:model-value="(v) => (launchId = v)"
>
<template #default="{ value }">
<div class="flex flex-col">
<span class="text-zinc-300 text-sm">
{{ value.name }}
</span>
<span class="text-zinc-400 text-xs">{{ value.command }}</span>
</div>
</template>
</SelectorCombox>
</div>
</div>
<div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton :loading="false" :disabled="!launchId" @click="submit">
{{ $t("common.select") }}
</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="() => (open = false)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/outline";
import type { EmulatorLaunchObject } from "~/composables/frontend";
import type { Platform } from "~/prisma/client/enums";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const props = defineProps<{ filterPlatform?: Platform }>();
const open = defineModel<boolean>({ required: true });
const error = ref<string | undefined>();
const game = ref<GameMetadataSearchResult | undefined>(undefined);
const version = ref<{ id: string; name: string } | undefined>(undefined);
const launchId = ref<
{ id: string; name: string; command: string; platform: Platform } | undefined
>(undefined);
const versions = ref<
| {
[key: string]: {
displayName: string | null;
launches: {
launchId: string;
command: string;
name: string;
platform: Platform;
}[];
versionId: string;
versionPath: string | null;
};
}
| undefined
>(undefined);
const emit = defineEmits<{
select: [data: EmulatorLaunchObject];
}>();
async function search(query: string) {
return await $dropFetch("/api/v1/admin/search/game", {
query: { q: query, type: "Emulator" },
});
}
function updateGame(value: GameMetadataSearchResult | undefined) {
if (game.value !== value || value == undefined) {
version.value = undefined;
versions.value = undefined;
launchId.value = undefined;
}
game.value = value;
if (game.value) fetchVersions();
}
async function fetchVersions() {
const newVersions = await $dropFetch("/api/v1/admin/game/:id/versions", {
params: { id: game.value!.id },
failTitle: "Failed to fetch versions for launch picker",
});
versions.value = Object.fromEntries(newVersions.map((v) => [v.versionId, v]));
}
function updateVersion(v: typeof version.value) {
if (version.value !== v || v == undefined) {
launchId.value = undefined;
}
version.value = v;
}
function submit() {
emit("select", {
launchId: launchId.value!.id,
gameName: game.value!.name,
gameIcon: game.value!.icon,
versionName: version.value!.name,
launchName: launchId.value!.name,
platform: launchId.value!.platform,
});
open.value = false;
}
watch(open, () => {
game.value = undefined;
updateGame(game.value);
});
</script>
-1
View File
@@ -24,7 +24,6 @@
>
{{ name }}
</NuxtLink>
<!-- todo -->
</div>
</div>
<div class="ml-4 flex shrink-0">
+29 -27
View File
@@ -1,34 +1,36 @@
<template>
<h2 v-if="title" class="text-lg mb-4 w-full">{{ title }}</h2>
<div class="flex flex-col xl:flex-row gap-4">
<div class="relative flex grow max-w-[12rem]">
<svg class="aspect-square grow relative inline" viewBox="0 0 100 100">
<PieChartPieSlice
<div class="flex">
<div class="flex flex-col md:flex-row xl:gap-4 mx-auto">
<div class="relative flex max-w-[12rem] my-auto min-w-50">
<svg class="aspect-square grow relative inline" viewBox="0 0 100 100">
<PieChartPieSlice
v-for="slice in slices"
:key="`${slice.percentage}-${slice.totalPercentage}`"
:slice="slice"
/>
</svg>
<div class="absolute inset-0 bg-zinc-900 rounded-full m-12" />
</div>
<ul class="flex flex-col gap-y-1 m-auto text-left">
<li
v-for="slice in slices"
:key="`${slice.percentage}-${slice.totalPercentage}`"
:slice="slice"
/>
</svg>
<div class="absolute inset-0 bg-zinc-900 rounded-full m-12" />
:key="slice.value"
class="text-sm inline-flex items-center gap-x-1"
>
<span
class="size-3 inline-block rounded-sm"
:class="CHART_COLOURS[slice.color].bg"
/>
{{
$t("common.labelValueColon", {
label: slice.label,
value: $n(slice.value),
})
}}
</li>
</ul>
</div>
<ul class="flex flex-col gap-y-1 justify-center text-left">
<li
v-for="slice in slices"
:key="slice.value"
class="text-sm inline-flex items-center gap-x-1"
>
<span
class="size-3 inline-block rounded-sm"
:class="CHART_COLOURS[slice.color].bg"
/>
{{
$t("common.labelValueColon", {
label: slice.label,
value: slice.value,
})
}}
</li>
</ul>
</div>
</template>
+2 -1
View File
@@ -12,7 +12,8 @@
<span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
>
{{ $t("tasks.admin.progress", [Math.round(percentage * 10) / 10]) }}
<!-- {{ $t("tasks.admin.progress", [Math.round(percentage * 10) / 10]) }} -->
{{ $n(Math.round(percentage * 100) / 10000, "percent") }}
</span>
</div>
</template>
+91
View File
@@ -0,0 +1,91 @@
<template>
<Combobox
as="div"
nullable
:immediate="true"
:model-value="model"
class="bg-zinc-800 rounded"
@update:model-value="updateModelValue"
>
<div class="relative">
<ComboboxInput
:key="model?.id ?? 'off'"
class="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="Start typing..."
:display-value="(v) => (v ? props.display(v as T) : '')"
@change="query = $event.target.value"
@blur="query = ''"
/>
<ComboboxButton
class="absolute inset-0 right-0 flex items-center justify-end 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"
>
<div
v-if="results.length == 0"
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
>
{{ $t("common.noResults") }}
</div>
<ComboboxOption
v-for="result in results"
v-else
:key="result.id"
v-slot="{ active, selected }"
:value="result"
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>
<slot :value="result" />
</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" generic="T extends { id: string }">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
const props = defineProps<{
search: (query: string) => T[];
display: (value: T) => string;
}>();
const model = defineModel<T | undefined>();
const query = ref("");
const results = computed(() => props.search(query.value));
function updateModelValue(v: T) {
model.value = v;
}
</script>
+122
View File
@@ -0,0 +1,122 @@
<template>
<div>
<div class="flex gap-1 flex-wrap">
<span
v-for="extension in model"
:key="extension"
class="inline-flex items-center gap-x-0.5 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 inset-ring inset-ring-blue-400/30"
>
{{ extension }}
<button
type="button"
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-500/30"
@click="() => removeFileExtension(extension)"
>
<span class="sr-only">{{ $t("common.remove") }}</span>
<svg
viewBox="0 0 14 14"
class="size-3.5 stroke-blue-400 group-hover:stroke-blue-300"
>
<path d="M4 4l6 6m0-6l-6 6" />
</svg>
<span class="absolute -inset-1"></span>
</button>
</span>
<span v-if="model.length == 0" class="text-zinc-500 text-xs">{{
$t("library.admin.fileExtSelector.noSelected")
}}</span>
</div>
<Combobox
as="div"
nullable
:immediate="true"
:model-value="model"
class="mt-2 bg-zinc-800 rounded"
@update:model-value="addFileExtension"
>
<div class="relative">
<ComboboxInput
class="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 w-full"
placeholder="Start typing..."
:display-value="(_) => ''"
@change="query = $event.target.value"
@blur="query = ''"
/>
<ComboboxButton
class="absolute inset-0 right-0 flex items-center justify-end 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-if="query"
v-slot="{ active, selected }"
:value="query"
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>
{{
$t("library.admin.fileExtSelector.add", [normalize(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>
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
const model = defineModel<string[]>({ required: true });
const query = ref("");
function normalize(v: string) {
const k = v.toLowerCase().replaceAll(/[^a-zA-Z0-9]*/g, "");
if (k.startsWith(".")) return k;
return `.${k}`;
}
function addFileExtension(raw: string) {
const value = normalize(raw);
if (model.value.includes(value)) return;
model.value.push(value);
}
function removeFileExtension(extension: string) {
const index = model.value.findIndex((v) => v === extension);
if (index == -1) return;
model.value.splice(index, 1);
}
</script>
+131
View File
@@ -0,0 +1,131 @@
<template>
<Combobox
v-model="currentResult"
as="div"
nullable
class="bg-zinc-800 rounded"
>
<div class="relative">
<ComboboxInput
class="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="Start typing..."
:display-value="(game) => (game as GameMetadataSearchResult)?.name"
@change="gameSearchQuery = $event.target.value"
@blur="gameSearchQuery = ''"
/>
<ComboboxButton
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"
>
<div
v-if="gameSearchQuery.length < 4"
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
>
{{ $t("library.admin.gameSelector.hint") }}
</div>
<div
v-else-if="resultsLoading || results === undefined"
class="flex items-center justify-center p-2"
>
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-zinc-100"
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-else-if="results.length == 0"
class="text-zinc-500 uppercase font-display font-bold text-center p-4"
>
{{ $t("common.noResults") }}
</div>
<ComboboxOption
v-for="result in results"
v-else
:key="result.id"
v-slot="{ active, selected }"
:value="result"
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>
<GameSearchResultWidget :game="result" :raw-icon="false" />
</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 { ChevronUpDownIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const props = defineProps<{
search: (query: string) => Promise<Array<GameMetadataSearchResult>>;
}>();
const currentResult = defineModel<GameMetadataSearchResult | undefined>();
const gameSearchQuery = ref("");
const resultsLoading = ref(false);
const results = ref<Array<GameMetadataSearchResult>>();
let timeout: NodeJS.Timeout | undefined = undefined;
watch(gameSearchQuery, async (v) => {
if (v.length < 4) {
results.value = [];
resultsLoading.value = false;
return;
}
if (timeout) clearTimeout(timeout);
resultsLoading.value = true;
timeout = setTimeout(async () => {
results.value = await props.search(v);
resultsLoading.value = false;
timeout = undefined;
}, 600);
});
</script>
@@ -1,6 +1,6 @@
<template>
<div>
<LanguageSelectorListbox />
<SelectorLanguageListbox />
<NuxtLink
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
to="https://translate.droposs.org/engage/drop/"
@@ -34,12 +34,12 @@
<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..."
:placeholder="$t('common.components.multiitem.placeholder')"
@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"
class="absolute inset-0 flex items-center justify-end rounded-r-md px-2 focus:outline-hidden"
>
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
@@ -68,7 +68,51 @@
</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="$props.create"
v-slot="{ active }"
:value="CREATE_PREFIX + search"
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">
{{ $t("common.components.multiitem.new", [search]) }}
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
<div
v-if="createLoading"
class="absolute inset-0 bg-zinc-950 flex items-center justify-center"
>
<div role="status">
<svg
aria-hidden="true"
class="size-8 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">{{ $t("common.srLoading") }}</span>
</div>
</div>
</div>
</Combobox>
</div>
@@ -85,6 +129,7 @@ import {
} from "@headlessui/vue";
const props = defineProps<{
items: Array<StoreSortOption>;
create?: (value: string) => Promise<string>;
}>();
const model = defineModel<{ [key: string]: boolean }>();
@@ -102,7 +147,37 @@ const enabledItems = computed(() =>
props.items.filter((e) => model.value?.[e.param]),
);
// I do not love how this works, but it's okay for now
const CREATE_PREFIX = "CREATE";
const createLoading = ref(false);
function add(item: string) {
if (item.startsWith(CREATE_PREFIX)) {
if (!props.create) return;
const value = item.substring(CREATE_PREFIX.length);
createLoading.value = true;
props
.create(value)
.then(
(result) => {
add(result);
},
(err) => {
createModal(
ModalType.Notification,
{
title: "Failed to create value",
description: err,
},
(_, c) => c(),
);
},
)
.finally(() => {
createLoading.value = false;
});
return;
}
search.value = "";
model.value ??= {};
model.value[item] = true;
@@ -32,7 +32,7 @@
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
v-for="[name, value] in Object.entries(values)"
v-for="[name, value] in values"
:key="value"
v-slot="{ active, selected }"
as="template"
@@ -82,10 +82,11 @@ import {
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { Platform } from "~/prisma/client/enums";
const model = defineModel<PlatformClient | undefined>();
const model = defineModel<Platform | undefined>();
const typedModel = computed<PlatformClient | null>({
const typedModel = computed<Platform | null>({
get() {
return model.value || null;
},
@@ -95,5 +96,5 @@ const typedModel = computed<PlatformClient | null>({
},
});
const values = Object.fromEntries(Object.entries(PlatformClient));
const values = Object.entries(Platform);
</script>
+6 -12
View File
@@ -56,18 +56,14 @@
</tr>
</thead>
<tbody>
<tr
v-for="(source, sourceIdx) in sources"
:key="source.id"
class="even:bg-zinc-800"
>
<tr v-for="(source, sourceIdx) in sources" :key="source.id">
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ source.name }}
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
@@ -96,14 +92,14 @@
v-if="source.fsStats"
:percentage="
getPercentage(
source.fsStats.freeSpace,
source.fsStats.totalSpace - source.fsStats.freeSpace,
source.fsStats.totalSpace,
)
"
:color="
getBarColor(
getPercentage(
source.fsStats.freeSpace,
source.fsStats.totalSpace - source.fsStats.freeSpace,
source.fsStats.totalSpace,
),
)
@@ -132,7 +128,7 @@
class="text-red-500 hover:text-red-400"
@click="() => deleteSource(sourceIdx)"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
@@ -152,6 +148,7 @@ import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { DropLogo } from "#components";
import { formatBytes } from "~/server/internal/utils/files";
import { getBarColor } from "~/utils/colors";
import { getPercentage } from "~/utils/utils";
const {
sources,
@@ -187,7 +184,4 @@ const optionsMetadata: {
icon: BackwardIcon,
},
};
const getPercentage = (value: number, total: number) =>
((total - value) * 100) / total;
</script>
+13 -7
View File
@@ -127,7 +127,7 @@
>
</div>
</div>
<MultiItemSelector
<SelectorMultiItem
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
@@ -189,7 +189,11 @@
>
{{ option.name }}
<span v-if="currentSort === option.param">
{{ sortOrder === "asc" ? $t("↑") : $t("↓") }}
{{
sortOrder === "asc"
? $t("chars.arrowUp")
: $t("chars.arrowDown")
}}
</span>
</button>
</MenuItem>
@@ -291,7 +295,7 @@
>
</div>
</div>
<MultiItemSelector
<SelectorMultiItem
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
@@ -304,7 +308,7 @@
<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-5 2xl:grid-cols-6 gap-4"
class="col-span-4 grid gap-5 grid-cols-[repeat(auto-fill,minmax(150px,auto))]"
>
<!-- Your content -->
<GamePanel
@@ -372,8 +376,10 @@ import {
} 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`);
import { Platform } from "~/prisma/client/enums";
const {
store: { showGamePanelTextDecoration },
} = await $dropFetch(`/api/v1/settings`);
const mobileFiltersOpen = ref(false);
@@ -424,7 +430,7 @@ const options: Array<StoreFilterOption> = [
name: "Platform",
param: "platform",
multiple: true,
options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })),
options: Object.values(Platform).map((e) => ({ name: e, param: e })),
},
...(props.extraOptions ?? []),
];
+9
View File
@@ -29,6 +29,15 @@
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
</div>
<ul v-if="task.actions" class="mt-1 flex flex-row gap-x-2">
<NuxtLink
v-for="[name, link] in task.actions.map((v) => v.split(':'))"
:key="link"
:href="link"
class="text-xs text-zinc-100 bg-blue-900 p-1 rounded"
>{{ name }}</NuxtLink
>
</ul>
<NuxtLink
type="button"
:href="`/admin/task/${task.id}`"
+1 -1
View File
@@ -10,7 +10,7 @@
{{ $t("drop.desc") }}
</p>
<LanguageSelector />
<SelectorLanguage />
<div class="flex space-x-6">
<NuxtLink
+1 -1
View File
@@ -138,7 +138,7 @@
>
<div class="flex shrink-0 h-16 items-center justify-between">
<NuxtLink :to="homepageURL">
<DropLogo class="h-8 w-auto" />
<ApplicationLogo class="h-8 w-auto" />
</NuxtLink>
<UserHeaderUserWidget />
+39
View File
@@ -0,0 +1,39 @@
<template>
<div
class="w-full bg-zinc-950 p-1 inline-flex items-center gap-x-2 fixed inset-x-0 top-0 z-100"
>
<button
class="p-1 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-900 transition-all rounded"
@click="() => router.back()"
>
<ChevronLeftIcon class="size-4" />
</button>
<button
class="p-1 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-900 transition-all rounded"
@click="() => router.forward()"
>
<ChevronRightIcon class="size-4" />
</button>
<span class="text-zinc-400 text-sm">
{{ title }}
</span>
</div>
</template>
<script setup lang="ts">
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
const router = useRouter();
const title = ref("Loading...");
onMounted(() => {
title.value = document.title;
});
router.afterEach(() => {
title.value = "Loading...";
// TODO: more robust after-render "detection"
setTimeout(() => {
title.value = document.title;
}, 500);
});
</script>
+21
View File
@@ -0,0 +1,21 @@
import type { SerializeObject } from "nitropack";
import type { SystemData } from "~/server/internal/system-data";
const ws = new WebSocketHandler("/api/v1/admin/system-data/ws");
export const useSystemData = () =>
useState<SerializeObject<SystemData>>(
"system-data",
(): SystemData => ({
totalRam: 0,
freeRam: 0,
cpuLoad: 0,
cpuCores: 0,
}),
);
ws.listen((systemDataString) => {
const data = JSON.parse(systemDataString) as SerializeObject<SystemData>;
const systemData = useSystemData();
systemData.value = data;
});
+22
View File
@@ -0,0 +1,22 @@
import type {
ComponentCustomOptions as _ComponentCustomOptions,
ComponentCustomProperties as _ComponentCustomProperties,
} from "vue";
import type { Platform } from "~/prisma/client/enums";
declare module "@vue/runtime-core" {
interface ComponentCustomProperties extends _ComponentCustomProperties {
$t: (key: string, ...args: unknown[]) => string;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ComponentCustomOptions extends _ComponentCustomOptions {}
}
export interface EmulatorLaunchObject {
launchId: string;
gameName: string;
gameIcon: string;
versionName: string;
launchName: string;
platform: Platform;
}
+4 -4
View File
@@ -1,8 +1,8 @@
import { IconsLinuxLogo, IconsWindowsLogo, IconsMacLogo } from "#components";
import { PlatformClient } from "./types";
import { Platform } from "~/prisma/client/enums";
export const PLATFORM_ICONS = {
[PlatformClient.Linux]: IconsLinuxLogo,
[PlatformClient.Windows]: IconsWindowsLogo,
[PlatformClient.macOS]: IconsMacLogo,
[Platform.Linux]: IconsLinuxLogo,
[Platform.Windows]: IconsWindowsLogo,
[Platform.macOS]: IconsMacLogo,
};
+1
View File
@@ -0,0 +1 @@
declare module "kjua";
+14 -2
View File
@@ -16,7 +16,7 @@ interface DropFetch<
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>(
request: R,
opts?: O & { failTitle?: string },
opts?: O & { failTitle?: string; params?: { [key: string]: string } },
): Promise<
// sometimes there is an error, other times there isn't
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -60,7 +60,7 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
{
title: opts.failTitle,
description:
(e as FetchError)?.statusMessage ?? (e as string).toString(),
(e as FetchError)?.data?.message ?? (e as string).toString(),
//buttonText: $t("common.close"),
},
(_, c) => c(),
@@ -89,3 +89,15 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
if (import.meta.server) state.value = data;
return data;
};
export function isClientRequest() {
const existingState = useState("clientMode", () => false);
if (import.meta.server) {
const headers = useRequestHeaders(["User-Agent"]);
const calculatedClientRequest =
headers["user-agent"] == "Drop Desktop Client";
existingState.value = calculatedClientRequest;
}
return existingState.value;
}
+1
View File
@@ -52,6 +52,7 @@ websocketHandler.listen((message) => {
progress: 0,
error: undefined,
log: [],
actions: [],
};
state.value.error = { title, description };
break;
-6
View File
@@ -11,9 +11,3 @@ export type QuickActionNav = {
notifications?: Ref<number>;
action: () => Promise<void>;
};
export enum PlatformClient {
Windows = "Windows",
Linux = "Linux",
macOS = "macOS",
}
+9
View File
@@ -11,3 +11,12 @@ export const updateUser = async () => {
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
};
export async function completeSignin() {
const route = useRoute();
const router = useRouter();
const user = useUser();
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
router.push(route.query.redirect?.toString() ?? "/");
}
-2
View File
@@ -2,8 +2,6 @@ services:
postgres:
# using alpine image to reduce image size
image: postgres:alpine
ports:
- 5432:5432
healthcheck:
test: pg_isready -d drop -U drop
interval: 30s
+4 -2
View File
@@ -1,12 +1,14 @@
services:
postgres:
image: postgres:14-alpine
user: "1000:1000"
ports:
- 5432:5432
volumes:
- ../.data/db:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=drop
- POSTGRES_USER=drop
- POSTGRES_DB=drop
volumes:
postgres-data:
+11 -15
View File
@@ -10,10 +10,10 @@ const props = defineProps({
const { t } = useI18n();
const route = useRoute();
const user = useUser();
const statusCode = props.error?.statusCode;
const message =
props.error?.message || props.error?.statusMessage || t("errors.unknown");
const message = props.error?.data
? JSON.parse(props.error.data as string).message
: props.error.cause || props.error?.statusMessage || t("errors.unknown");
const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
async function signIn() {
@@ -21,11 +21,6 @@ async function signIn() {
redirect: `/auth/signin?redirect=${encodeURIComponent(route.fullPath)}`,
});
}
switch (statusCode) {
case 401:
case 403:
await signIn();
}
useHead({
title: t("errors.pageTitle", [statusCode ?? message]),
@@ -43,13 +38,13 @@ if (import.meta.client) {
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<DropLogo class="h-10 w-auto sm:h-12" />
<ApplicationLogo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div class="max-w-lg">
<p class="text-base font-semibold leading-8 text-blue-600">
<p class="text-base font-semibold leading-8 text-red-600">
{{ error?.statusCode }}
</p>
<h1
@@ -63,15 +58,16 @@ if (import.meta.client) {
>
{{ message }}
</p>
<p class="mt-6 text-base leading-7 text-zinc-400">
{{ $t("errors.occurred") }}
</p>
<!-- <p>{{ error. }}</p> -->
<div class="mt-10">
<!-- full app reload to fix errors -->
<NuxtLink
v-if="user && !showSignIn"
to="/"
<!-- clearError is inconsistent so reload app to clear erro -->
<a
v-if="!showSignIn"
href="/"
class="text-sm font-semibold leading-7 text-blue-600"
>
<i18n-t keypath="errors.backHome" tag="span" scope="global">
@@ -79,7 +75,7 @@ if (import.meta.client) {
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
</template>
</i18n-t>
</NuxtLink>
</a>
<button
v-else
class="text-sm font-semibold leading-7 text-blue-600"
+3
View File
@@ -1,10 +1,13 @@
// @ts-check
import { globalIgnores } from "eslint/config";
import withNuxt from "./.nuxt/eslint.config.mjs";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
import noPrismaDelete from "./rules/no-prisma-delete.mts";
export default withNuxt([
globalIgnores([".data/*"]),
eslintConfigPrettier,
// vue-i18n plugin
+19
View File
@@ -15,6 +15,13 @@ export default defineI18nConfig(() => {
},
} as const;
const defaultNumberFormat = {
percent: {
style: "percent",
useGrouping: false,
},
} as const;
return {
// https://i18n.nuxtjs.org/docs/guide/locale-fallback
fallbackLocale: "en-us",
@@ -31,5 +38,17 @@ export default defineI18nConfig(() => {
zh: defaultDateTimeFormat,
"zh-tw": defaultDateTimeFormat,
},
numberFormats: {
"en-us": defaultNumberFormat,
"en-gb": defaultNumberFormat,
"en-au": defaultNumberFormat,
"en-pirate": defaultNumberFormat,
fr: defaultNumberFormat,
de: defaultNumberFormat,
it: defaultNumberFormat,
es: defaultNumberFormat,
zh: defaultNumberFormat,
"zh-tw": defaultNumberFormat,
},
};
});
+39 -6
View File
@@ -9,8 +9,12 @@
"subheader": "Geräte verwalten, die auf Ihr Drop Konto zugreifen dürfen.",
"title": "Geräte"
},
"home": {
"title": "Startseite"
},
"notifications": {
"all": "Alles anzeigen {arrow}",
"clear": "Benachrichtigungen entfernen",
"desc": "Anzeigen und Verwalten deiner Benachrichtigung.",
"markAllAsRead": "Markiere alle als gelesen",
"markAsRead": "Als gelesen Markieren",
@@ -19,6 +23,9 @@
"title": "Benachrichtigungen",
"unread": "Ungelesene Benachrichtigungen"
},
"security": {
"title": "Sicherheit"
},
"settings": "Einstellungen",
"title": "Kontoeinstellungen",
"token": {
@@ -69,19 +76,20 @@
"register": {
"confirmPasswordFormat": "Muss mit oben genanntem übereinstimmen",
"emailFormat": "Muss im Format nutzer{'@'}beispiel.de sein",
"passwordFormat": "Muss mindestens 14 Zeichen enthalten",
"passwordFormat": "Muss mindestens 8 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}",
"externalProvider": "Externer Anbieter",
"forgot": "Passwort vergessen?",
"noAccount": "Noch kein Konto? Bitte den Admin, eines für dich zu erstellen.",
"or": "ODER",
"pageTitle": "Bei Drop anmelden",
"rememberMe": "Erinnere mich",
"signin": "Anmelden",
"signinWithExternalProvider": "Bei externem Anbieter anmelden {arrow}",
"title": "Melde dich bei deinem Konto an"
},
"signout": "Ausloggen",
@@ -91,6 +99,8 @@
"chars": {
"arrow": "→",
"arrowBack": "←",
"arrowDown": "↓",
"arrowUp": "↑",
"quoted": "\"\"",
"srComma": ", {0}"
},
@@ -100,6 +110,7 @@
"close": "Schließen",
"create": "Erstellen",
"date": "Datum",
"delete": "Löschen",
"deleteConfirm": "Möchtest du \"{0}\" wirklich löschen?",
"divider": "{'|'}",
"edit": "Bearbeiten",
@@ -119,7 +130,6 @@
"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"
@@ -243,6 +253,7 @@
"footer": {
"about": "Über",
"aboutDrop": "Über Drop",
"api": "API Dokumentation",
"comparison": "Vergleich",
"docs": {
"client": "Client Dokumentation",
@@ -283,13 +294,17 @@
"activeInactiveUsers": "Aktive/inaktive Benutzer",
"activeUsers": "Aktive Benutzer",
"allVersionsCombined": "Alle Versionen zusammen",
"availableRam": "({usedRam} / {totalRam})",
"biggestGamesOnServer": "Größte Spiele auf dem Server",
"biggestGamesToDownload": "Die größten Spiele zum Herunterladen",
"cpuUsage": "CPU Nutzung",
"games": "Spiele",
"goToUsers": "Zu den Benutzern",
"inactiveUsers": "Inaktive Benutzer",
"latestVersionOnly": "Nur die neueste Version",
"librarySources": "Bibliotheksquellen",
"numberCores": "({count} Kerne) | ({count} Kerne) | ({count} Kerne)",
"ramUsage": "RAM Nutzung",
"subheader": "Übersicht",
"title": "Startseite",
"users": "Benutzer",
@@ -341,9 +356,11 @@
"installDir": "(Installationsverzeichnis)/",
"launchCmd": "Programm/Befehl starten",
"launchDesc": "Ausführbare Datei zum starten des Spiels",
"launchPlaceholder": "spiel.exe",
"launchPlaceholder": "spiel.exe --args",
"loadingVersion": "Lade Versionsmetadaten…",
"noAdv": "Keine erweiterten Optionen für diese Konfiguration.",
"noLaunches": "Keine Startkonfigurationen hinzugefügt.",
"noSetups": "Keine Einrichtungskonfigurationen hinzugefügt.",
"noVersions": "Keine Version zum importieren",
"platform": "Plattformversion",
"setupCmd": "Installationsprogramm oder Befehl ausführen",
@@ -479,7 +496,6 @@
"search": "Durchsuche Bibliothek…",
"subheader": "Verwalte deine Spiele in Sammlungen für einen einfacheren Zugriff auf alle deine Spiele."
},
"lowest": "Niedrigste",
"news": {
"article": {
"add": "Hinzufügen",
@@ -512,8 +528,17 @@
"title": "Neueste Neuigkeiten"
},
"options": "Einstellungen",
"security": "Sicherheit",
"selectLanguage": "Sprache auswählen",
"services": {
"nginx": {
"description": "Integrierter einfacher Reverse-Proxy, um alle Drop-Komponenten miteinander zu verbinden.",
"title": "NGINX"
},
"torrential": {
"description": "Der interne Download-Server für Drop.",
"title": "Torrential"
}
},
"settings": {
"admin": {
"description": "Konfiguriere Drop Einstellungen",
@@ -626,6 +651,14 @@
"type": "Typ",
"upload": "Hochladen",
"uploadFile": "Datei hochladen",
"user": {
"editProfile": "Profil bearbeiten",
"noActivity": "Keine aktuellen Ereignisse",
"notFound": "Nutzer nicht gefunden",
"recent": "Kürzliche Aktivitäten (TODO)",
"recentSub": "Kürzliche Aktivitäten von diesem Nutzer",
"unknown": "Unbekannter Nutzer"
},
"userHeader": {
"closeSidebar": "Seitenleiste schließen",
"links": {
+3 -3
View File
@@ -1,7 +1,4 @@
{
"setup": {
"welcome": "G'day."
},
"account": {
"devices": {
"subheader": "Manage the devices authorised to access your Drop account."
@@ -19,5 +16,8 @@
"subheader": "Add a new collection to organise your games"
},
"subheader": "Organise your games into collections for easy access, and access all your games."
},
"setup": {
"welcome": "G'day."
}
}
+12 -7
View File
@@ -11,6 +11,7 @@
},
"notifications": {
"all": "Gaze upon all {arrow}",
"clear": "Notifications walk the plank, eh?",
"desc": "View and manage yer messages from the crows' nest.",
"markAllAsRead": "Mark all as read, aye!",
"markAsRead": "Mark as read, matey!",
@@ -20,7 +21,13 @@
"unread": "Unread Messages"
},
"settings": "Settings",
"title": "Yer Own Coffer"
"title": "Yer Own Coffer",
"token": {
"name": "Key engraving",
"nameDesc": "What here be inscribed on this key.",
"subheader": "Keep 'yer keys to your treasure close.",
"title": "Treasure keys"
}
},
"actions": "Deeds",
"add": "Add",
@@ -49,7 +56,7 @@
"register": {
"confirmPasswordFormat": "Must be the same as above, savvy?",
"emailFormat": "Must be in the fashion of a true scallywag {'@'} example.com",
"passwordFormat": "Must be 14 or more marks, ye landlubber!",
"passwordFormat": "Must be 8 or more marks, ye landlubber!",
"subheader": "Fill in yer details below to make yer mark.",
"title": "Forge yer Drop Mark",
"usernameFormat": "Must be 5 or more marks, and all lowercase, argh!"
@@ -80,6 +87,7 @@
"close": "Shut yer trap!",
"create": "Forge!",
"date": "Date",
"delete": "Scuttle!",
"deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?",
"divider": "{'|'}",
"edit": "Amend",
@@ -97,7 +105,6 @@
"tags": "Marks",
"today": "Today"
},
"delete": "Scuttle!",
"drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!",
"drop": "Drop"
@@ -291,7 +298,7 @@
"installDir": "(install_dir)/",
"launchCmd": "Launch executable/command, argh!",
"launchDesc": "Executable to launch the game, matey!",
"launchPlaceholder": "game.exe, aye!",
"launchPlaceholder": "game.exe --args",
"loadingVersion": "Loading version charts…",
"noAdv": "No advanced options for this rig, argh.",
"noVersions": "No versions to import, savvy!",
@@ -370,7 +377,6 @@
"search": "Search treasure hoard, ye dog…",
"subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!"
},
"lowest": "lowest",
"news": {
"article": {
"add": "Add, ye dog!",
@@ -403,7 +409,6 @@
"title": "Latest News from the High Seas"
},
"options": "Options, matey!",
"security": "Safety",
"selectLanguage": "Pick yer tongue",
"settings": "Settings",
"store": {
@@ -448,7 +453,7 @@
}
},
"title": "Drop",
"titleTemplate": "{0} | Drop",
"titleTemplate": "{0} - Drop",
"todo": "Todo, argh!",
"type": "Type",
"upload": "Hoist!",
+224 -80
View File
@@ -9,47 +9,102 @@
"subheader": "Manage the devices authorized to access your Drop account.",
"title": "Devices"
},
"home": {
"title": "Home"
},
"notifications": {
"all": "View all {arrow}",
"clear": "Clear notifications",
"desc": "View and manage your notifications.",
"markAllAsRead": "Mark all as read",
"clear": "Clear notifications",
"markAsRead": "Mark as read",
"none": "No notifications",
"notifications": "Notifications",
"title": "Notifications",
"unread": "Unread Notifications"
},
"security": {
"2fa": {
"superlevelHint": {
"signin": "Sign in {arrow}",
"success": "You have access to these protected actions.",
"title": "Sign in again to access these settings."
},
"title": "Two-factor authentication",
"totp": {
"description": "TOTP generates one-time codes, completely offline. You can use any TOTP authenticator you like.",
"disableButton": "Disable",
"title": "TOTP"
},
"webauthn": {
"bypassHint": "Also lets you bypass signing in with compatible devices.",
"description": "Otherwise known as passkeys. Authenticate using biometrics, a device, YubiKeys, or any compatible FIDO2 device.",
"manage": "Manage",
"modal": {
"description": "Create new keys or remove existing keys from your account.",
"new": "New key",
"tableCreated": "Created",
"tableName": "Name",
"title": "WebAuthn Keys"
},
"title": "WebAuthn"
}
},
"title": "Security"
},
"settings": "Settings",
"title": "Account Settings",
"token": {
"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",
"expiry6Month": "6 months",
"expiryMonth": "A month",
"expiryYear": "A year",
"name": "API token name",
"nameDesc": "The name of the token, for reference.",
"namePlaceholder": "My New Token",
"noExpiry": "No expiry",
"noTokens": "No tokens connected to your account.",
"revoke": "Revoke",
"subheader": "Manage your API tokens, and what they can access.",
"success": "Successfully created token.",
"successNote": "Make sure to copy it now, as it won't be shown again."
},
"settings": "Settings",
"title": "Account Settings"
"successNote": "Make sure to copy it now, as it won't be shown again.",
"title": "API Tokens"
}
},
"actions": "Actions",
"add": "Add",
"adminTitle": "Admin Dashboard - Drop",
"adminTitleTemplate": "{0} - Admin - Drop",
"adminTitle": "Admin Dashboard - {0}",
"adminTitleTemplate": "{0} - Admin - {1}",
"auth": {
"2fa": {
"backToOptions": "{arrow} Back to options",
"description": "Two-factor authentication is enabled on your account. Choose one of the options below to continue.",
"passkey": {
"createDescription": "WebAuthn, or passkeys, allow you to sign in or complete 2FA with biometrics or hardware security devices.",
"createTitle": "Create a passkey",
"description": "Use a passkey, like biometrics, a hardware security device, or other compatible device to sign in to your Drop account.",
"passkeyNameTag": "Name",
"signinButton": "Sign in with WebAuthn",
"title": "WebAuthn"
},
"success": {
"back": "{arrow} Back to account security",
"description": "Drop has successfully created and added your 2FA method. If this is your first time configuring 2FA, your account now requires it to sign in.",
"title": "Added your 2FA method!"
},
"title": "Two-factor authentication",
"totp": {
"createDescription": "Use your TOTP authenticator, like Google Authenticator, Aegis, or Bitwarden, to add 2FA to your Drop account.",
"createHint": "Enter the generated code to enable TOTP",
"createTitle": "Set up your authenticator",
"description": "Use a one-time code to sign in to your Drop account.",
"title": "TOTP"
}
},
"callback": {
"authClient": "Authorize client?",
"authorize": "Authorize",
@@ -72,20 +127,23 @@
"register": {
"confirmPasswordFormat": "Must be the same as above",
"emailFormat": "Must be in the format user{'@'}example.com",
"passwordFormat": "Must be 14 or more characters",
"passwordFormat": "Must be 8 or more characters",
"subheader": "Fill in your details below to create your account.",
"title": "Create your Drop account",
"usernameFormat": "Must be 5 or more characters, and lowercase"
},
"signin": {
"externalProvider": "Sign in with external provider {arrow}",
"externalProvider": "external provider",
"forgot": "Forgot password?",
"noAccount": "Don't have an account? Ask an admin to create one for you.",
"noAccountProtected": "We need you to sign in again for security reasons while attempting to access more sensitive actions.",
"or": "OR",
"pageTitle": "Sign in to Drop",
"rememberMe": "Remember me",
"signin": "Sign in",
"title": "Sign in to your account"
"signinWithExternalProvider": "Sign in with {externalProvider} {arrow}",
"title": "Sign in to your account",
"titleProtected": "Sign in to access protected action"
},
"signout": "Signout",
"username": "Username"
@@ -94,35 +152,43 @@
"chars": {
"arrow": "→",
"arrowBack": "←",
"quoted": "\"\"",
"arrowDown": "",
"arrowUp": "↑",
"srComma": ", {0}"
},
"common": {
"add": "Add",
"cannotUndo": "This action cannot be undone.",
"close": "Close",
"components": {
"multiitem": {
"new": "Create new: \"{0}\"",
"placeholder": "Start typing..."
}
},
"create": "Create",
"date": "Date",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete \"{0}\"?",
"divider": "{'|'}",
"edit": "Edit",
"friends": "Friends",
"groups": "Groups",
"insert": "Insert",
"labelValueColon": "{label}: {value}",
"name": "Name",
"noData": "No data",
"noResults": "No results",
"noSelected": "No items selected.",
"remove": "Remove",
"save": "Save",
"saved": "Saved",
"select": "Select",
"servers": "Servers",
"srLoading": "Loading…",
"tags": "Tags",
"today": "Today",
"labelValueColon": "{label}: {value}",
"noData": "No data"
"today": "Today"
},
"delete": "Delete",
"drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty.",
"drop": "Drop"
@@ -246,6 +312,7 @@
"footer": {
"about": "About",
"aboutDrop": "About Drop",
"api": "API documentation",
"comparison": "Comparison",
"docs": {
"client": "Client Docs",
@@ -265,14 +332,15 @@
"header": {
"admin": {
"admin": "Admin",
"metadata": "Meta",
"settings": {
"title": "Settings",
"store": "Store",
"tokens": "API tokens"
},
"home": "Home",
"library": "Library",
"metadata": "Meta",
"settings": {
"general": "General Settings",
"store": "Store",
"title": "Settings",
"tokens": "API tokens"
},
"tasks": "Tasks",
"users": "Users"
},
@@ -280,23 +348,26 @@
"openSidebar": "Open sidebar"
},
"helpUsTranslate": "Help us translate Drop {arrow}",
"highest": "highest",
"home": {
"admin": {
"title": "Home",
"subheader": "Instance summary",
"games": "Games",
"librarySources": "Library sources",
"version": "Version",
"activeInactiveUsers": "Active/inactive users",
"activeUsers": "Active users",
"inactiveUsers": "Inactive users",
"goToUsers": "Go to users",
"users": "Users",
"biggestGamesToDownload": "Biggest games to download",
"latestVersionOnly": "Latest version only",
"allVersionsCombined": "All versions combined",
"availableRam": "({usedRam} / {totalRam})",
"biggestGamesOnServer": "Biggest games on server",
"allVersionsCombined": "All versions combined"
"biggestGamesToDownload": "Biggest games to download",
"cpuUsage": "CPU usage",
"games": "Games",
"goToUsers": "Go to users",
"inactiveUsers": "Inactive users",
"latestVersionOnly": "Latest version only",
"librarySources": "Library sources",
"numberCores": "({count} cores) | ({count} core) | ({count} cores)",
"ramUsage": "RAM usage",
"subheader": "Instance summary",
"title": "Home",
"users": "Users",
"version": "Version"
}
},
"library": {
@@ -305,6 +376,10 @@
"admin": {
"detectedGame": "Drop has detected you have new games to import.",
"detectedVersion": "Drop has detected you have new versions of this game to import.",
"fileExtSelector": {
"add": "Add \"{0}\"",
"noSelected": "No extensions selected."
},
"game": {
"addCarouselNoImages": "No images to add.",
"addDescriptionNoImages": "No images to add.",
@@ -325,10 +400,14 @@
"setCover": "Set as cover"
},
"gameLibrary": "Game Library",
"gameSelector": {
"hint": "Type at least 4 characters to get results"
},
"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",
"importAs": "Import as",
"link": "Import {arrow}",
"loading": "Loading game results…",
"search": "Search",
@@ -339,44 +418,62 @@
"selectGameSearch": "Select game",
"selectPlatform": "Please select a platform…",
"version": {
"advancedOptions": "Advanced options",
"displayName": "Display Name",
"displayNameDesc": "Optionally, set the display name of the version. If not set, uses the name in the dropdown.",
"displayNamePlaceholder": "My New Version",
"import": "Import version",
"installDir": "(install_dir)/",
"launchCmd": "Launch executable/command",
"launchDesc": "Executable to launch the game",
"launchPlaceholder": "game.exe",
"launchPlaceholder": "game.exe --args",
"loadingVersion": "Loading version metadata…",
"noAdv": "No advanced options for this configuration.",
"noLaunches": "No launch configurations added.",
"noNameProvided": "No name provided.",
"noSetups": "No setup configurations added.",
"noVersions": "No versions to import",
"platform": "Version platform",
"setupCmd": "Setup executable/command",
"setupDesc": "Ran once when the game is installed",
"setupMode": "Setup mode",
"setupModeDesc": "When enabled, this version does not have a launch command, and simply runs the executable on the user's computer. Useful for games that only distribute installer and not portable files.",
"setupPlaceholder": "setup.exe",
"umuLauncherId": "UMU Launcher ID",
"umuOverride": "Override UMU Launcher Game ID",
"umuOverrideDesc": "By default, Drop uses a non-ID when launching with UMU Launcher. In order to get the right patches for some games, you may have to manually set this field.",
"updateMode": "Update mode",
"updateModeDesc": "When enabled, these files will be installed on top of (overwriting) the previous version's. If multiple \"update modes\" are chained together, they are applied in order.",
"version": "Select version to import"
},
"withoutMetadata": "Import without metadata"
},
"launchRow": {
"autosuggestHint": "Auto-suggest extensions",
"currentDirHint": "The installation directory is set as the current directory when launching. It is not prepended to your command.",
"emulatorHint": "{rom} is replaced with the game's launch command for emulators.",
"emulatorSelect": "Select new emulator",
"emulatorTitle": "Emulator",
"noEmulatorSelected": "No emulator selected"
},
"launchSelector": {
"description": "Select a launch option as an emulator for your new launch option.",
"noVersions": "No versions imported.",
"platformFilterHint": "Only showing launches for:",
"search": "Search for an emulator",
"selectCommand": "Select a launch command",
"selectVersions": "Select a version",
"title": "Select a launch option"
},
"libraryHint": "No libraries configured.",
"libraryHintDocsLink": "What does this mean? {arrow}",
"massImportTool": "Mass Import Tool",
"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",
@@ -420,9 +517,28 @@
}
},
"metadataProvider": "Metadata provider",
"nav": {
"backPagination": "Previous",
"clearAllFilters": "Clear all",
"filterCount": "{0} filters",
"filterLabel": "Filters",
"filters": {
"metadata": {
"emptyDescription": "Empty description",
"featured": "Featured",
"noCarousel": "No images in carousel",
"title": "Metadata"
},
"version": {
"available": "Available to import",
"none": "No versions imported",
"title": "Versions"
}
},
"nextPagination": "Next",
"sortLabel": "Sort"
},
"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}",
@@ -434,6 +550,7 @@
"desc": "Configure your library sources, where Drop will look for new games and versions to import.",
"documentationLink": "Documentation {arrow}",
"edit": "Edit source",
"freeSpace": "Free space",
"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",
@@ -445,21 +562,27 @@
"nameDesc": "The name of your source, for reference.",
"namePlaceholder": "My New Source",
"sources": "Library Sources",
"typeDesc": "The type of your source. Changes the required options.",
"working": "Working?",
"freeSpace": "Free space",
"totalSpace": "Total space",
"typeDesc": "The type of your source. Changes the required options.",
"utilizationPercentage": "Utilization percentage",
"percentage": "{number}%"
"working": "Working?"
},
"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",
"version": {
"delta": "Upgrade mode",
"description": "All versions imported for your game.",
"noSetups": "No setups configured.",
"noVersions": "You have no versions of this game available.",
"noVersionsAdded": "no versions added"
},
"versionPriority": "Version priority"
"setupOnly": "Version configured as in setup-only mode.",
"table": {
"delta": "Update mode",
"launch": "Launch Configurations",
"name": "Name (ID)",
"path": "Path",
"setup": "Setup Configurations"
},
"title": "Versions"
}
},
"back": "Back to Library",
"collection": {
@@ -482,7 +605,6 @@
"search": "Search library…",
"subheader": "Organize your games into collections for easy access, and access all your games."
},
"lowest": "lowest",
"news": {
"article": {
"add": "Add",
@@ -515,11 +637,30 @@
"title": "Latest News"
},
"options": "Options",
"security": "Security",
"selectLanguage": "Select language",
"services": {
"nginx": {
"description": "Built-in simple reverse proxy to connect all the Drop components together.",
"title": "NGINX"
},
"torrential": {
"description": "The internal download server for Drop.",
"title": "Torrential"
}
},
"settings": {
"admin": {
"description": "Configure Drop settings",
"general": {
"applicationLogo": "Application logo",
"customLogo": "Custom logo",
"defaultLogo": "Default logo",
"logo": "Logo",
"serverName": "Server name",
"serverNameDescription": "The name of the server",
"serverNamePlaceholder": "My Drop Instance",
"title": "General settings",
"uploadLogo": "Upload logo"
},
"store": {
"dropGameAltPlaceholder": "Example Game icon",
"dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.",
@@ -563,10 +704,8 @@
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works."
},
"store": {
"about": "About",
"commingSoon": "coming soon",
"developers": "Developers | Developer | Developers",
"exploreMore": "Explore more {arrow}",
"featured": "Featured",
"images": "Game Images",
"lookAt": "Check it out",
@@ -580,15 +719,12 @@
"openFeatured": "Star games in Admin Library {arrow}",
"platform": "Platform | Platform | Platforms",
"publishers": "Publishers | Publisher | Publishers",
"size": "Size",
"rating": "Rating",
"readLess": "Click to read less",
"readMore": "Click to read more",
"recentlyAdded": "Recently Added",
"recentlyReleased": "Recently released",
"recentlyUpdated": "Recently Updated",
"released": "Released",
"reviews": "({0} Reviews)",
"size": "Size",
"tags": "Tags",
"title": "Store",
"view": {
@@ -597,15 +733,17 @@
"srGames": "Games",
"srViewGrid": "View grid"
},
"viewInStore": "View in Store",
"website": "Website"
"viewInStore": "View in Store"
},
"tasks": {
"admin": {
"back": "{arrow} Back to Tasks",
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"execute": "{arrow} Execute",
"noActions": "No actions",
"noTasksRunning": "No tasks currently running",
"progress": "{0}%",
"runningTasksTitle": "Running tasks",
"scheduled": {
"checkUpdateDescription": "Check if Drop has an update.",
@@ -618,9 +756,7 @@
"cleanupSessionsName": "Clean up sessions."
},
"viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly scheduled tasks",
"progress": "{0}%",
"execute": "{arrow} Execute"
"weeklyScheduledTitle": "Weekly scheduled tasks"
}
},
"title": "Drop",
@@ -629,6 +765,14 @@
"type": "Type",
"upload": "Upload",
"uploadFile": "Upload file",
"user": {
"editProfile": "Edit profile",
"noActivity": "No recent activity",
"notFound": "User not found",
"recent": "Recent activity (TODO)",
"recentSub": "Recent activity by this user",
"unknown": "Unknown user"
},
"userHeader": {
"closeSidebar": "Close sidebar",
"links": {
@@ -645,6 +789,7 @@
"admin": {
"adminHeader": "Admin?",
"adminUserLabel": "Admin user",
"authLink": "Authentication {arrow}",
"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.",
@@ -656,7 +801,6 @@
"srOpenOptions": "Open options",
"title": "Authentication"
},
"authLink": "Authentication {arrow}",
"authoptionsHeader": "Auth Options",
"delete": "Delete",
"deleteUser": "Delete user {0}",
+39 -6
View File
@@ -9,8 +9,12 @@
"subheader": "Gérer les appareils authorisés à accéder à votre compte Drop.",
"title": "Appareils"
},
"home": {
"title": "Accueil"
},
"notifications": {
"all": "Voir tout {arrow}",
"clear": "Effacer les notifications",
"desc": "Voir et gérer vos notifications.",
"markAllAsRead": "Marquer tout comme lu",
"markAsRead": "Marquer comme lu",
@@ -19,6 +23,9 @@
"title": "Notifications",
"unread": "Notifications Non Lues"
},
"security": {
"title": "Sécurité"
},
"settings": "Paramètres",
"title": "Paramètres du Compte",
"token": {
@@ -69,19 +76,20 @@
"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",
"passwordFormat": "Doit être au moins 8 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}",
"externalProvider": "un fournisseur externe",
"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",
"signinWithExternalProvider": "Connectez vous avec {externalProvider} {arrow}",
"title": "Se connecter à votre compte"
},
"signout": "Déconnexion",
@@ -91,6 +99,8 @@
"chars": {
"arrow": "→",
"arrowBack": "←",
"arrowDown": "↓",
"arrowUp": "↑",
"quoted": "\"\"",
"srComma": ", {0}"
},
@@ -100,6 +110,7 @@
"close": "Fermer",
"create": "Créer",
"date": "Date",
"delete": "Supprimer",
"deleteConfirm": "Êtes vous sûr de vouloir supprimer \"{0}\" ?",
"divider": "{'|'}",
"edit": "Éditer",
@@ -119,7 +130,6 @@
"tags": "Étiquettes",
"today": "Aujourd'hui"
},
"delete": "Supprimer",
"drop": {
"desc": "Une plateforme de distribution libre conçue pour être rapide, flexible et belle.",
"drop": "Drop"
@@ -243,6 +253,7 @@
"footer": {
"about": "À propos",
"aboutDrop": "À propos de Drop",
"api": "Documentation de l'API",
"comparison": "Comparaison",
"docs": {
"client": "Documentation du client",
@@ -283,13 +294,17 @@
"activeInactiveUsers": "Utilisateurs actifs/inactifs",
"activeUsers": "Utilisateurs actifs",
"allVersionsCombined": "Toutes les versions combinées",
"availableRam": "({usedRam} / {totalRam})",
"biggestGamesOnServer": "Les plus gros jeux sur le serveur",
"biggestGamesToDownload": "Les plus gros jeux à télécharger",
"cpuUsage": "Utilisation du processeur",
"games": "Jeux",
"goToUsers": "Aller aux utilisateurs",
"inactiveUsers": "Utilisateurs inactifs",
"latestVersionOnly": "Dernière version seulement",
"librarySources": "Sources de bibliothèques",
"numberCores": "({count} cœur) | ({count} cœur) | ({count} cœurs)",
"ramUsage": "Utilisation de la mémoire vive",
"subheader": "Résumé de l'instance",
"title": "Accueil",
"users": "Utilisateurs",
@@ -341,9 +356,11 @@
"installDir": "(install_dir)/",
"launchCmd": "Lancer l'exécutable/commande",
"launchDesc": "Exécutable pour lancer le jeu",
"launchPlaceholder": "jeu.exe",
"launchPlaceholder": "jeu.exe --args",
"loadingVersion": "Chargement des métadonnées de la version…",
"noAdv": "Pas d'option avancée pour cette configuration.",
"noLaunches": "Aucune configuration de lancement ajoutée.",
"noSetups": "Aucune configuration d'installation ajoutée.",
"noVersions": "Pas de version à importer",
"platform": "Version de la plateforme",
"setupCmd": "Exécutable/commande d'installation",
@@ -479,7 +496,6 @@
"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",
@@ -512,8 +528,17 @@
"title": "Dernières Nouvelles"
},
"options": "Options",
"security": "Sécurité",
"selectLanguage": "Sélectionner la langue",
"services": {
"nginx": {
"description": "Proxy inverse simple intégré pour connecter tous les composants Drop.",
"title": "NGINX"
},
"torrential": {
"description": "Le server de téléchargement interne de Drop.",
"title": "Torrential"
}
},
"settings": {
"admin": {
"description": "Configurer les paramètres de Drop",
@@ -626,6 +651,14 @@
"type": "Type",
"upload": "Uploader",
"uploadFile": "Uploader fichier",
"user": {
"editProfile": "Éditer profil",
"noActivity": "Pas d'activité récente",
"notFound": "Utilisateur introuvable",
"recent": "Activité récente (À faire)",
"recentSub": "Activité récente de cet utilisateur",
"unknown": "Utilisateur inconnu"
},
"userHeader": {
"closeSidebar": "Fermer la barre latérale",
"links": {
+709
View File
@@ -0,0 +1,709 @@
{
"account": {
"devices": {
"capabilities": "Możliwości",
"lastConnected": "Ostatnio Połączone",
"noDevices": "Brak urządzeń połączonych z Twoim kontem.",
"platform": "Platforma",
"revoke": "Usuń",
"subheader": "Zarządzaj urządzeniami z dostępem do Twojego konta Drop.",
"title": "Urządzenia"
},
"notifications": {
"all": "Zobacz wszystkie {arrow}",
"clear": "Wyczyść powiadomienia",
"desc": "Przeglądaj i zarządzaj powiadomieniami.",
"markAllAsRead": "Oznacz wszystkie jako przeczytane",
"markAsRead": "Oznacz jako przeczytane",
"none": "Brak powiadomień",
"notifications": "Powiadomienia",
"title": "Powiadomienia",
"unread": "Nieprzeczytane Powiadomienia"
},
"settings": "Ustawienia",
"title": "Ustawienia Konta",
"token": {
"acls": "ACLe/zakresy",
"aclsDesc": "Definiuje zakres uprawnień tego tokena. Unikaj zaznaczania wszystkich uprawnień ACL, jeśli nie są one potrzebne.",
"expiry": "Data Ważności",
"expiry3Month": "3 miesiące",
"expiry5Year": "5 lat",
"expiry6Month": "6 miesięcy",
"expiryMonth": "Miesiąc",
"expiryYear": "Rok",
"name": "Nazwa tokena API",
"nameDesc": "Nazwa tokena (do identyfikacji).",
"namePlaceholder": "Mój Nowy Token",
"noExpiry": "Bezterminowy",
"noTokens": "Brak tokenów podłączonych do Twojego konta.",
"revoke": "Usuń",
"subheader": "Zarządzaj swoimi tokenami API oraz tym, do czego mają dostęp.",
"success": "Pomyślnie utworzono token.",
"successNote": "Upewnij się aby skopiować go teraz, ponieważ nie będzie wyświetlany ponownie.",
"title": "Tokeny API"
}
},
"actions": "Akcje",
"add": "Dodaj",
"adminTitle": "Panel Administratora - Drop",
"adminTitleTemplate": "{0} - Administrator - Drop",
"auth": {
"callback": {
"authClient": "Autoryzować klienta?",
"authorize": "Autoryzuj",
"authorizedClient": "Drop pomyślnie autoryzował klienta. Możesz teraz zamknąć to okno.",
"issues": "Masz problemy?",
"learn": "Dowiedz się więcej {arrow}",
"paste": "Wklej ten kod do klienta aby kontynuować:",
"permWarning": "Akceptowanie tego żądania pozwoli \"{name}\" na platformie \"{platform}\" na:",
"requestedAccess": "\"{name}\" poprosił o dostęp do twojego konta Drop.",
"success": "Pomyślnie!"
},
"code": {
"description": "Użyj kodu aby połączyć twojego klienta Drop jeżeli nie jesteś w stanie otworzyć przeglądarki na swoim urządzeniu.",
"title": "Połącz swojego klienta Drop"
},
"confirmPassword": "Potwierdź @:auth.password",
"displayName": "Nazwa Wyświetlana",
"email": "Email",
"password": "Hasło",
"register": {
"confirmPasswordFormat": "Musi być takie samo jak powyżej",
"emailFormat": "Musi być w formacie uzytkownik{'@'}example.com",
"passwordFormat": "Musi mieć conajmniej 8 znaków",
"subheader": "Wpisz poniżej swoje dane, aby utworzyć swoje konto.",
"title": "Stwórz swoje konto Drop",
"usernameFormat": "Musi mieć co najmniej 5 znaków i małe litery"
},
"signin": {
"externalProvider": "Zaloguj się za pomocą zewnętrznego dostawcy {arrow}",
"forgot": "Zapomniałeś hasła?",
"noAccount": "Nie posiadasz konta? Poproś administratora żeby ci je stworzył.",
"or": "LUB",
"pageTitle": "Zaloguj się do Drop",
"rememberMe": "Zapamiętaj mnie",
"signin": "Zaloguj się",
"title": "Zaloguj się do swojego konta"
},
"signout": "Wyloguj się",
"username": "Nazwa Użytkownika"
},
"cancel": "Anuluj",
"chars": {
"arrow": "→",
"arrowBack": "←",
"quoted": "\"\"",
"srComma": ", {0}"
},
"common": {
"add": "Dodaj",
"cannotUndo": "Ta czynność nie może zostać cofnięta.",
"close": "Zamknij",
"create": "Utwórz",
"date": "Data",
"delete": "Usuń",
"deleteConfirm": "Czy jesteś pewny że chcesz usunąć \"{0}\"?",
"divider": "{'|'}",
"edit": "Edytuj",
"friends": "Znajomi",
"groups": "Grupy",
"insert": "Wstaw",
"labelValueColon": "{label}: {value}",
"name": "Nazwa",
"noData": "Brak danych",
"noResults": "Brak wyników",
"noSelected": "Nie wybrano żadnych elementów.",
"remove": "Usuń",
"save": "Zapisz",
"saved": "Zapisano",
"servers": "Serwery",
"srLoading": "Ładowanie…",
"tags": "Tagi",
"today": "Dzisiaj"
},
"drop": {
"desc": "Platforma typu open source do dystrybucji gier, stworzona z myślą o szybkości, elastyczności i estetyce.",
"drop": "Drop"
},
"editor": {
"bold": "Pogrubienie",
"boldPlaceholder": "pogrubiony tekst",
"code": "Kod",
"codePlaceholder": "kod",
"heading": "Nagłówek",
"headingPlaceholder": "nagłówek",
"italic": "Kursywa",
"italicPlaceholder": "tekst kursywny",
"link": "Link",
"linkPlaceholder": "tekst linku",
"listItem": "Element listy",
"listItemPlaceholder": "element listy"
},
"errors": {
"admin": {
"user": {
"delete": {
"desc": "Drop nie mógł usunąć tego użytkownika: {0}",
"title": "Nie udało się usunąć użytkownika"
}
}
},
"auth": {
"disabled": "Nieprawidłowe lub wyłączone konto. Prosze skontaktuj się z administratorem serwera.",
"invalidInvite": "Nieprawidłowe lub wygasłe zaproszenie",
"invalidPassState": "Nieprawidłowe hasło. Skontaktuj się z administratorem serwera.",
"invalidUserOrPass": "Nieprawidłowa nazwa użytkownika i hasło.",
"inviteIdRequired": "identyfikator wymagany do pobrania zaproszenia",
"method": {
"signinDisabled": "Metoda logowania nie jest włączona"
},
"usernameTaken": "Nazwa użytkownika jest już zajęta."
},
"backHome": "{arrow} Powrót do domu",
"externalUrl": {
"subtitle": "Ta wiadomość jest widoczna tylko dla administratorów.",
"title": "Dostęp poprzez inny EXTERNAL_URL. Proszę sprawdzić dokumentację."
},
"game": {
"banner": {
"description": "Drop nie udało się zaktualizować obrazu banera: {0}",
"title": "Nie udało się zaktualizować obrazu banera"
},
"carousel": {
"description": "Drop nie udało się zaktualizować karuzeli obrazów: {0}",
"title": "Nie udało się zaktualizować karuzeli obrazów"
},
"cover": {
"description": "Drop nie udało się zaktualizować obrazu okładki: {0}",
"title": "Nie udało się zaktualizować obrazu okładki"
},
"deleteImage": {
"description": "Drop nie udało się usunąć obrazu: {0}",
"title": "Nie udało się usunąć obrazu"
},
"description": {
"description": "Drop nie udało się zaktualizować opisu gry: {0}",
"title": "Nie udało się zaktualizować opisu gry"
},
"metadata": {
"description": "Drop nie udało się zaktualizować metadanych gry: {0}",
"title": "Nie udało się zaktualizować metadanych"
}
},
"invalidBody": "Nieprawidłowa treść żądania: {0}",
"inviteRequired": "Do rejestracji wymagane jest zaproszenie.",
"library": {
"add": {
"desc": "Drop nie mógł dodać tej gry do twojej biblioteki: {0}",
"title": "Nie udało się dodać gry do biblioteki"
},
"collection": {
"create": {
"desc": "Drop nie mógł utworzyć twojej kolekcji: {0}",
"title": "Nie udało się utworzyć kolekcji"
}
},
"source": {
"delete": {
"desc": "Drop nie mógł usunąć tego źródła: {0}",
"title": "Nie udało się usunąć źródła biblioteki"
}
}
},
"news": {
"article": {
"delete": {
"desc": "Drop nie mógł usunąć tego artykułu: {0}",
"title": "Nie udało się usunąć artykułu"
}
}
},
"occurred": "Wystąpił błąd podczas odpowiadania na Twoje żądanie. Jeśli uważasz, że jest to błąd, zgłoś go. Spróbuj się zalogować i sprawdź, czy to rozwiąże problem.",
"ohNo": "O nie!",
"pageTitle": "{0} | Drop",
"revokeClient": "Nie udało się usunąć klienta",
"revokeClientFull": "Nie udało się usunąć klienta {0}",
"signIn": "Zaloguj się {arrow}",
"support": "Discord Wsparcia",
"unknown": "Wystąpił nieznany błąd",
"upload": {
"description": "Drop nie mógł przesłać pliku: {0}",
"title": "Nie udało się przesłać pliku"
},
"version": {
"delete": {
"desc": "Drop napotkał błąd podczas usuwania wersji: {error}",
"title": "Wystąpił błąd podczas usuwania wersji"
},
"order": {
"desc": "Drop napotkał błąd podczas aktualizowania wersji: {error}",
"title": "Wystąpił błąd podczas aktualizacji kolejności wersji"
}
}
},
"footer": {
"about": "O",
"aboutDrop": "O Drop",
"comparison": "Porównanie",
"docs": {
"client": "Dokumentacja Klienta",
"server": "Dokumentacja Serwera"
},
"documentation": "Dokumentacja",
"findGame": "Znajdź grę",
"footer": "Stopka",
"games": "Gry",
"social": {
"discord": "Discord",
"github": "GitHub"
},
"topSellers": "Bestsellery",
"version": "Drop {version} {gitRef}"
},
"header": {
"admin": {
"admin": "Administrator",
"home": "Strona Główna",
"library": "Biblioteka",
"metadata": "Metadane",
"settings": {
"store": "Sklep",
"title": "Ustawienia",
"tokens": "Tokeny API"
},
"tasks": "Zadania",
"users": "Użytkownicy"
},
"back": "Wróć",
"openSidebar": "Otwórz menu boczne"
},
"helpUsTranslate": "Pomóż nam tłumaczyć Drop {arrow}",
"highest": "najwyższe",
"home": {
"admin": {
"activeInactiveUsers": "Aktywni/nieaktywni użytkownicy",
"activeUsers": "Aktywni użytkownicy",
"allVersionsCombined": "Wszystkie wersje łącznie",
"biggestGamesOnServer": "Największe gry na serwerze",
"biggestGamesToDownload": "Największe gry do pobrania",
"games": "Gry",
"goToUsers": "Idź do użytkowników",
"inactiveUsers": "Nieaktywni użytkownicy",
"latestVersionOnly": "Tylko najnowsza wersja",
"librarySources": "Źródła biblioteki",
"subheader": "Podsumowanie Instancji",
"title": "Strona Główna",
"users": "Użytkownicy",
"version": "Wersja"
}
},
"library": {
"addGames": "Wszystkie Gry",
"addToLib": "Dodaj do Biblioteki",
"admin": {
"detectedGame": "Drop wykrył że masz nowe gry do zaimportowania.",
"detectedVersion": "Drop wykrył że masz nowe wersje tej gry do zaimportowania.",
"game": {
"addCarouselNoImages": "Brak obrazów do dodania.",
"addDescriptionNoImages": "Brak obrazów do dodania.",
"addImageCarousel": "Dodaj z galerii obrazów",
"currentBanner": "baner",
"currentCover": "okładka",
"deleteImage": "Usuń obraz",
"editGameDescription": "Opis Gry",
"editGameName": "Nazwa Gry",
"editReleaseDate": "Data Wydania",
"imageCarousel": "Karuzela Obrazów",
"imageCarouselDescription": "Dostosuj, jakie zdjęcia i w jakiej kolejności są wyświetlane na stronie sklepu.",
"imageCarouselEmpty": "Nie dodano jeszcze żadnych zdjęć do karuzeli.",
"imageLibrary": "Biblioteka Obrazów",
"imageLibraryDescription": "Należy pamiętać, że wszystkie przesłane obrazy są dostępne dla wszystkich użytkowników za pośrednictwem narzędzi programistycznych przeglądarki.",
"removeImageCarousel": "Usuń zdjęcie",
"setBanner": "Ustaw jako baner",
"setCover": "Ustaw jako okładke"
},
"gameLibrary": "Biblioteka Gier",
"import": {
"bulkImportDescription": "Na tej stronie nie zostaniesz przekierowany do zadania importowania, więc możesz importować wiele gier po kolei.",
"bulkImportTitle": "Tryb importu zbiorczego",
"import": "Importuj",
"link": "Importuj {arrow}",
"loading": "Ładowanie wyników gry…",
"search": "Szukaj",
"searchPlaceholder": "Fallout 4",
"selectDir": "Wybierz katalog…",
"selectGame": "Wybierz grę do zaimportowania",
"selectGamePlaceholder": "Wybierz grę…",
"selectGameSearch": "Wybierz grę",
"selectPlatform": "Wybierz platformę…",
"version": {
"advancedOptions": "Zaawansowane opcje",
"import": "Zaimportuj wersje",
"installDir": "(katalog_instalacji)/",
"launchCmd": "Plik wykonywalny/polecenie uruchomienia gry",
"launchDesc": "Plik wykonywalny do uruchomienia gry",
"launchPlaceholder": "gra.exe",
"loadingVersion": "Ładowanie metadanych wersji…",
"noAdv": "Brak zaawansowanych opcji dla tej konfiguracji.",
"noVersions": "Brak wersji do zaimportowania",
"platform": "Platforma wersji",
"setupCmd": "Plik wykonywalny/polecenie instalacji gry",
"setupDesc": "Uruchamiany raz po zainstalowaniu gry",
"setupMode": "Tryb instalacji",
"setupModeDesc": "Po włączeniu, ta wersja nie posiada polecenia uruchamiania i po prostu uruchamia plik wykonywalny na komputerze użytkownika. Przydatne w przypadku gier, które dystrybuują tylko instalator, a nie pliki przenośne.",
"setupPlaceholder": "instalator.exe",
"umuLauncherId": "ID UMU Launcher",
"umuOverride": "Nadpisz ID Gry UMU Launcher",
"umuOverrideDesc": "Domyślnie Drop używa identyfikatora non-ID podczas uruchamiania z pomocą UMU Launcher. Aby uzyskać odpowiednie poprawki dla niektórych gier, może być konieczne ręczne ustawienie tego pola.",
"updateMode": "Tryb aktualizacji",
"updateModeDesc": "Po włączeniu, te pliki zostaną zainstalowane nad (nadpisując) poprzednią wersją. Jeśli połączonych jest kilka \"trybów aktualizacji\", są one stosowane w kolejności.",
"version": "Wybierz wersję do zaimportowania"
},
"withoutMetadata": "Importuj bez metadanych"
},
"libraryHint": "Brak skonfigurowanych bibliotek.",
"libraryHintDocsLink": "Co to oznacza? {arrow}",
"metadata": {
"companies": {
"action": "Zarządzaj {arrow}",
"addGame": {
"description": "Wybierz grę, którą chcesz dodać do firmy, i zdecyduj, czy powinna być wymieniona jako producent, wydawca, czy jedno i drugie.",
"developer": "Deweloper?",
"noGames": "Brak gier do dodania",
"publisher": "Wydawca?",
"title": "Połącz grę do tej firmy"
},
"description": "Firmy organizują gry według tego, kto je stworzył lub wydał.",
"editor": {
"action": "Dodaj Grę {plus}",
"descriptionPlaceholder": "{'<'}opis{'>'}",
"developed": "Stworzone przez",
"libraryDescription": "Dodawaj, usuwaj lub dostosowuj treści stworzone i/lub wydane przez tę firmę.",
"libraryTitle": "Biblioteka Gier",
"noDescription": "(brak opisu)",
"published": "Wydane przez",
"uploadBanner": "Prześlij baner",
"uploadIcon": "Prześlij ikonę",
"websitePlaceholder": "{'<'}strona{'>'}"
},
"modals": {
"createDescription": "Stwórz firmę, aby lepiej organizować swoje gry.",
"createFieldDescription": "Opis Firmy",
"createFieldDescriptionPlaceholder": "Małe studio indie które...",
"createFieldName": "Nazwa Firmy",
"createFieldNamePlaceholder": "Moja Nowa Firma...",
"createFieldWebsite": "Strona Firmy",
"createFieldWebsitePlaceholder": "https://example.com/",
"createTitle": "Utwórz firmę",
"nameDescription": "Edytuj nazwę firmy. Służy do dopasowania do nowych importów gier.",
"nameTitle": "Edytuj nazwę firmy",
"shortDeckDescription": "Edytuj opis firmy. Nie wpływa na długi opis (markdown).",
"shortDeckTitle": "Edytuj opis firmy",
"websiteDescription": "Edytuj stronę internetową firmy. Uwaga: będzie to link i nie będzie miał zabezpieczenia przed przekierowaniem.",
"websiteTitle": "Edytuj stronę firmy"
},
"noCompanies": "Brak firm",
"noGames": "Brak gier",
"search": "Szukaj firm…",
"searchGames": "Szukaj gier firmy…",
"title": "Firmy"
},
"tags": {
"action": "Zarządzaj {arrow}",
"create": "Utwórz",
"description": "Tagi są tworzone automatycznie na podstawie importowanych gatunków. Możesz dodawać własne tagi, aby kategoryzować swoją bibliotekę gier.",
"modal": {
"description": "Utwórz tag, aby uporządkować swoją bibliotekę.",
"title": "Utwórz Tag"
},
"title": "Tagi"
}
},
"metadataProvider": "Dostawca Metadanych",
"noGames": "Brak zaimportowanych gier",
"offline": "Drop nie mógł uzyskać dostępu do tej gry.",
"offlineTitle": "Gra Offline",
"openEditor": "Otwórz w Edytorze {arrow}",
"openStore": "Otwórz w Sklepie",
"shortDesc": "Krótki Opis",
"sources": {
"create": "Utwórz źródło",
"createDesc": "Drop użyje tego źródła, aby uzyskać dostęp do Twojej biblioteki gier i je udostępnić.",
"desc": "Skonfiguruj źródła biblioteki, w których Drop będzie wyszukiwał nowe gry i wersje do zaimportowania.",
"documentationLink": "Dokumentacja {arrow}",
"edit": "Edytuj źródło",
"freeSpace": "Wolna przestrzeń",
"fsDesc": "Importuje gry ze ścieżki na dysku. Wymaga struktury folderów opartej na wersjach, i obsługuje gry zarchiwizowane.",
"fsFlatDesc": "Importuje gry ze ścieżki na dysku, ale bez osobnego podfolderu wersji. Przydatne podczas migracji istniejącej biblioteki do Drop.",
"fsFlatTitle": "Kompatybilność",
"fsPath": "Ścieżka",
"fsPathDesc": "Ścieżka absolutna do twojej biblioteki gier.",
"fsPathPlaceholder": "/mnt/games",
"fsTitle": "Styl Drop",
"link": "Źródła {arrow}",
"nameDesc": "Nazwa źródła, do celów referencyjnych.",
"namePlaceholder": "Moje Nowe Źródło",
"percentage": "{number}%",
"sources": "Źródła Biblioteki",
"totalSpace": "Całkowita przestrzeń",
"typeDesc": "Typ twojego źródła. Zmienia wymagane opcje.",
"utilizationPercentage": "Procent wykorzystania",
"working": "Działa?"
},
"subheader": "Gdy dodasz foldery do źródeł biblioteki, Drop je wykryje i poprosi o zaimportowanie. Każda gra musi zostać zaimportowana, zanim będzie można zaimportować jej wersję.",
"title": "Biblioteki",
"version": {
"delta": "Tryb uaktualnienia",
"noVersions": "Nie masz dostępnych wersji tej gry.",
"noVersionsAdded": "nie dodano żadnych wersji"
},
"versionPriority": "Priorytet wersji"
},
"back": "Wróć do Biblioteki",
"collection": {
"addToNew": "Dodaj do nowej kolekcji",
"collections": "Kolekcje",
"create": "Stwórz Kolekcję",
"createDesc": "Kolekcje mogą pomóc Ci uporządkować swoje gry i ułatwić ich odnajdywanie, zwłaszcza jeśli masz dużą bibliotekę.",
"delete": "Usuń Kolekcję",
"namePlaceholder": "Nazwa Kolekcji",
"noCollections": "Brak kolekcji",
"notFound": "Kolekcja nie znaleziona",
"subheader": "Dodaj nową kolekcję aby zorganizować swoje gry",
"title": "Kolekcja"
},
"gameCount": "{0} gry | {0} gra | {0} gier",
"inLib": "W Bibliotece",
"launcherOpen": "Otwórz w Programie Uruchamiającym",
"noGames": "Brak gier w bibliotece",
"notFound": "Gra nie znaleziona",
"search": "Przeszukaj bibliotekę…",
"subheader": "Zorganizuj swoje gry w kolekcje, aby mieć do nich łatwy dostęp i mieć dostęp do wszystkich swoich gier."
},
"lowest": "najniższy",
"news": {
"article": {
"add": "Dodaj",
"content": "Treść (Markdown)",
"create": "Utwórz Nowy Artykuł",
"editor": "Edytor",
"editorGuide": "Użyj powyższych skrótów lub napisz bezpośrednio w Markdown. Obsługuje **pogrubienie**, *kursywę*, [linki](url) i wiele innych.",
"new": "Nowy artykuł",
"preview": "Podgląd",
"shortDesc": "Krótki opis",
"submit": "Zatwierdź",
"tagPlaceholder": "Dodaj tag…",
"titles": "Tytuł",
"uploadCover": "Prześlij zdjęcie okładki"
},
"back": "Wróć do Wiadomości",
"checkLater": "Sprawdź później, czy są jakieś aktualizacje.",
"delete": "Usuń Artykuł",
"filter": {
"all": "Od początku",
"month": "W tym miesiącu",
"week": "W tym tygodniu",
"year": "W tym roku"
},
"none": "Brak artykułów",
"notFound": "Artykuł nie znaleziony",
"search": "Szukaj artykułów",
"searchPlaceholder": "Przeszukaj artykuły…",
"subheader": "Bądź na bieżąco z najnowszymi aktualizacjami i ogłoszeniami.",
"title": "Najnowsze Wiadomości"
},
"options": "Opcje",
"security": "Bezpieczeństwo",
"selectLanguage": "Wybierz język",
"settings": {
"admin": {
"description": "Skonfiguruj ustawienia Drop",
"store": {
"dropGameAltPlaceholder": "Przykładowa ikonka Gry",
"dropGameDescriptionPlaceholder": "To przykładowa gra. Będzie zastąpiona kiedy zaimportujesz grę.",
"dropGameNamePlaceholder": "Przykładowa Gra",
"showGamePanelTextDecoration": "Pokaż tytuł i opis na kafelkach gry (domyślnie: włączone)",
"title": "Sklep"
},
"title": "Ustawienia"
}
},
"setup": {
"auth": {
"description": "Uwierzytelnianie w Drop odbywa się za pośrednictwem wielu skonfigurowanych \"dostawców\". Każdy z nich może umożliwiać użytkownikom logowanie się za pomocą wybranej metody. Aby rozpocząć, należy włączyć co najmniej jednego dostawcę uwierzytelniania i utworzyć u niego konto.",
"docs": "Dokumentacja {arrow}",
"enabled": "Włączone?",
"openid": {
"description": "OpenID Connect (OIDC) to powszechnie obsługiwane rozszerzenie OAuth2. Drop wymaga konfiguracji OIDC za pomocą zmiennych środowiskowych.",
"skip": "Mam użytkownika z OIDC",
"title": "OpenID Connect"
},
"simple": {
"description": "Proste uwierzytelnianie wykorzystuje nazwę użytkownika i hasło do uwierzytelniania użytkowników. Jest ono domyślnie włączone, jeśli żaden inny dostawca uwierzytelniania nie jest włączony.",
"register": "Zarejestruj się jako administrator {arrow}",
"title": "Proste uwierzytelnianie"
},
"title": "Uwierzytelnianie"
},
"finish": "Chodźmy {arrow}",
"noPage": "brak strony",
"stages": {
"account": {
"description": "Potrzebujesz co najmniej jednego konta żeby zacząć używać Drop.",
"name": "Skonfiguruj swoje konto administratora."
},
"library": {
"description": "Dodaj co najmniej jedno źródło biblioteki żeby używać Drop.",
"name": "Utwórz bibliotekę."
}
},
"welcome": "Cześć.",
"welcomeDescription": "Witamy w kreatorze konfiguracji Drop. Poprowadzi Cię on przez proces pierwszej konfiguracji Drop i pokaże, jak to wszystko działa."
},
"store": {
"about": "O",
"commingSoon": "wkrótce",
"developers": "Producentów | Producent | Producentów",
"exploreMore": "Odkryj więcej {arrow}",
"featured": "Wyróżnione",
"images": "Zdjęcia Gry",
"lookAt": "Sprawdź",
"noDevelopers": "Brak producentów",
"noFeatured": "BRAK WYRÓŻNIONYCH GIER",
"noGame": "BRAK GRY",
"noImages": "Brak zdjęć",
"noPublishers": "Brak wydawców.",
"noTags": "Brak tagów",
"openAdminDashboard": "Otwórz w Panelu Administracyjnym",
"openFeatured": "Oznacz gry w Bibliotece Administracyjnej {arrow}",
"platform": "Platforma | Platforma | Platformy",
"publishers": "Wydawcy | Wydawca | Wydawcy",
"rating": "Ocena",
"readLess": "Kliknij aby przeczytać mniej",
"readMore": "Kliknij aby przeczytać więcej",
"recentlyAdded": "Ostatnio Dodane",
"recentlyReleased": "Ostatnio wydane",
"recentlyUpdated": "Ostatnio Zaktualizowane",
"released": "Wydane",
"reviews": "({0} Ocen)",
"size": "Rozmiar",
"tags": "Tagi",
"title": "Sklep",
"view": {
"sort": "Sortuj",
"srFilters": "Filtry",
"srGames": "Gry",
"srViewGrid": "Zobacz siatkę"
},
"viewInStore": "Zobacz w Sklepie",
"website": "Strona"
},
"tasks": {
"admin": {
"back": "{arrow} Wróć do Zadań",
"completedTasksTitle": "Ukończone zadania",
"dailyScheduledTitle": "Codzienne zaplanowane zadania",
"execute": "{arrow} Wykonaj",
"noTasksRunning": "Brak aktualnie uruchomionych zadań",
"progress": "{0}%",
"runningTasksTitle": "Uruchomione zadania",
"scheduled": {
"checkUpdateDescription": "Sprawdź czy Drop ma aktualizację.",
"checkUpdateName": "Sprawdź aktualizację.",
"cleanupInvitationsDescription": "Usuwa wygasłe zaproszenia z bazy danych, aby zaoszczędzić miejsce.",
"cleanupInvitationsName": "Wyczyść zaproszenia",
"cleanupObjectsDescription": "Wykrywa i usuwa nieużywane i nieodwoływane obiekty, aby zaoszczędzić miejsce.",
"cleanupObjectsName": "Wyczyść obiekty",
"cleanupSessionsDescription": "Usuwa wygasłe sesje, aby zaoszczędzić miejsce i zapewnić bezpieczeństwo.",
"cleanupSessionsName": "Wyczyść sesje."
},
"viewTask": "Zobacz {arrow}",
"weeklyScheduledTitle": "Zadania zaplanowane tygodniowo"
}
},
"title": "Drop",
"titleTemplate": "{0} - Drop",
"todo": "Do zrobienia",
"type": "Typ",
"upload": "Prześlij",
"uploadFile": "Prześlij plik",
"user": {
"editProfile": "Edytuj profil",
"noActivity": "Brak ostatniej aktywności",
"notFound": "Użytkownik nie znaleziony",
"recent": "Ostatnia aktywność (Do Zrobienia)",
"recentSub": "Ostatnia aktywność tego użytkownika",
"unknown": "Nieznany użytkownik"
},
"userHeader": {
"closeSidebar": "Zamknij menu boczne",
"links": {
"community": "Społeczność",
"library": "Biblioteka",
"news": "Wiadomości"
},
"profile": {
"admin": "Panel Administracyjny",
"settings": "Ustawienia konta"
}
},
"users": {
"admin": {
"adminHeader": "Administrator?",
"adminUserLabel": "Użytkownik administratora",
"authLink": "Uwierzytelnianie {arrow}",
"authentication": {
"configure": "Konfiguruj",
"description": "Drop obsługuje różnorodne \"mechanizmy uwierzytelniania\". Po ich włączeniu lub wyłączeniu są one wyświetlane na ekranie logowania, umożliwiając użytkownikom wybór. Kliknij menu z kropkami, aby skonfigurować mechanizm uwierzytelniania.",
"disabled": "Wyłączone",
"enabled": "Włączone",
"enabledKey": "Włączone?",
"oidc": "OpenID Connect",
"simple": "Proste (nazwa użytkownika/hasło)",
"srOpenOptions": "Otwórz opcje",
"title": "Uwierzytelnianie"
},
"authoptionsHeader": "Opcje Uwierzytelniania",
"delete": "Usuń",
"deleteUser": "Usuń użytkownika {0}",
"description": "Zarządzaj użytkownikami na twojej instancji Drop, i skonfiguruj swoje metody uwierzytelniania.",
"displayNameHeader": "Nazwa Wyświetlana",
"emailHeader": "Email",
"normalUserLabel": "Normalny użytkownik",
"simple": {
"adminInvitation": "Zaproszenie administratora",
"createInvitation": "Utwórz zaproszenie",
"description": "Proste uwierzytelnianie wykorzystuje system 'zaproszeń' do tworzenia użytkowników. Możesz utworzyć zaproszenie i opcjonalnie podać nazwę użytkownika lub adres e-mail, a następnie system wygeneruje magiczny adres URL, którego można użyć do utworzenia konta.",
"expires": "Wygasa: {expiry}",
"invitationTitle": "Zaproszenia",
"invite3Days": "3 dni",
"invite6Months": "6 miesięcy",
"inviteAdminSwitchDescription": "Utwórz tego użytkownika jako administratora",
"inviteAdminSwitchLabel": "Zaproszenie administratora",
"inviteButton": "Zaproś",
"inviteDescription": "Drop wygeneruje adres URL, który możesz wysłać osobie, którą chcesz zaprosić. Opcjonalnie możesz podać jej nazwę użytkownika lub adres e-mail.",
"inviteEmailDescription": "Musi być w formacie uzytkownik{'@'}example.com",
"inviteEmailLabel": "Adres e-mail (opcjonalny)",
"inviteEmailPlaceholder": "ja{'@'}example.com",
"inviteExpiryLabel": "Wygasa",
"inviteMonth": "1 miesiąc",
"inviteNever": "Nigdy",
"inviteTitle": "Zaproś użytkownika do Drop",
"inviteUsernameFormat": "Musi mieć 5 lub więcej znaków",
"inviteUsernameLabel": "Nazwa Użytkownika (opcjonalna)",
"inviteUsernamePlaceholder": "mojaNazwa",
"inviteWeek": "1 tydzień",
"inviteYear": "1 rok",
"neverExpires": "Nigdy nie wygasa.",
"noEmailEnforced": "Nie wymuszono adresu e-mail.",
"noInvitations": "Brak zaproszeń.",
"noUsernameEnforced": "Nie wymuszono Nazwy Użytkownika.",
"title": "Proste uwierzytelnianie",
"userInvitation": "Zaproszenie użytkownika"
},
"srEditLabel": "Edytuj",
"usernameHeader": "Nazwa Użytkownika"
}
},
"welcome": "Polaku, Witaj!"
}
+1 -1
View File
@@ -79,6 +79,7 @@
"close": "Закрыть",
"create": "Создать",
"date": "Дата",
"delete": "Удалить",
"deleteConfirm": "Вы точно хотите удалить \"{0}\"?",
"edit": "Редактировать",
"friends": "Друзья",
@@ -94,7 +95,6 @@
"tags": "Теги",
"today": "Сегодня"
},
"delete": "Удалить",
"drop": {
"drop": "Уронить"
},
+32
View File
@@ -0,0 +1,32 @@
import type { Localisation } from "./utils";
import {
allLocalisableFiles,
flattenLocalisation,
keysFromContent,
stripEquivalence,
} from "./utils";
import fs from "node:fs";
const files = allLocalisableFiles();
const keySet = new Map<string, string>();
for (const file of files) {
const content = fs.readFileSync(file, "utf-8");
const keys = keysFromContent(content);
keys.forEach((key) => keySet.set(key, file));
}
const localeFile: Localisation = JSON.parse(
fs.readFileSync("./i18n/locales/en_us.json", "utf-8"),
);
const flattenedLocalisation = flattenLocalisation(localeFile);
for (const [key, file] of keySet.entries()) {
console.log(stripEquivalence(flattenedLocalisation.get(key)!));
if (!flattenedLocalisation.delete(key))
throw new Error(
`Found key "${key}" in file ${file} that doesn't exist in localisation`,
);
}
+54
View File
@@ -0,0 +1,54 @@
import fs from "node:fs";
import type { Localisation } from "./utils";
import {
allLocalisableFiles,
fetchLocalisation,
keysFromContent,
} from "./utils";
const files = allLocalisableFiles();
const localeFile: Localisation = JSON.parse(
fs.readFileSync("./i18n/locales/en_us.json", "utf-8"),
);
const keepPrefixes = ["error", "common", "chars"];
const keyMap: Map<string, string> = new Map();
for (const file of files) {
const content = fs.readFileSync(file, "utf-8");
const keys = keysFromContent(content);
const fileNoExtension = file.slice(0, file.lastIndexOf("."));
for (const key of keys) {
const _value = fetchLocalisation(localeFile, key);
const newKeySuffix = key.split(".").slice(-1); /*value
.replaceAll(/[^a-zA-Z\s]/g, "")
.toLowerCase()
.split(" ")
.slice(0, 3)
.map((v, i) =>
v
? i > 0
? v[0].toUpperCase() + v.slice(1)
: v
: key.split(".").slice(-1),
)
.join("");*/
const newKey = [
...fileNoExtension
.replaceAll(/[^a-zA-Z0-9/]/g, "")
.toLowerCase()
.split("/"),
newKeySuffix,
].join(".");
const finalKey = keepPrefixes.some((v) => key.startsWith(v)) ? key : newKey;
keyMap.set(key, finalKey);
}
}
console.log(keyMap);
+122
View File
@@ -0,0 +1,122 @@
import path from "node:path";
import fs from "node:fs";
import prettier from "prettier";
const prettierConfig = JSON.parse(
fs.readFileSync("./.prettierrc.json", "utf-8"),
);
const paths = ["./components", "./layouts", "./pages", "./server"];
const constPaths = ["error.vue", "app.vue"];
const extensions = [".vue", ".ts"];
function recursiveFindFiles(root: string): string[] {
const results = [];
const subpaths = fs.readdirSync(root);
for (const subpath of subpaths) {
const absPath = path.join(root, subpath);
if (extensions.some((v) => absPath.endsWith(v))) {
results.push(absPath);
continue;
}
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
results.push(...recursiveFindFiles(absPath));
continue;
}
}
return [...results, ...constPaths];
}
/**
* Fetches the paths of all files available to be localised
*/
export function allLocalisableFiles(): string[] {
const files = paths.map((k) => recursiveFindFiles(k)).flat();
return files;
}
const I18N_UTIL_REGEX = /(?<=[^a-zA-Z]t\(\s*?["']).*?(?=["'])/g;
const I18N_KEYPATH_REGEX = /(?<=keypath=["']).*?(?=["'])/g;
/**
* Uses regex to match all i18n keys in content
* @param content The file content to match against
*/
export function keysFromContent(content: string): string[] {
const matches = [
...content.matchAll(I18N_UTIL_REGEX),
...content.matchAll(I18N_KEYPATH_REGEX),
];
return matches.map((v) => v[0]);
}
export type Localisation = { [key: string]: Localisation | string };
export function flattenLocalisation(localisation: Localisation) {
const map = new Map<string, string>();
flattenLocalisationRecursive(map, [], localisation);
return map;
}
function flattenLocalisationRecursive(
map: Map<string, string>,
key: string[],
localisationBranch: Localisation | string,
) {
if (typeof localisationBranch === "string") {
map.set(key.join("."), localisationBranch);
return;
}
for (const [subKey, value] of Object.entries(localisationBranch)) {
const newKey = [...key, subKey];
flattenLocalisationRecursive(map, newKey, value);
}
}
export function deleteLocalisation(localisation: Localisation, key: string) {
const parts = key.split(".");
let current: Localisation | string = localisation;
for (const part of parts.slice(0, -1)) {
if (typeof current === "string")
throw new Error(`${key} not found in localisation`);
current = current[part];
}
if (typeof current === "string")
throw new Error(`${key} not found in localisation`);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete current[parts.at(-1)!];
}
export function fetchLocalisation(
localisation: Localisation,
key: string,
): string {
const parts = key.split(".");
let current: Localisation | string = localisation;
for (const part of parts.slice(0, -1)) {
if (typeof current === "string")
throw new Error(`${key} not found in localisation`);
current = current[part];
}
if (typeof current === "string")
throw new Error(`${key} not found in localisation`);
return current[parts.at(-1)!] as string;
}
export async function writeJSON<T>(path: string, object: T) {
const flatStr = JSON.stringify(object);
const formatted = await prettier.format(flatStr, {
parser: "json",
...prettierConfig,
});
fs.writeFileSync(path, formatted);
}
/**
* Strips some sort of English language string down to something that can be compared to be basically equivalent
*/
export function stripEquivalence(value: string): string {
return value.replaceAll(/[.,\s]/g, "").toLowerCase();
}
+14 -2
View File
@@ -95,7 +95,7 @@
class="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:z-50 lg:block lg:w-20 lg:overflow-y-auto lg:bg-zinc-950 lg:pb-4"
>
<div class="flex flex-col h-24 shrink-0 items-center justify-center">
<DropLogo class="h-8 w-auto" />
<ApplicationLogo class="h-8 w-auto" />
<span
class="mt-1 bg-blue-400 px-1 py-0.5 rounded-md text-xs font-bold font-display"
>{{ $t("header.admin.admin") }}</span
@@ -170,6 +170,7 @@ import type { NavigationItem } from "~/composables/types";
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
import { XMarkIcon } from "@heroicons/vue/24/solid";
import type { Settings } from "~/server/internal/utils/types";
const i18nHead = useLocaleHead();
@@ -231,6 +232,15 @@ router.afterEach(() => {
sidebarOpen.value = false;
});
const {
generalSettings: { serverName, mLogoObjectId },
} = await $dropFetch<Settings>("/api/v1/settings");
const favicon = mLogoObjectId ? useObject(mLogoObjectId) : "/favicon.ico";
useFavicon(favicon, { rel: "icon" });
const applicationName = serverName || $t("drop.drop");
useHead({
htmlAttrs: {
lang: i18nHead.value.htmlAttrs.lang,
@@ -238,7 +248,9 @@ useHead({
dir: i18nHead.value.htmlAttrs.dir,
},
titleTemplate(title) {
return title ? $t("adminTitleTemplate", [title]) : $t("adminTitle");
return title
? $t("adminTitleTemplate", [title, applicationName])
: $t("adminTitle", [applicationName]);
},
});
</script>
+10 -4
View File
@@ -1,20 +1,23 @@
<template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
<div
v-if="!clientRequest"
class="flex flex-col w-full min-h-screen bg-zinc-900"
>
<LazyUserHeader class="z-50" hydrate-on-idle />
<div class="grow flex">
<NuxtPage />
</div>
<LazyUserFooter class="z-50" hydrate-on-interaction />
</div>
<div v-else class="flex w-full min-h-screen bg-zinc-900">
<div v-else class="flex flex-col w-full min-h-screen bg-zinc-900">
<NuxtPage />
<LazyUserHeaderStoreNav />
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const clientRequest = isClientRequest();
const i18nHead = useLocaleHead();
const noWrapper = !!route.query.noWrapper;
const { t } = useI18n();
@@ -31,4 +34,7 @@ useHead({
return title ? t("titleTemplate", [title]) : t("title");
},
});
const { mLogoObjectId } = await $dropFetch("/api/v1");
const favicon = mLogoObjectId ? useObject(mLogoObjectId) : "/favicon.ico";
useFavicon(favicon, { rel: "icon" });
</script>
+31 -35
View File
@@ -1,9 +1,9 @@
import tailwindcss from "@tailwindcss/vite";
import { execSync } from "node:child_process";
import { cpSync, readFileSync, existsSync } from "node:fs";
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
import module from "module";
import { viteStaticCopy } from "vite-plugin-static-copy";
import module from "node:module";
import { fileURLToPath } from "node:url";
import { type } from "arktype";
const packageJsonSchema = type({
@@ -11,13 +11,18 @@ const packageJsonSchema = type({
version: "string",
});
const twemojiJson = module.findPackageJSON(
const twemojiPackage = module.findPackageJSON(
"@discordapp/twemoji",
import.meta.url,
);
if (!twemojiJson) {
if (!twemojiPackage) {
throw new Error("Could not find @discordapp/twemoji package.");
}
const twemojiAssetsPath = path.join(
path.dirname(twemojiPackage),
"dist",
"svg",
);
// get drop version
const dropVersion = getDropVersion();
@@ -64,7 +69,8 @@ export default defineNuxtConfig({
experimental: {
buildCache: true,
viewTransition: true,
viewTransition: false,
appManifest: false,
componentIslands: true,
},
@@ -76,32 +82,9 @@ export default defineNuxtConfig({
plugins: [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tailwindcss() as any,
// only used in dev server, not build because nitro sucks
// see build hook below
viteStaticCopy({
targets: [
{
src: "node_modules/@discordapp/twemoji/dist/svg/*",
dest: "twemoji",
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
],
},
hooks: {
"nitro:build:public-assets": (nitro) => {
// this is only run during build, not dev server
// https://github.com/nuxt/nuxt/issues/18918#issuecomment-1925774964
// copy emojis to .output/public/twemoji
const targetDir = path.join(nitro.options.output.publicDir, "twemoji");
cpSync(path.join(path.dirname(twemojiJson), "dist", "svg"), targetDir, {
recursive: true,
});
},
},
runtimeConfig: {
gitRef: commitHash,
dropVersion: dropVersion,
@@ -115,6 +98,15 @@ export default defineNuxtConfig({
routeRules: {
"/api/**": { cors: true },
// redirect old OIDC callback route
"/auth/callback/oidc": {
redirect: "/api/v1/auth/oidc/callback",
},
},
devServer: {
port: 4000,
},
nitro: {
@@ -140,7 +132,6 @@ export default defineNuxtConfig({
scheduledTasks: {
"0 * * * *": ["dailyTasks"],
"*/30 * * * *": ["downloadCleanup"],
},
storage: {
@@ -156,6 +147,14 @@ export default defineNuxtConfig({
base: "./.data/appCache",
},
},
serverAssets: [
{
baseName: "twemoji",
// get path to twemoji svg assets
dir: twemojiAssetsPath,
},
],
},
typescript: {
@@ -179,6 +178,7 @@ export default defineNuxtConfig({
optimizeTranslationDirective: false,
},
defaultLocale: "en-us",
lazy: true,
strategy: "no_prefix",
experimental: {
localeDetector: "localeDetector.ts",
@@ -282,11 +282,7 @@ function getDropVersion(): string {
// 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",
);
const packageJsonPath = fileURLToPath(import.meta.resolve("./package.json"));
if (!existsSync(packageJsonPath)) {
console.error("Could not find package.json, using default version.");
+27 -10
View File
@@ -1,6 +1,6 @@
{
"name": "drop",
"version": "0.3.5",
"version": "0.4.0",
"private": true,
"type": "module",
"license": "AGPL-3.0-or-later",
@@ -12,7 +12,7 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare && prisma generate",
"postinstall": "nuxt prepare && prisma generate && buf generate",
"typecheck": "nuxt typecheck",
"lint": "pnpm run lint:eslint && pnpm run lint:prettier",
"lint:eslint": "eslint .",
@@ -20,41 +20,51 @@
"lint:fix": "eslint . --fix && prettier --write --list-different ."
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "3.5.0",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3",
"@nuxt/fonts": "^0.11.0",
"@nuxt/image": "^1.10.0",
"@nuxt/kit": "^3.20.1",
"@nuxtjs/i18n": "^9.5.5",
"@prisma/client": "^6.11.1",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tailwindcss/vite": "^4.0.6",
"@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.0",
"arktype": "^2.1.10",
"axios": "^1.12.0",
"bcryptjs": "^3.0.2",
"cbor2": "^2.0.1",
"cheerio": "^1.0.0",
"cookie-es": "^2.0.0",
"deepmerge": "^4.3.1",
"dotenv": "^17.2.3",
"fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0",
"jose": "^6.1.3",
"jsonwebtoken": "^9.0.3",
"kjua": "^0.10.0",
"luxon": "^3.6.1",
"micromark": "^4.0.1",
"normalize-url": "^8.0.2",
"nuxt": "^3.17.4",
"nuxt": "^3.20.1",
"nuxt-security": "2.2.0",
"otp-io": "^1.2.7",
"parse-cosekey": "^1.0.2",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"prisma": "6.11.1",
"prisma": "7.3.0",
"sanitize-filename": "^1.6.3",
"semver": "^7.7.1",
"shescape": "^2.1.8",
"stream-mime-type": "^2.0.0",
"turndown": "^7.2.0",
"unstorage": "^1.15.0",
"vite-plugin-static-copy": "^3.1.2",
"vue": "latest",
"vue-router": "latest",
"vue3-carousel": "^0.16.0",
@@ -62,21 +72,28 @@
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@bufbuild/buf": "^1.65.0",
"@bufbuild/protoc-gen-es": "^2.11.0",
"@golar/vue": "^0.0.13",
"@intlify/eslint-plugin-vue-i18n": "^4.0.1",
"@nuxt/eslint": "^1.3.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/jsonwebtoken": "^9.0.10",
"@types/luxon": "^3.6.2",
"@types/node": "^22.13.16",
"@types/semver": "^7.7.0",
"@types/turndown": "^5.0.5",
"@typescript-eslint/utils": "^8.50.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.1",
"h3": "^1.15.3",
"golar": "^0.0.13",
"h3": "^1.15.5",
"nitropack": "^2.11.12",
"ofetch": "^1.4.1",
"prettier": "^3.5.3",
"prettier-plugin-sort-json": "^4.1.1",
"sass": "^1.79.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3",
@@ -87,5 +104,5 @@
"vue3-carousel": "^0.16.0"
}
},
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
"packageManager": "pnpm@10.29.1+sha512.48dae233635a645768a3028d19545cacc1688639eeb1f3734e42d6d6b971afbf22aa1ac9af52a173d9c3a20c15857cfa400f19994d79a2f626fcc73fccda9bbc"
}
+1 -1
View File
@@ -85,7 +85,7 @@
@click="deleteNotification(notification.id)"
>
<TrashIcon class="size-3" />
{{ $t("delete") }}
{{ $t("common.delete") }}
</button>
</div>
</div>
+255 -1
View File
@@ -1,3 +1,257 @@
<template>
<div></div>
<div>
<div
v-if="!superlevel"
class="border-l-4 p-4 border-yellow-500 bg-yellow-500/10"
>
<div class="flex">
<div class="shrink-0">
<ExclamationTriangleIcon
class="size-5 text-yellow-500"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-300">
{{ $t("account.security.2fa.superlevelHint.title") }}
<NuxtLink
href="/auth/signin?redirect=/account/security&superlevel=true"
class="font-medium underline text-yellow-300 hover:text-yellow-200"
>
<i18n-t
keypath="account.security.2fa.superlevelHint.signin"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</p>
</div>
</div>
</div>
<div v-else class="border-l-4 p-4 border-green-500 bg-green-500/10">
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-500" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-green-300">
{{ $t("account.security.2fa.superlevelHint.success") }}
</p>
</div>
</div>
</div>
<div class="mt-6 relative">
<div></div>
<div class="mt-8 border-b border-white/10 pb-2">
<h3 class="text-base font-semibold text-white">
{{ $t("account.security.2fa.title") }}
</h3>
</div>
<div class="mt-4 flex flex-wrap gap-8">
<!-- TOTP -->
<div
class="group relative border-white/10 bg-zinc-800/50 p-6 rounded-md"
>
<div>
<span
class="inline-flex rounded-lg p-3 bg-blue-400/10 text-blue-400"
>
<ClockIcon class="size-6" aria-hidden="true" />
</span>
</div>
<div class="mt-8 max-w-sm">
<h3 class="text-base font-semibold text-white">
<NuxtLink
:href="mfa.mecs.TOTP?.enabled ? '' : '/mfa/setup/totp'"
class="focus:outline-hidden"
>
<!-- Extend touch target to entire panel -->
<span
v-if="!mfa.mecs.TOTP?.enabled"
class="absolute inset-0"
aria-hidden="true"
></span>
{{ $t("account.security.2fa.totp.title") }}
</NuxtLink>
</h3>
<p class="mt-2 text-sm text-gray-400">
{{ $t("account.security.2fa.totp.description") }}
</p>
<div v-if="mfa.mecs.TOTP?.enabled" class="mt-3">
<LoadingButton :loading="false">{{
$t("account.security.2fa.totp.disableButton")
}}</LoadingButton>
</div>
</div>
<span
class="pointer-events-none absolute top-6 right-6"
aria-hidden="true"
>
<svg
v-if="!mfa.mecs.TOTP?.enabled"
class="size-6 text-gray-500 group-hover:text-gray-200"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z"
/>
</svg>
<CheckIcon v-else class="size-6 text-green-600" />
</span>
</div>
<!-- WebAuthn -->
<div
class="group relative border-white/10 bg-zinc-800/50 p-6 rounded-md"
>
<div>
<span
class="inline-flex rounded-lg p-3 bg-blue-400/10 text-blue-400"
>
<KeyIcon class="size-6" aria-hidden="true" />
</span>
</div>
<div class="mt-8 max-w-sm">
<h3 class="text-base font-semibold text-white">
{{ $t("account.security.2fa.webauthn.title") }}
</h3>
<p class="mt-2 text-sm text-gray-400">
{{ $t("account.security.2fa.webauthn.description") }}
</p>
<p class="mt-1 text-xs font-bold text-zinc-300">
{{ $t("account.security.2fa.webauthn.bypassHint") }}
</p>
</div>
<LoadingButton
class="mt-3"
:loading="false"
@click="() => (webAuthnOpen = true)"
>{{ $t("account.security.2fa.webauthn.manage") }}</LoadingButton
>
</div>
</div>
<div v-if="!superlevel" class="absolute inset-0 bg-zinc-900/50" />
</div>
<ModalTemplate v-model="webAuthnOpen" size-class="max-w-2xl">
<template #default>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-white">
{{ $t("account.security.2fa.webauthn.modal.title") }}
</h1>
<p class="mt-2 text-sm text-gray-300">
{{ $t("account.security.2fa.webauthn.modal.description") }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<NuxtLink
to="/mfa/setup/webauthn"
class="block rounded-md bg-blue-500 px-3 py-2 text-center text-sm font-semibold text-white shadow-xs hover:bg-blue-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
{{ $t("account.security.2fa.webauthn.modal.new") }}
</NuxtLink>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div
class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"
>
<table class="relative min-w-full divide-y divide-white/15">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
>
{{ $t("account.security.2fa.webauthn.modal.tableName") }}
</th>
<th
scope="col"
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
>
{{
$t("account.security.2fa.webauthn.modal.tableCreated")
}}
</th>
<th scope="col" class="py-3.5 pr-4 pl-3 sm:pr-0">
<span class="sr-only">{{ $t("common.delete") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr
v-for="mec in (mfa.mecs.WebAuthn?.credentials as Array<{
id: string;
name: string;
created: number;
}>) ?? []"
:key="mec.id"
>
<td
class="py-4 pr-3 pl-4 text-sm font-medium whitespace-nowrap text-white sm:pl-0"
>
{{ mec.name }}
</td>
<td
class="py-4 pr-3 pl-4 text-sm font-medium whitespace-nowrap text-white sm:pl-0"
>
<RelativeTime :date="new Date(mec.created)" />
</td>
<td
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0"
>
<button
class="text-blue-400 hover:text-blue-300"
@click="() => deletePasskey(mec.id)"
>
{{ $t("common.delete") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<template #buttons>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="webAuthnOpen = false"
>
{{ $t("common.close") }}
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/vue/20/solid";
import { CheckIcon, ClockIcon, KeyIcon } from "@heroicons/vue/24/outline";
const superlevel = await $dropFetch("/api/v1/user/superlevel");
//const auth = await $dropFetch("/api/v1/user/auth");
const mfa = await $dropFetch("/api/v1/user/mfa");
const webAuthnOpen = ref(false);
async function deletePasskey(id: string) {
await $dropFetch("/api/v1/user/mfa/webauthn", {
method: "DELETE",
body: { id },
failTitle: "Failed to delete passkey",
});
}
</script>
-1
View File
@@ -202,7 +202,6 @@ async function createToken(
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} finally {
+76 -50
View File
@@ -15,39 +15,19 @@
>
<div class="grid grid-cols-6 gap-4">
<div class="col-span-6 lg:col-span-1 md:col-span-3 row-span-1">
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<DropLogo />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-2xl flex-1 font-bold">{{ version }}</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.version") }}
</div>
</div>
</div>
</TileWithLink>
<MiniTile :value="version" :label="t('home.admin.version')">
<template #icon>
<ApplicationLogo />
</template>
</MiniTile>
</div>
<div class="col-span-6 lg:col-span-1 md:col-span-3">
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<GamepadIcon />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">{{ gameCount }}</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.games") }}
</div>
</div>
</div>
</TileWithLink>
<MiniTile :label="t('home.admin.games')" :value="$n(gameCount)">
<template #icon>
<GamepadIcon />
</template>
</MiniTile>
</div>
<div
@@ -62,7 +42,7 @@
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">
{{ sources.length }}
{{ $n(sources.length) }}
</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.librarySources") }}
@@ -84,7 +64,7 @@
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">
{{ userStats.userCount }}
{{ $n(userStats.userCount) }}
</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.users") }}
@@ -105,6 +85,63 @@
<PieChart :data="pieChartData" />
</TileWithLink>
</div>
<div class="col-span-6 row-span-1 lg:col-span-2 lg:row-span-2">
<TileWithLink title="System">
<div class="h-full pb-15 content-center">
<div class="grid grid-cols-1 text-center gap-4">
<h3 class="col-span-1 text-lg font-semibold flex">
<div class="flex-1 text-left">
{{ $t("home.admin.cpuUsage") }}
</div>
<div class="flex-1 text-sm grow text-right self-center">
{{ $t("home.admin.numberCores", systemData.cpuCores) }}
</div>
</h3>
<div class="col-span-1">
<ProgressBar
:color="getBarColor(systemData.cpuLoad)"
:percentage="systemData.cpuLoad"
/>
</div>
<h3 class="col-span-1 text-lg font-semibold my-2 flex">
<div class="flex-none text-left">
{{ $t("home.admin.ramUsage") }}
</div>
<div class="flex-1 text-sm grow text-right self-center">
{{
$t("home.admin.availableRam", {
usedRam: formatBytes(
systemData.totalRam - systemData.freeRam,
),
totalRam: formatBytes(systemData.totalRam),
})
}}
</div>
</h3>
<div class="col-span-1">
<ProgressBar
:color="
getBarColor(
getPercentage(
systemData.totalRam - systemData.freeRam,
systemData.totalRam,
),
)
"
:percentage="
getPercentage(
systemData.totalRam - systemData.freeRam,
systemData.totalRam,
)
"
/>
</div>
</div>
</div>
</TileWithLink>
</div>
<div class="col-span-6">
<TileWithLink
title="Library"
@@ -118,7 +155,7 @@
:title="t('home.admin.biggestGamesToDownload')"
:subtitle="t('home.admin.latestVersionOnly')"
>
<RankingList :items="biggestGamesLatest.map(gameToRankItem)" />
<!-- <RankingList :items="biggestGamesLatest.map(gameToRankItem)" />-->
</TileWithLink>
</div>
<div class="col-span-6 lg:col-span-2">
@@ -126,7 +163,7 @@
:title="t('home.admin.biggestGamesOnServer')"
:subtitle="t('home.admin.allVersionsCombined')"
>
<RankingList :items="biggestGamesCombined.map(gameToRankItem)" />
<!-- <RankingList :items="biggestGamesCombined.map(gameToRankItem)" />-->
</TileWithLink>
</div>
</div>
@@ -137,10 +174,9 @@
<script setup lang="ts">
import { formatBytes } from "~/server/internal/utils/files";
import GamepadIcon from "~/components/Icons/GamepadIcon.vue";
import DropLogo from "~/components/DropLogo.vue";
import { ServerStackIcon, UserGroupIcon } from "@heroicons/vue/24/outline";
import type { GameSize } from "~/server/internal/gamesize";
import type { RankItem } from "~/components/RankingList.vue";
import { getPercentage } from "~/utils/utils";
import { getBarColor } from "~/utils/colors";
definePageMeta({
layout: "admin",
@@ -152,20 +188,10 @@ useHead({
const { t } = useI18n();
const {
version,
gameCount,
sources,
userStats,
biggestGamesLatest,
biggestGamesCombined,
} = await $dropFetch("/api/v1/admin/home");
const systemData = useSystemData();
const gameToRankItem = (game: GameSize, rank: number): RankItem => ({
rank: rank + 1,
name: game.gameName,
value: formatBytes(game.size),
});
const { version, gameCount, sources, userStats } =
await $dropFetch("/api/v1/admin/home");
const pieChartData = [
{
+237 -472
View File
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-y-4 max-w-lg">
<div class="flex flex-col gap-y-4 sm:max-w-[40rem]">
<Listbox
as="div"
:model-value="currentlySelectedVersion"
@@ -13,7 +13,7 @@
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="currentlySelectedVersion != -1" class="block truncate">{{
versions[currentlySelectedVersion]
versions[currentlySelectedVersion].name
}}</span>
<span v-else class="block truncate text-zinc-600">{{
$t("library.admin.import.selectDir")
@@ -38,7 +38,7 @@
>
<ListboxOption
v-for="(version, versionIdx) in versions"
:key="version"
:key="version.identifier"
v-slot="{ active, selected }"
as="template"
:value="versionIdx"
@@ -54,7 +54,7 @@
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ version }}</span
>{{ version.name }}</span
>
<span
@@ -73,303 +73,198 @@
</div>
</Listbox>
<div v-if="versionGuesses" class="flex flex-col gap-8">
<!-- setup executable -->
<div>
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.setupCmd") }}</label
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.setupDesc") }}
</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>
<Combobox
as="div"
:value="versionSettings.setup"
nullable
@update:model-value="(v) => updateSetupCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="
$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
id="startup"
v-model="versionSettings.setupArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--setup"
/>
</div>
<div v-if="versionGuesses" class="flex flex-col gap-4">
<!-- version display name -->
<div class="bg-zinc-800 p-4 rounded-xl relative flex flex-col gap-y-2">
<div>
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("library.admin.import.version.displayName")
}}</label>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.displayNameDesc") }}
</p>
</div>
<div>
<input
id="display-name"
v-model="versionSettings.displayName"
type="text"
class="min-w-48 block w-full rounded-md border-radius-md bg-zinc-950 px-3 py-1.5 text-white outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-zinc-500 focus:outline-1 focus:-outline-offset-1 focus:outline-blue-500 sm:text-sm/6"
:placeholder="
$t('library.admin.import.version.displayNamePlaceholder')
"
/>
</div>
</div>
<!-- setup mode -->
<SwitchGroup as="div" class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
<!-- setup executable -->
<div class="bg-zinc-800 p-4 rounded-xl relative flex flex-col gap-y-2">
<div>
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("library.admin.import.version.setupCmd")
}}</label>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.setupDesc") }}
</p>
</div>
<ol
v-if="versionSettings.setups.length > 0"
class="divide-y-1 divide-zinc-700"
>
<li
v-for="(launch, launchIdx) in versionSettings.setups"
:key="launchIdx"
class="py-2 inline-flex items-start gap-x-1 w-full"
>
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
$t("library.admin.import.version.setupModeDesc")
}}</SwitchDescription>
</span>
<Switch
v-model="versionSettings.onlySetup"
:class="[
versionSettings.onlySetup ? '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',
]"
<ImportVersionLaunchRow
v-model="versionSettings.setups[launchIdx]"
:version-guesses="versionGuesses"
:needs-name="false"
/>
<button
class="transition rounded p-1 bg-zinc-900/30 group hover:bg-red-600/30"
@click="() => versionSettings.setups.splice(launchIdx, 1)"
>
<TrashIcon
class="transition size-5 text-zinc-700 group-hover:text-red-700"
/>
</button>
</li>
</ol>
<span
v-else
class="text-sm text-zinc-700 uppercase font-display font-bold"
>{{ $t("library.admin.import.version.noSetups") }}</span
>
<span
aria-hidden="true"
:class="[
versionSettings.onlySetup ? '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>
<LoadingButton
:loading="false"
class="w-fit"
@click="() => versionSettings.setups.push({} as any)"
>{{ $t("common.add") }}</LoadingButton
>
</div>
<!-- setup mode -->
<div class="relative">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.launchCmd") }}</label
<SwitchGroup
as="div"
class="bg-zinc-800 p-4 rounded-xl flex items-center justify-between gap-4"
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.launchDesc") }}
</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 flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
>
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
$t("library.admin.import.version.setupModeDesc")
}}</SwitchDescription>
</span>
<Switch
v-model="versionSettings.onlySetup"
:class="[
versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-900',
'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
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
id="startup"
v-model="versionSettings.launchArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--launch"
aria-hidden="true"
:class="[
versionSettings.onlySetup ? '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',
]"
/>
</div>
</Switch>
</SwitchGroup>
<div
v-if="type === GameType.Dependency"
class="absolute inset-0 bg-zinc-900/50"
/>
</div>
<!-- launch executables -->
<div class="relative flex flex-col gap-y-2 bg-zinc-800 p-4 rounded-xl">
<div>
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("library.admin.import.version.launchCmd")
}}</label>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.launchDesc") }}
</p>
</div>
<ol
v-if="versionSettings.launches.length > 0"
class="divide-y-1 divide-zinc-700"
>
<li
v-for="(launch, launchIdx) in versionSettings.launches"
:key="launchIdx"
class="py-2 inline-flex items-start gap-x-1 w-full"
>
<Disclosure
v-slot="{ open }"
:default-open="true"
as="div"
class="py-2 px-3 w-full bg-zinc-900 rounded-lg"
>
<dt>
<DisclosureButton
class="flex w-full items-center text-left text-white"
>
<span v-if="launch.name" class="text-sm font-semibold">{{
launch.name
}}</span>
<span v-else class="text-sm text-zinc-500 italic">{{
$t("library.admin.import.version.noNameProvided")
}}</span>
<span class="ml-auto flex h-7 items-center">
<PlusIcon v-if="!open" class="size-6" aria-hidden="true" />
<MinusIcon v-else class="size-6" aria-hidden="true" />
</span>
<button
class="ml-1 transition rounded p-1 bg-zinc-900/30 group hover:bg-red-600/30"
@click.prevent="
() => versionSettings.launches.splice(launchIdx, 1)
"
>
<TrashIcon
class="transition size-5 text-zinc-700 group-hover:text-red-700"
/>
</button>
</DisclosureButton>
</dt>
<DisclosurePanel as="dd" class="mt-2">
<ImportVersionLaunchRow
v-model="versionSettings.launches[launchIdx]"
:version-guesses="versionGuesses"
:needs-name="true"
:allow-emulator="true"
:type="type"
/>
</DisclosurePanel>
</Disclosure>
</li>
</ol>
<span
v-else
class="text-sm text-zinc-700 uppercase font-display font-bold"
>{{ $t("library.admin.import.version.noLaunches") }}</span
>
<LoadingButton
:loading="false"
class="w-fit"
@click="() => versionSettings.launches.push({} as any)"
>{{ $t("common.add") }}</LoadingButton
>
<div
v-if="versionSettings.onlySetup"
class="absolute inset-0 bg-zinc-900/50"
/>
</div>
<PlatformSelector v-model="versionSettings.platform">
{{ $t("library.admin.import.version.platform") }}
</PlatformSelector>
<SwitchGroup as="div" class="flex items-center justify-between">
<SwitchGroup
as="div"
class="bg-zinc-800 p-4 rounded-xl flex items-center gap-4 justify-between"
>
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
@@ -385,7 +280,7 @@
<Switch
v-model="versionSettings.delta"
:class="[
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-800',
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-900',
'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',
]"
>
@@ -398,91 +293,11 @@
/>
</Switch>
</SwitchGroup>
<Disclosure v-slot="{ open }" as="div" class="py-2">
<dt>
<DisclosureButton
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
>
<span class="text-base/7 font-semibold">
{{ $t("library.admin.import.version.advancedOptions") }}
</span>
<span class="ml-6 flex h-7 items-center">
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
</span>
</DisclosureButton>
</dt>
<DisclosurePanel
as="dd"
class="bg-zinc-950/30 p-3 rounded-b-lg mt-2 flex flex-col gap-y-4"
>
<!-- UMU launcher configuration -->
<div
v-if="versionSettings.platform == PlatformClient.Windows"
class="flex flex-col gap-y-4"
>
<SwitchGroup as="div" class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>
{{ $t("library.admin.import.version.umuOverride") }}
</SwitchLabel>
<SwitchDescription as="span" class="text-sm text-zinc-400">
{{ $t("library.admin.import.version.umuOverrideDesc") }}
</SwitchDescription>
</span>
<Switch
v-model="umuIdEnabled"
:class="[
umuIdEnabled ? '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="[
umuIdEnabled ? '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>
<label
for="umu-id"
class="block text-sm font-medium leading-6 text-zinc-100"
>
{{ $t("library.admin.import.version.umuLauncherId") }}
</label>
<div class="mt-2">
<input
id="umu-id"
v-model="umuId"
name="umu-id"
type="text"
autocomplete="umu-id"
required
:disabled="!umuIdEnabled"
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"
/>
</div>
</div>
</div>
<div v-else class="text-zinc-400">
{{ $t("library.admin.import.version.noAdv") }}
</div>
</DisclosurePanel>
</Disclosure>
<LoadingButton
class="w-fit"
class="w-fit ml-auto"
:loading="importLoading"
@click="startImport_wrapper"
@click="startImport"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
@@ -539,15 +354,19 @@ import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import {
CheckIcon,
ChevronUpDownIcon,
TrashIcon,
MinusIcon,
PlusIcon,
} from "@heroicons/vue/20/solid";
import { FetchError } from "ofetch";
import { GameType } from "~/prisma/client/enums";
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
import type { VersionGuess } from "~/server/internal/library";
definePageMeta({
layout: "admin",
@@ -557,80 +376,21 @@ const router = useRouter();
const { t } = useI18n();
const route = useRoute();
const gameId = route.params.id.toString();
const versions = await $dropFetch(
const { versions, type } = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
);
const currentlySelectedVersion = ref(-1);
const versionSettings = ref<{
platform: PlatformClient | undefined;
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 =
ref<Array<{ platform: PlatformClient; filename: string }>>();
const launchProcessQuery = ref("");
const setupProcessQuery = ref("");
const launchFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
),
);
const setupFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase()),
),
const versionSettings = ref<Omit<typeof ImportVersion.infer, "version" | "id">>(
{
delta: false,
onlySetup: type === GameType.Dependency,
launches: [],
setups: [],
requiredContent: [],
},
);
function updateLaunchCommand(value: string) {
versionSettings.value.launch = value;
autosetPlatform(value);
}
function updateSetupCommand(value: string) {
versionSettings.value.setup = value;
autosetPlatform(value);
}
function autosetPlatform(value: string) {
if (!versionGuesses.value) return;
if (versionSettings.value.platform) return;
const guessIndex = versionGuesses.value.findIndex(
(e) => e.filename === value,
);
if (guessIndex == -1) return;
versionSettings.value.platform = versionGuesses.value[guessIndex].platform;
}
const umuIdEnabled = ref(false);
const umuId = computed({
get() {
if (umuIdEnabled.value) return versionSettings.value.umuId;
return undefined;
},
set(v) {
if (umuIdEnabled.value && v) {
versionSettings.value.umuId = v;
}
},
});
const versionGuesses = ref<Array<VersionGuess>>();
const importLoading = ref(false);
const importError = ref<string | undefined>();
@@ -639,38 +399,43 @@ async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value];
const results = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId,
)}&version=${encodeURIComponent(version)}`,
);
versionGuesses.value = results.map((e) => ({
...e,
platform: e.platform as PlatformClient,
}));
try {
const results = await $dropFetch(`/api/v1/admin/import/version/preload`, {
failTitle: "Failed to fetch version information",
query: {
id: gameId,
type: version.type,
version: version.identifier,
},
});
versionGuesses.value = results as typeof versionGuesses.value;
} catch {
currentlySelectedVersion.value = -1;
}
}
async function startImport() {
if (!versionSettings.value) return;
const taskId = await $dropFetch("/api/v1/admin/import/version", {
method: "POST",
body: {
id: gameId,
version: versions[currentlySelectedVersion.value],
...versionSettings.value,
},
});
router.push(`/admin/task/${taskId.taskId}`);
}
function startImport_wrapper() {
importLoading.value = true;
startImport()
.catch((error) => {
importError.value = error.statusMessage ?? t("errors.unknown");
})
.finally(() => {
importLoading.value = false;
if (!versionSettings.value) return;
try {
const taskId = await $dropFetch("/api/v1/admin/import/version", {
method: "POST",
body: {
...versionSettings.value,
id: gameId,
version: versions[currentlySelectedVersion.value],
},
});
router.push(`/admin/task/${taskId.taskId}`);
} catch (error) {
if (error instanceof FetchError) {
importError.value = error.data?.message ?? t("errors.unknown");
} else {
importError.value = (error as string)?.toString();
}
} finally {
importLoading.value = false;
}
}
</script>
+4 -4
View File
@@ -3,11 +3,11 @@
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"
class="bg-zinc-950 w-full flex flex-row items-center gap-2 justify-between px-2 pt-6 lg:pt-0"
>
<!--start-->
<div>
<Listbox v-if="false" v-model="currentMode" as="div">
<Listbox v-model="currentMode" as="div" class="sm:hidden mb-2">
<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"
@@ -68,7 +68,7 @@
</div>
</Listbox>
<div class="pt-4 inline-flex gap-x-2">
<div class="hidden sm:inline-flex pt-4 gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
@@ -93,7 +93,7 @@
<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"
class="whitespace-nowrap 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-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{{ $t("library.admin.openStore") }}
<ArrowTopRightOnSquareIcon
+64 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-y-6 w-full max-w-md">
<div class="flex flex-col gap-y-6 w-full max-w-lg">
<Listbox
as="div"
:model="currentlySelectedGame"
@@ -114,6 +114,41 @@
</div>
<div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4">
<fieldset>
<legend class="text-sm/6 font-semibold text-white">
{{ $t("library.admin.import.importAs") }}
</legend>
<div class="mt-6 grid grid-cols-1 gap-y-6 sm:grid-cols-3 sm:gap-x-4">
<label
v-for="[type, meta] in Object.entries(importModes)"
:key="type"
:aria-label="meta.title"
class="cursor-pointer group relative flex rounded-lg border border-white/10 bg-gray-800/50 p-4 has-checked:bg-blue-500/10 has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-blue-500 has-focus-visible:outline-3 has-focus-visible:-outline-offset-1 has-disabled:bg-gray-800 has-disabled:opacity-25"
>
<input
v-model="importMode"
type="radio"
name="mailing-list"
:value="type"
:checked="importMode === type"
class="absolute inset-0 opacity-0 focus:outline-none"
/>
<div class="flex flex-col grow">
<span class="block text-sm font-medium text-white">{{
meta.title
}}</span>
<span class="mt-1 block text-xs text-gray-400">{{
meta.description
}}</span>
</div>
<CheckCircleIcon
class="invisible size-5 text-blue-500 group-has-checked:visible"
aria-hidden="true"
/>
</label>
</div>
</fieldset>
<!-- without metadata option -->
<div>
<LoadingButton
@@ -309,6 +344,8 @@ import {
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import { FetchError } from "ofetch";
import type { GameType } from "~/prisma/client/enums";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
definePageMeta({
@@ -326,6 +363,25 @@ const gameSearchTerm = ref("");
const gameSearchLoading = ref(false);
const bulkImportMode = ref(false);
const importModes: {
[key in GameType]: { title: string; description: string };
} = {
Game: {
title: "Game",
description: "Games are shown in store, and are discoverable.",
},
Emulator: {
title: "Emulator",
description:
"Emulators are used to launch other games, wrapping them in another executable.",
},
Dependency: {
title: "Dependency",
description:
"Dependencies are setup-only files that get installed before the game is launched.",
},
};
async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value) return;
currentlySelectedGame.value = value;
@@ -351,6 +407,11 @@ async function searchGame() {
gameSearchLoading.value = false;
} catch (e) {
gameSearchLoading.value = false;
if (e instanceof FetchError) {
gameSearchResultsError.value = e.data?.message ?? t("errors.unknown");
} else {
gameSearchResultsError.value = (e as string)?.toString();
}
throw e;
}
@@ -374,6 +435,7 @@ const router = useRouter();
const importLoading = ref(false);
const importError = ref<string | undefined>();
const importMode = ref<GameType>("Game");
async function importGame(useMetadata: boolean) {
if (!metadataResults.value && useMetadata) return;
@@ -389,6 +451,7 @@ async function importGame(useMetadata: boolean) {
path: option.game,
library: option.library.id,
metadata,
type: importMode.value,
},
});
+498 -93
View File
@@ -12,7 +12,7 @@
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/library/sources"
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"
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-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.sources.link"
@@ -26,57 +26,240 @@
</NuxtLink>
</div>
</div>
<div v-if="toImport" class="rounded-md bg-blue-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
{{ $t("library.admin.detectedGame") }}
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
href="/admin/library/import"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
<div class="flex flex-row justify-between gap-x-5">
<div v-if="toImport" class="rounded-md bg-zinc-600/10 p-3">
<div class="flex">
<div class="flex-shrink-0">
<WrenchScrewdriverIcon
class="h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-zinc-400">
{{ $t("library.admin.massImportTool") }}
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
href="/admin/library/mass-import"
class="whitespace-nowrap font-medium text-zinc-400 hover:text-zinc-500"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</p>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</p>
</div>
</div>
</div>
<div v-if="toImport" class="rounded-md bg-blue-600/10 p-3">
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
{{ $t("library.admin.detectedGame") }}
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
href="/admin/library/import"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</p>
</div>
</div>
</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.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>
<!-- Search & filter -->
<Disclosure
as="section"
aria-labelledby="filter-heading"
class="mt-2 relative flex items-center border-y border-zinc-800 gap-x-4"
>
<h2 id="filter-heading" class="sr-only">
{{ $t("library.admin.nav.filterLabel") }}
</h2>
<div class="relative col-start-1 row-start-1 py-4">
<div class="mx-auto flex max-w-7xl divide-x divide-zinc-700 text-sm">
<div class="pr-6">
<DisclosureButton
class="group flex items-center font-medium text-zinc-400"
>
<FunnelIcon
class="mr-2 size-5 flex-none text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
{{
$t("library.admin.nav.filterCount", [
Object.values(currentFilters).filter((v) => v).length,
])
}}
</DisclosureButton>
</div>
<div class="pl-6">
<button type="button" class="text-zinc-400">
{{ $t("library.admin.nav.clearAllFilters") }}
</button>
</div>
</div>
</div>
<DisclosurePanel
class="absolute bottom-0 translate-y-full left-0 border border-zinc-800 py-4 bg-zinc-900 rounded-b-xl z-10 shadow"
>
<div
class="flex flex-wrap flex-col lg:flex-row max-w-7xl text-sm px-4 gap-4"
>
<fieldset v-for="filter in filterScaffold" :key="filter.value">
<legend class="block font-medium text-zinc-100">
{{ filter.title }}
</legend>
<div class="space-y-6 sm:space-y-4 pt-2">
<div
v-for="option in filter.values"
:key="option.value"
class="flex gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
:id="createFilterKey(filter, option)"
v-model="currentFilters[createFilterKey(filter, option)]"
:value="createFilterKey(filter, option)"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-950 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"
/>
<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-gray-950/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>
<label
:for="createFilterKey(filter, option)"
class="text-base text-zinc-300 sm:text-sm"
>{{ option.label }}</label
>
</div>
</div>
</fieldset>
</div>
</DisclosurePanel>
<div class="grow 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 border-[0px] outline-[0px] placeholder:text-zinc-400 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.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>
<div class="col-start-1 row-start-1 py-4">
<div class="mx-auto flex max-w-7xl justify-end px-2">
<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.prevent="handleSortClick(option, $event)"
>
{{ option.name }}
<span v-if="currentSort === option.param">
{{
sortOrder === "asc"
? $t("chars.arrowUp")
: $t("chars.arrowDown")
}}
</span>
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</div>
</Disclosure>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
class="relative grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="game in filteredLibraryGames"
v-for="game in libraryGames"
: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 hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
>
@@ -88,7 +271,7 @@
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
class="gap-2 text-sm flex flex-wrap items-center font-medium text-zinc-100 font-display"
>
{{ game.mName }}
<button
@@ -128,6 +311,10 @@
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.library!.name }}</span
>
<span
class="inline-flex items-center rounded-full bg-green-600/10 px-2 py-1 text-xs font-medium text-green-600 ring-1 ring-inset ring-green-600/20"
>{{ game.type }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
@@ -157,7 +344,7 @@
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)"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</button>
</div>
</div>
@@ -236,23 +423,13 @@
</div>
</li>
<p
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
v-if="libraryGames.length == 0 && hasLibraries"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="
filteredLibraryGames.length == 0 &&
libraryGames.length == 0 &&
libraryState.hasLibraries
"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.noGames") }}
</p>
<p
v-else-if="!libraryState.hasLibraries"
v-else-if="!hasLibraries"
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
>
<span class="text-sm font-display font-bold uppercase">{{
@@ -276,7 +453,77 @@
</i18n-t>
</NuxtLink>
</p>
<div
v-if="gamesLoading"
class="absolute inset-0 bg-zinc-900/50 flex items-start p-4 justify-center"
>
<div role="status">
<svg
aria-hidden="true"
class="size-8 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">{{ $t("common.srLoading") }}</span>
</div>
</div>
</ul>
<nav
class="flex items-center justify-between border-t border-white/10 px-4 sm:px-0"
>
<div class="-mt-px flex w-0 flex-1">
<button
class="group inline-flex items-center border-t-2 border-transparent pt-4 pr-1 text-sm font-medium text-zinc-400 disabled:text-zinc-700 hover:not-disabled:border-white/20 hover:not-disabled:text-zinc-200"
:disabled="currentIndex == 0"
@click="previousPage"
>
<ArrowLongLeftIcon
class="mr-3 size-5 text-zinc-500 group-disabled:text-zinc-700"
aria-hidden="true"
/>
{{ $t("library.admin.nav.backPagination") }}
</button>
</div>
<div class="hidden md:-mt-px md:flex">
<button
v-for="page in maxPages"
:key="page"
:class="[
currentIndex == page - 1
? 'border-blue-400 text-blue-400'
: 'border-transparent hover:not-disabled:text-zinc-white/20 text-zinc-400 hover:not-disabled:border-white/20',
'transition inline-flex items-center border-t-2 px-4 pt-4 text-sm font-medium',
]"
@click="currentIndex = page - 1"
>
{{ page }}
</button>
</div>
<div class="-mt-px flex w-0 flex-1 justify-end">
<button
class="group inline-flex items-center border-t-2 border-transparent pt-4 pl-1 text-sm font-medium text-zinc-400 disabled:text-zinc-700 hover:not-disabled:border-white/20 hover:not-disabled:text-zinc-200"
:disabled="currentIndex == maxPages - 1"
@click="nextPage"
>
{{ $t("library.admin.nav.nextPagination") }}
<ArrowLongRightIcon
class="ml-3 size-5 text-zinc-500 group-disabled:text-zinc-700"
aria-hidden="true"
/>
</button>
</div>
</nav>
</div>
</template>
@@ -289,8 +536,23 @@ import {
ArrowTopRightOnSquareIcon,
InformationCircleIcon,
StarIcon,
WrenchScrewdriverIcon,
ArrowLongLeftIcon,
ArrowLongRightIcon,
ChevronDownIcon,
FunnelIcon,
} from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { AdminLibraryGame } from "~/server/api/v1/admin/library/index.get";
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
} from "@headlessui/vue";
const { t } = useI18n();
@@ -302,33 +564,46 @@ useHead({
title: t("library.admin.title"),
});
const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library");
type LibraryStateGame = (typeof libraryState.games)[number]["game"];
const toImport = ref(
Object.values(libraryState.unimportedGames).flat().length > 0,
const { unimportedGames, hasLibraries } = await $dropFetch(
"/api/v1/admin/library/libraries",
);
const libraryGames = ref<
Array<
LibraryStateGame & {
status: "online" | "offline";
hasNotifications?: boolean;
notifications: {
noVersions?: boolean;
toImport?: boolean;
offline?: boolean;
};
}
>
>(
libraryState.games.map((e) => {
const route = useRoute();
const router = useRouter();
// Hard limit on server
const pageSize = 24;
const currentIndex = ref(
route.query.page ? parseInt(route.query.page.toString()) - 1 : 0,
);
const maxIndex = ref(0);
const maxPages = computed(() => Math.ceil(maxIndex.value / pageSize));
const games = ref<AdminLibraryGame[]>([]);
const gamesLoading = ref(false);
const searchQuery = ref("");
function nextPage() {
if (currentIndex.value < maxPages.value - 1) {
currentIndex.value++;
}
}
function previousPage() {
if (currentIndex.value > 0) {
currentIndex.value--;
}
}
const toImport = ref(Object.values(unimportedGames).flat().length > 0);
const libraryGames = computed(() =>
games.value.map((e) => {
if (e.status == "offline") {
return {
...e.game,
status: "offline" as const,
status: "offline",
hasNotifications: true,
notifications: {
offline: true,
@@ -351,19 +626,6 @@ const libraryGames = ref<
}),
);
const filteredLibraryGames = computed(() =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore excessively deep ts
libraryGames.value.filter((e) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
return true;
return false;
}),
);
async function deleteGame(id: string) {
await $dropFetch(`/api/v1/admin/game/${id}`, {
method: "DELETE",
@@ -392,4 +654,147 @@ async function featureGame(id: string) {
libraryGames.value[gameIndex].featured = !game.featured;
gameFeatureLoading.value[game.id] = false;
}
const currentFilters = ref<{ [key: string]: boolean }>({});
function createFilterKey(
filter: { value: string },
subfilter: { value: string },
) {
return `${filter.value}.${subfilter.value}`;
}
const filters = computed(
() =>
({
version: [
{
value: "none",
label: t("library.admin.nav.filters.version.none"),
},
/*{
value: "available",
label: t("library.admin.nav.filters.version.available"),
},*/
],
metadata: [
{
value: "featured",
label: t("library.admin.nav.filters.metadata.featured"),
},
{
value: "noCarousel",
label: t("library.admin.nav.filters.metadata.noCarousel"),
},
{
value: "emptyDescription",
label: t("library.admin.nav.filters.metadata.emptyDescription"),
},
],
}) as const,
);
const filterScaffold = computed(
() =>
({
version: {
title: t("library.admin.nav.filters.version.title"),
value: "version",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values: filters.value.version as any,
},
metadata: {
title: t("library.admin.nav.filters.metadata.title"),
value: "metadata",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values: filters.value.metadata as any,
},
}) satisfies {
[key in keyof typeof filters.value]: {
title: string;
value: string;
values: Array<{ value: string; label: string }>;
};
},
);
const sorts: Array<StoreSortOption> = [
{
name: "Default",
param: "default",
},
{
name: "Newest",
param: "newest",
},
{
name: "Recently Added",
param: "recent",
},
{
name: "Name",
param: "name",
},
];
const currentSort = ref(sorts[0].param);
const sortOrder = ref<"asc" | "desc">("desc");
function handleSortClick(option: StoreSortOption, event: MouseEvent) {
event.stopPropagation();
if (currentSort.value === option.param) {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
} else {
currentSort.value = option.param;
sortOrder.value = option.param === "name" ? "asc" : "desc";
}
}
async function fetchPage() {
gamesLoading.value = true;
const { results, count } = await $dropFetch("/api/v1/admin/library", {
query: {
skip: currentIndex.value * pageSize,
limit: pageSize,
sort: currentSort.value,
order: sortOrder.value,
filters: Object.entries(currentFilters.value)
.filter(([_, enabled]) => enabled)
.map(([name, _]) => name)
.join(","),
query: searchQuery.value ? searchQuery.value : undefined,
},
failTitle: "Failed to fetch game library",
});
maxIndex.value = count;
games.value = results;
gamesLoading.value = false;
router.push({
path: route.path,
query: {
...route.query,
page: currentIndex.value + 1,
},
});
}
function watchHandler() {
fetchPage();
document.body.scrollTop = document.documentElement.scrollTop = 0;
}
watch([currentIndex, currentSort, sortOrder], watchHandler);
watch(currentFilters, watchHandler, { deep: true });
let searchTimeout: NodeJS.Timeout | undefined;
watch(searchQuery, () => {
if (searchTimeout) clearTimeout(searchTimeout);
gamesLoading.value = true;
searchTimeout = setTimeout(() => {
watchHandler();
}, 80);
});
await fetchPage();
</script>
+363
View File
@@ -0,0 +1,363 @@
<template>
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1
class="inline-flex items-center gap-x-2 text-base font-semibold text-white"
>
<WrenchScrewdriverIcon class="size-6" /> Mass Import Tool
</h1>
<p class="mt-2 text-sm text-zinc-300">
Quickly import a large amount of versions at once.
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<LoadingButton
:loading="false"
:disabled="!hasSelected"
@click="triggerImport"
>
Import &rarr;
</LoadingButton>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div class="group/table relative">
<table
class="relative min-w-full table-fixed divide-y divide-white/15"
>
<thead>
<tr>
<th scope="col" class="relative px-7 sm:w-12 sm:px-6">
<div
class="group absolute top-1/2 left-4 -mt-2 grid size-4 grid-cols-1"
>
<input
v-model="globalState"
:indeterminate="globalState === 'indeterminate'"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-white/20 bg-zinc-800/50 checked:border-blue-500 checked:bg-blue-500 indeterminate:border-blue-500 indeterminate:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:border-white/10 disabled:bg-zinc-800 disabled:checked:bg-zinc-800 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-zinc-50/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>
</th>
<th
scope="col"
class="w-full py-3.5 pr-3 text-left text-sm font-semibold text-white whitespace-nowrap"
>
Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-white whitespace-nowrap"
>
Type
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-white whitespace-nowrap"
>
Display Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-white whitespace-nowrap"
>
Setup Mode
</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10 bg-zinc-900">
<template v-for="game in massImport" :key="game.id">
<tr class="text-sm/6 text-zinc-100 bg-zinc-950">
<th scope="colgroup" colspan="5" class="py-2 text-left">
<div class="inline-flex gap-x-2 px-4">
<img
:src="useObject(game.icon)"
class="size-6 rounded-sm"
/>
{{ game.name }}
</div>
</th>
</tr>
<tr
v-for="version in game.versions"
:key="version.identifier"
class="group has-checked:bg-zinc-800/50"
>
<td class="relative px-7 sm:w-12 sm:px-6">
<div
className="hidden group-has-checked:block absolute inset-y-0 left-0 w-0.5 bg-blue-500"
/>
<div
class="absolute top-1/2 left-4 -mt-2 grid size-4 grid-cols-1"
>
<input
v-model="version.enabled"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-white/20 bg-zinc-800/50 checked:border-blue-500 checked:bg-blue-500 indeterminate:border-blue-500 indeterminate:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 disabled:border-white/10 disabled:bg-zinc-800 disabled:checked:bg-zinc-800 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-zinc-50/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>
</td>
<td
class="py-4 pr-3 text-sm font-medium whitespace-nowrap text-white group-has-checked:text-blue-400"
>
{{ version.name }}
</td>
<td
class="px-3 py-4 text-sm whitespace-nowrap text-zinc-400"
>
{{ version.type }}
</td>
<td class="px-3 text-sm whitespace-nowrap text-zinc-400">
<input
id="display-name"
v-model="version.settings.displayName"
type="text"
class="min-w-48 block w-full rounded-md border-radius-md bg-zinc-900 px-3 py-1.5 text-white outline-2 -outline-offset-1 outline-zinc-800 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-500 sm:text-sm/6"
placeholder="My New Version"
/>
</td>
<td class="px-3 text-sm whitespace-nowrap text-zinc-400">
<Switch
v-model="version.settings.setupMode"
:class="[
version.settings.setupMode
? 'bg-blue-600'
: 'bg-zinc-900',
'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="[
version.settings.setupMode
? '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>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
<TransitionRoot as="template" :show="open">
<Dialog class="relative z-10" @close="open = false">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to=""
leave="ease-in duration-200"
leave-from=""
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-zinc-900/70 transition-opacity"></div>
</TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div
class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to=" translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from=" translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel
class="relative transform overflow-hidden rounded-lg bg-zinc-900 px-4 pt-5 pb-4 text-left shadow-xl outline -outline-offset-1 outline-white/10 transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"
>
<div>
<div
class="mx-auto flex size-12 items-center justify-center rounded-full bg-yellow-500/10"
>
<ExclamationTriangleIcon
class="size-6 text-yellow-400"
aria-hidden="true"
/>
</div>
<div class="mt-3 text-center sm:mt-5">
<DialogTitle
as="h3"
class="text-base font-semibold text-white"
>This tool is basic.</DialogTitle
>
<div class="mt-2">
<p class="text-sm text-zinc-400">
While it is useful to import a lot of versions at once,
this tool is designed for migrating from other projects,
rather than building your Drop library from scratch.
<span class="text-sm text-zinc-100 font-bold">
It is missing functionality present in the normal
import wizard.
</span>
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
type="button"
class="inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-800"
@click="open = false"
>
Accept
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</div>
</template>
<script setup lang="ts">
import {
WrenchScrewdriverIcon,
ExclamationTriangleIcon,
} from "@heroicons/vue/24/outline";
import {
Switch,
Dialog,
DialogPanel,
DialogTitle,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
definePageMeta({
layout: "admin",
});
const open = ref(true);
const raw = await $dropFetch("/api/v1/admin/import/massversion");
const massImport = ref(
raw.map((game) => ({
...game,
versions: game.versions!.map((version) => ({
...version,
enabled: true,
settings: {
displayName: undefined,
setupMode: false,
},
})),
})),
);
const hasSelected = computed(
() =>
massImport.value
.map((v) => v.versions)
.flat()
.filter((e) => e.enabled).length > 0,
);
const globalState = computed({
get() {
let lastSeen = undefined;
for (const game of massImport.value) {
for (const version of game.versions!) {
if (lastSeen === undefined) {
lastSeen = version.enabled;
continue;
}
if (lastSeen != version.enabled) return "indeterminate" as const;
}
}
return lastSeen;
},
set(v) {
if (typeof v !== "boolean") return;
for (const game of massImport.value) {
for (const version of game.versions!) {
version.enabled = v;
}
}
},
});
const router = useRouter();
async function triggerImport() {
const { taskId } = await $dropFetch("/api/v1/admin/import/massversion", {
method: "POST",
body: {
versions: massImport.value
.map((game) =>
game.versions
.filter((version) => version.enabled)
.map((version) => ({
id: game.id,
version: {
type: version.type,
identifier: version.identifier,
name: version.name,
},
...version.settings,
})),
)
.flat(),
},
});
router.push(`/admin/task/${taskId}`);
}
</script>
+6 -12
View File
@@ -11,18 +11,12 @@
<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>
<ImageUpload
:object-id="company.mLogoObjectId"
:open-modal="() => (uploadLogoOpen = true)"
:hover-text="$t('library.admin.metadata.companies.editor.uploadIcon')"
:image-alt="`${company.mName} logo`"
/>
<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"
+1 -1
View File
@@ -78,7 +78,7 @@
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") }}
{{ $t("common.delete") }}
</button>
</div>
</div>
+15 -1
View File
@@ -42,13 +42,21 @@
import {
BuildingStorefrontIcon,
CodeBracketIcon,
ServerIcon,
ServerStackIcon,
} from "@heroicons/vue/24/outline";
const navigation: Array<NavigationItem & { icon: Component }> = [
{
label: $t("header.admin.settings.store"),
label: $t("header.admin.settings.general"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: ServerIcon,
},
{
label: $t("header.admin.settings.store"),
route: "/admin/settings/store",
prefix: "/admin/settings/store",
icon: BuildingStorefrontIcon,
},
{
@@ -57,6 +65,12 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
prefix: "/admin/settings/tokens",
icon: CodeBracketIcon,
},
{
label: "Services",
route: "/admin/settings/services",
prefix: "/admin/settings/services",
icon: ServerStackIcon,
},
];
// const notifications = useNotifications();
+124 -58
View File
@@ -1,59 +1,103 @@
<template>
<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>
<div>
<form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="pb-4 border-b border-zinc-700 w-2xl mt-2">
<h2 class="text-xl font-semibold text-zinc-100">
{{ $t("settings.admin.general.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="mt-4">
<label
for="serverName"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("settings.admin.general.serverName") }}</label
>
<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>
<div class="mt-2">
<input
id="name"
v-model="settings.generalSettings.serverName"
type="text"
name="serverName"
:placeholder="$t('settings.admin.general.serverNamePlaceholder')"
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"
@input="(event) => updateServerName(event)"
/>
</div>
</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>
<div class="mt-4">
<p for="logo" class="block text-sm/6 font-medium text-zinc-100">
{{ $t("settings.admin.general.logo") }}
</p>
<ul class="flex gap-3">
<li class="w-40 flex flex-col items-center">
<div class="flex items-center max-w-25 mt-2 mb-2 h-full">
<ImageUpload
:hover-text="$t('settings.admin.general.uploadLogo')"
:open-modal="openModal"
:object-id="mCustomLogoObjectId"
:image-alt="$t('settings.admin.general.applicationLogo')"
/>
</div>
<label class="flex flex-col text-zinc-100 text-sm items-center">
<div class="flex items-center">
<input
v-model="settings.generalSettings.mLogoObjectId"
class="mr-1"
type="radio"
name="mLogoObjectId"
:value="mCustomLogoObjectId"
@input="updateFormLogo"
/>
{{ $t("settings.admin.general.customLogo") }}
</div>
</label>
</li>
<li class="w-40 flex flex-col items-center">
<div class="flex w-25 mt-2 mb-2 h-full">
<DropLogo @click="() => updateFormLogo(null)" />
</div>
<label class="flex flex-col text-zinc-100 text-sm items-center">
<div class="flex items-center">
<input
v-model="settings.generalSettings.mLogoObjectId"
class="mr-1"
type="radio"
name="isDefaultLogo"
:checked="settings.generalSettings.mLogoObjectId === null"
:value="null"
@input="() => updateFormLogo(null)"
/>
{{ $t("settings.admin.general.defaultLogo") }}
</div>
</label>
</li>
</ul>
</div>
</div>
<ModalUploadFile
v-model="uploadLogoOpen"
:endpoint="`/api/v1/admin/settings/logo`"
accept="image/*"
@upload="updateLogo"
/>
<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>
</div>
</template>
<script setup lang="ts">
import { FetchError } from "ofetch";
import type { Settings } from "~/server/internal/utils/types";
const { t } = useI18n();
@@ -65,30 +109,40 @@ useHead({
title: t("settings.admin.title"),
});
const settings = await $dropFetch("/api/v1/settings");
const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data");
const settings = ref<Settings>(await $dropFetch("/api/v1/settings"));
const allowSave = ref(false);
const allowSave = ref<boolean>(false);
const uploadLogoOpen = ref<boolean>(false);
const showGamePanelTextDecoration = ref<boolean>(
settings.showGamePanelTextDecoration,
const mCustomLogoObjectId = ref<string>(
settings.value.generalSettings.mLogoObjectId || "",
);
function setShowTitleDescription(value: boolean) {
showGamePanelTextDecoration.value = value;
const updateServerName = (event: InputEvent) => {
settings.value.generalSettings.serverName =
(event.target as HTMLInputElement)?.value || "";
allowSave.value = true;
}
};
const openModal = () => {
uploadLogoOpen.value = true;
};
const saving = ref<boolean>(false);
async function saveSettings() {
saving.value = true;
try {
await $dropFetch("/api/v1/admin/settings", {
settings.value = await $dropFetch("/api/v1/admin/settings", {
method: "PATCH",
body: {
showGamePanelTextDecoration: showGamePanelTextDecoration.value,
generalSettings: {
serverName: settings.value.generalSettings.serverName,
mLogoObjectId: settings.value.generalSettings.mLogoObjectId,
},
},
});
window.location.reload();
} catch (e) {
createModal(
ModalType.Notification,
@@ -105,4 +159,16 @@ async function saveSettings() {
saving.value = false;
allowSave.value = false;
}
function updateLogo(response: { id: string }) {
mCustomLogoObjectId.value = response.id;
settings.value.generalSettings.mLogoObjectId = response.id;
allowSave.value = true;
}
const updateFormLogo = (event: InputEvent | null) => {
settings.value.generalSettings.mLogoObjectId =
(event?.target as HTMLInputElement)?.value || null;
allowSave.value = true;
};
</script>
+91
View File
@@ -0,0 +1,91 @@
<template>
<div class="max-w-xl">
<div
class="divide-y divide-white/10 overflow-hidden rounded-lg bg-zinc-900 outline -outline-offset-1 outline-white/20 sm:grid sm:grid-cols-2 sm:divide-y-0"
>
<div
v-for="(service, serviceIdx) in services"
:key="service.name"
:class="[
serviceIdx === 0
? 'rounded-tl-lg rounded-tr-lg sm:rounded-tr-none'
: '',
serviceIdx === 1 ? 'sm:rounded-tr-lg' : '',
serviceIdx === services.length - 2 ? 'sm:rounded-bl-lg' : '',
serviceIdx === services.length - 1
? 'rounded-br-lg rounded-bl-lg sm:rounded-bl-none'
: '',
'group relative border-white/10 bg-zinc-800/50 p-6 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-500 sm:odd:not-nth-last-2:border-b sm:even:border-l sm:even:not-last:border-b',
]"
>
<div>
<span
:class="[
serviceMetadata[service.name].iconBackground,
serviceMetadata[service.name].iconForeground,
'inline-flex rounded-lg p-3',
]"
>
<component
:is="serviceMetadata[service.name].icon"
class="size-6"
aria-hidden="true"
/>
</span>
</div>
<div class="mt-8">
<h3 class="text-base font-semibold text-white">
<a :href="service.href" class="focus:outline-hidden">
<!-- Extend touch target to entire panel -->
<span class="absolute inset-0" aria-hidden="true"></span>
{{ serviceMetadata[service.name].title }}
</a>
</h3>
<p class="mt-2 text-sm text-zinc-400">
{{ serviceMetadata[service.name].description }}
</p>
</div>
<span
class="pointer-events-none absolute top-6 right-6"
aria-hidden="true"
>
<CheckIcon
:class="[
'size-6',
service.healthy ? 'text-green-600' : 'text-zinc-500',
]"
/>
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ArrowDownTrayIcon, CheckIcon } from "@heroicons/vue/24/outline";
definePageMeta({
layout: "admin",
});
const services = await $dropFetch("/api/v1/admin/services");
const { t } = useI18n();
const serviceMetadata = computed(() => ({
torrential: {
title: t("services.torrential.title"),
description: t("services.torrential.description"),
iconForeground: "text-blue-400",
iconBackground: "bg-blue-500/10",
icon: ArrowDownTrayIcon,
},
nginx: {
title: t("services.nginx.title"),
description: t("services.nginx.description"),
iconForeground: "text-green-400",
iconBackground: "bg-green-500/10",
icon: ArrowDownTrayIcon,
},
}));
</script>
+110
View File
@@ -0,0 +1,110 @@
<template>
<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>
<script setup lang="ts">
import { FetchError } from "ofetch";
const { t } = useI18n();
definePageMeta({
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.store.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: {
store: {
showGamePanelTextDecoration: showGamePanelTextDecoration.value,
},
},
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: `Failed to save settings.`,
description:
e instanceof FetchError
? (e.statusMessage ?? e.message)
: (e as string).toString(),
},
(_, c) => c(),
);
}
saving.value = false;
allowSave.value = false;
}
</script>
-1
View File
@@ -206,7 +206,6 @@ async function createToken(
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} catch {
+41 -28
View File
@@ -11,41 +11,54 @@
</i18n-t>
</NuxtLink>
<div
v-if="task && task.error"
class="grow w-full flex items-center justify-center"
>
<div class="flex flex-col items-center">
<ExclamationCircleIcon
class="h-12 w-12 text-red-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1
class="text-3xl font-semibold font-display leading-6 text-zinc-100"
>
{{ task.error.title }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ task.error.description }}
</p>
</div>
</div>
</div>
</div>
<div v-else-if="task" class="flex flex-col w-full gap-y-4">
<div v-if="task" class="flex flex-col w-full gap-y-4">
<h1
class="inline-flex items-center gap-x-3 text-3xl text-zinc-100 font-bold font-display"
>
<div>
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
<CheckCircleIcon v-if="task.success" class="size-8 text-green-600" />
<XMarkIcon v-else-if="task.error" class="size-8 text-red-600" />
<div v-else class="size-4 bg-blue-600 rounded-full animate-pulse" />
</div>
{{ task.name }}
</h1>
<div
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
v-if="task.error"
class="rounded-md bg-red-500/15 p-4 outline outline-red-500/25"
>
<div class="flex">
<div class="shrink-0">
<XCircleIcon class="size-5 text-red-400" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-200">
{{ task.error.title }}
</h3>
<div class="mt-2 text-sm text-red-200/80">
{{ task.error.description }}
</div>
</div>
</div>
</div>
<ul class="flex flex-row flex-wrap items-center h-12 gap-x-3">
<li
v-for="[name, link] in task.actions.map((v) => v.split(':'))"
:key="link"
>
<NuxtLink :href="link">
<LoadingButton :loading="false"> {{ name }} </LoadingButton>
</NuxtLink>
</li>
<li
v-if="task.actions.length == 0"
class="text-md uppercase font-display font-bold text-zinc-700"
>
{{ $t("tasks.admin.noActions") }}
</li>
</ul>
<div
class="bg-zinc-950 p-2 rounded-md h-[70vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
>
<LogLine
v-for="(_, idx) in task.log"
@@ -78,8 +91,8 @@
</template>
<script setup lang="ts">
import { CheckCircleIcon } from "@heroicons/vue/16/solid";
import { ExclamationCircleIcon } from "@heroicons/vue/24/solid";
import { CheckCircleIcon } from "@heroicons/vue/24/solid";
import { XMarkIcon, XCircleIcon } from "@heroicons/vue/24/outline";
const route = useRoute();
const taskId = route.params.id.toString();
+62 -6
View File
@@ -23,18 +23,69 @@
{{ $t("tasks.admin.noTasksRunning") }}
</div>
</div>
<div class="mt-6 w-full grid lg:grid-cols-2 gap-8">
<div>
<div class="mt-6 w-full grid lg:grid-cols-3 gap-8">
<div class="col-span-2">
<h2 class="text-sm font-medium text-zinc-400">
{{ $t("tasks.admin.completedTasksTitle") }}
</h2>
<ul role="list" class="mt-4 grid grid-cols-1 gap-6 lg:grid-cols-2">
<ul
role="list"
class="mt-4 grid grid-cols-1 gap-2 lg:grid-cols-4 overflow-y-scroll max-h-[80vh]"
>
<li
v-for="task in historicalTasks"
:key="task.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<TaskWidget :task="task" />
<div class="flex w-full items-center justify-between space-x-6 p-2">
<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>
<ul v-if="task.actions" class="mt-1 flex flex-row gap-x-2">
<NuxtLink
v-for="[name, link] in task.actions.map((v) =>
v.split(':'),
)"
:key="link"
:href="link"
class="text-xs text-zinc-100 bg-blue-900 p-1 rounded"
>{{ name }}</NuxtLink
>
</ul>
<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>
</li>
</ul>
</div>
@@ -120,6 +171,7 @@
</div>
</template>
<script lang="ts" setup>
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import { PlayIcon } from "@heroicons/vue/24/outline";
import type { TaskGroup } from "~/server/internal/tasks/group";
@@ -163,9 +215,13 @@ const scheduledTasks: {
name: "",
description: "",
},
"import:version": {
name: "",
description: "",
},
debug: {
name: "Debug Task",
description: "Does debugging things.",
name: "",
description: "",
},
};
+43
View File
@@ -0,0 +1,43 @@
<template>
<main class="mx-auto w-full max-w-7xl px-6 pt-10 pb-16 sm:pb-24 lg:px-8">
<ApplicationLogo class="mx-auto h-10 w-auto sm:h-12" />
<div class="mx-auto mt-20 max-w-md text-center sm:mt-24">
<h1
class="mt-4 text-3xl font-semibold tracking-tight text-balance text-white sm:text-4xl"
>
{{ $t("auth.2fa.title") }}
</h1>
<p class="mt-6 text-sm font-medium text-pretty text-zinc-400 sm:text-md">
{{ $t("auth.2fa.description") }}
</p>
</div>
<div class="mx-auto mt-16 flow-root max-w-lg sm:mt-20">
<NuxtPage />
<div v-if="route.path !== '/auth/mfa'" class="mt-10 flex justify-center">
<NuxtLink
:href="{ path: '/auth/mfa', query: route.query }"
class="text-sm/6 font-semibold text-blue-400"
><i18n-t keypath="auth.2fa.backToOptions" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
</template>
</i18n-t></NuxtLink
>
</div>
</div>
</main>
</template>
<script setup lang="ts">
const route = useRoute();
definePageMeta({
layout: false,
});
useHead({
titleTemplate(title) {
return title ? `${title} - Drop` : "Two-factor authentication - Drop";
},
});
</script>
+64
View File
@@ -0,0 +1,64 @@
<template>
<ul
role="list"
class="-mt-6 divide-y divide-white/10 border-b border-white/10"
>
<li v-if="mfa.includes(MFAMec.TOTP)" class="relative flex gap-x-6 py-6">
<div
class="flex size-10 flex-none items-center justify-center rounded-lg bg-zinc-800/50 shadow-xs outline-1 -outline-offset-1 outline-white/10"
>
<ClockIcon class="size-6 text-blue-400" aria-hidden="true" />
</div>
<div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-white">
<NuxtLink :to="{ path: '/auth/mfa/totp', query: route.query }">
<span class="absolute inset-0" aria-hidden="true"></span>
{{ $t("auth.2fa.totp.title") }}
</NuxtLink>
</h3>
<p class="mt-2 text-sm/6 text-zinc-400">
{{ $t("auth.2fa.totp.description") }}
</p>
</div>
<div class="flex-none self-center">
<ChevronRightIcon class="size-5 text-zinc-500" aria-hidden="true" />
</div>
</li>
<li v-if="mfa.includes(MFAMec.WebAuthn)" class="relative flex gap-x-6 py-6">
<div
class="flex size-10 flex-none items-center justify-center rounded-lg bg-zinc-800/50 shadow-xs outline-1 -outline-offset-1 outline-white/10"
>
<KeyIcon class="size-6 text-blue-400" aria-hidden="true" />
</div>
<div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-white">
<NuxtLink :to="{ path: '/auth/mfa/webauthn', query: route.query }">
<span class="absolute inset-0" aria-hidden="true"></span>
{{ $t("auth.2fa.passkey.title") }}
</NuxtLink>
</h3>
<p class="mt-2 text-sm/6 text-zinc-400">
{{ $t("auth.2fa.passkey.description") }}
</p>
</div>
<div class="flex-none self-center">
<ChevronRightIcon class="size-5 text-zinc-500" aria-hidden="true" />
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import {
ChevronRightIcon,
ClockIcon,
KeyIcon,
} from "@heroicons/vue/24/outline";
import { MFAMec } from "~/prisma/client/enums";
const mfa = await $dropFetch("/api/v1/auth/mfa");
const route = useRoute();
const router = useRouter();
if (mfa.length == 0) router.push("/");
</script>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col items-center">
<div v-if="success">
<CheckIcon class="w-8 h-8 text-green-600" />
</div>
<div v-else-if="loading">
<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 v-else class="inline-flex gap-x-2">
<CodeInput
:length="6"
placeholder="123456"
size="w-10 h-10 text-lg lg:w-16 lg:h-16 lg:text-2xl"
@complete="(code) => signin(code)"
/>
</div>
<div v-if="error" class="mt-8 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import { CheckIcon } from "@heroicons/vue/24/outline";
import type { FetchError } from "ofetch";
definePageMeta({
layout: false,
});
const loading = ref<boolean>(false);
const success = ref(false);
const error = ref<undefined | string>(undefined);
async function signin(code: string) {
loading.value = true;
error.value = undefined;
try {
await $dropFetch("/api/v1/auth/mfa/totp", {
method: "POST",
body: { code },
});
} catch (e) {
error.value = (e as FetchError)?.data?.message ?? e;
loading.value = false;
return;
}
success.value = true;
await completeSignin();
}
</script>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col items-center">
<div v-if="success">
<CheckIcon class="w-8 h-8 text-green-600" />
</div>
<div v-else-if="loading">
<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 v-else class="inline-flex gap-x-2">
<LoadingButton :loading="false" @click="() => tryAuthWrapper()">
{{ $t("auth.2fa.passkey.signinButton") }}
</LoadingButton>
</div>
<div v-if="error" class="mt-8 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { startAuthentication } from "@simplewebauthn/browser";
import type { FetchError } from "ofetch";
const loading = ref<boolean>(false);
const success = ref(false);
const error = ref<undefined | string>(undefined);
async function tryAuth() {
const optionsJSON = await $dropFetch("/api/v1/auth/mfa/webauthn/start", {
method: "POST",
});
let asseResp;
try {
asseResp = await startAuthentication({ optionsJSON });
} catch {
throw createError({
statusCode: 400,
message: "Passkey sign-in cancelled.",
});
}
if (!asseResp)
throw createError({
statusCode: 400,
message: "Passkey sign-in cancelled.",
});
await $dropFetch("/api/v1/auth/mfa/webauthn/finish", {
method: "POST",
body: asseResp,
});
await completeSignin();
}
async function tryAuthWrapper() {
loading.value = true;
try {
await tryAuth();
success.value = true;
} catch (e) {
error.value = (e as FetchError)?.data?.message ?? e;
}
loading.value = false;
}
</script>

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