40 Commits

Author SHA1 Message Date
fd828d5b50 Update droplet & other small features, and bump version for v0.3.3 (#212)
* fix: bump version and fix context timeout issues

* fix: bump droplet

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

* feat: api token creation and management

* fix: lint

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 13:49:31 +10:00
6d89b7e510 Admin task UI update & QoL (#194)
* feat: revise library source names & update droplet

* feat: add internal name hint to library sources

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

* fix: admin invitation localisation issue

* feat: #164

* feat: overhaul task UIs, #163

* fix: remove debug task

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

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

* fix linting

* fix linting

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

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

This reverts commit f98b811ab9.

* replace buffer implementation with md5 hash from node:crypto

* fix linting.. again

---------

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

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

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

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

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

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

* fix: use taskHandler as source of truth for imports

* fix: task formatting

* fix: zip downloads

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

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

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

* feat: #171

* fix: improvements to library scanning on admin UI

* feat: v2 download API

* fix: add download context cleanup

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

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

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

Currently translated at 100.0% (458 of 458 strings)

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

* Translated using Weblate (English (en_PIRATE))

Currently translated at 80.7% (370 of 458 strings)

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

* Translated using Weblate (English)

Currently translated at 100.0% (458 of 458 strings)

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

* Translated using Weblate (English (en_PIRATE))

Currently translated at 83.4% (382 of 458 strings)

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

* Added translation using Weblate (Russian)

* Translated using Weblate (French)

Currently translated at 49.1% (225 of 458 strings)

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

* Translated using Weblate (German)

Currently translated at 54.3% (249 of 458 strings)

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

* Translated using Weblate (German)

Currently translated at 54.3% (249 of 458 strings)

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

* Translated using Weblate (Russian)

Currently translated at 6.1% (28 of 458 strings)

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

* Translated using Weblate (English (en_PIRATE))

Currently translated at 84.0% (385 of 458 strings)

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

* Translated using Weblate (French)

Currently translated at 97.8% (448 of 458 strings)

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

* Translated using Weblate (French)

Currently translated at 97.8% (448 of 458 strings)

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

* Translated using Weblate (German)

Currently translated at 68.5% (314 of 458 strings)

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

* Translated using Weblate (German)

Currently translated at 68.5% (314 of 458 strings)

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

* Translated using Weblate (German)

Currently translated at 68.5% (314 of 458 strings)

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

* Translated using Weblate (English)

Currently translated at 100.0% (458 of 458 strings)

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

* Translated using Weblate (French)

Currently translated at 97.3% (446 of 458 strings)

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

* Update translation files

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: add drop logo aria label

* feat: disable all crawling by bots

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

* feat: hide logo when inside wordmark for aria

* docs: update readme and contributing

* feat: default page in setup wizzard is img

* ci: remove redundant perm in release ci

* docs: update translation links and add progress image

* fix: lang selector using wrong weblate link
2025-08-04 16:30:22 +10:00
a435ead916 Fix various typos (#156)
Found via `codespell -q 3 -S "./yarn.lock" -L pris`
2025-08-01 21:53:31 +10:00
75 changed files with 3356 additions and 599 deletions

View File

@ -8,9 +8,6 @@ on:
schedule:
- cron: "0 2 * * *" # run at 2 AM UTC
permissions:
contents: read
jobs:
web:
name: Push website Docker image to registry

View File

@ -16,11 +16,15 @@ you would make is not already covered.
- [Reporting Issues](#reporting-issues)
- [You have a problem](#you-have-a-problem)
- [You have a suggestion](#you-have-a-suggestion)
- [Development](#development)
- [Note: `--optional` flag is **REQUIRED**](#note-optional-flag-is-required)
- [Tech Stack](#tech-stack)
- [Submitting Pull Requests](#submitting-pull-requests)
- [Getting started](#getting-started)
- [You have a solution](#you-have-a-solution)
- [You have an addition](#you-have-an-addition)
- [Use the Search, Luke](#use-the-search-luke)
- [Translation](#translation)
- [Commit Guidelines](#commit-guidelines)
- [Format](#format)
- [Style](#style)
@ -65,6 +69,31 @@ If you find one, comment on it, so we know more people are supporting it.
If not, you can go ahead and create an issue. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
## Development
To get started with development, you need `yarn` and `docker compose` installed (or know how to set up a PostgreSQL database).
Steps:
1. Run `git submodule update --init --recursive` to setup submodules
1. Copy the `.env.example` to `.env` and add any api keys you need to use (e.g. for the Giant Bomb API)
- You can find other configuration options in the [documentation](https://docs.droposs.org/)
1. Create the `.data` directory with `mkdir .data`
1. Ensure that your user owns the `.data` directory with `sudo chown -R $(id -u $(whoami))`
1. Open up a terminal and navigate to `dev-tools`, and run `docker compose up`
1. Open up another terminal in the root directory of the project and run `yarn` and then `yarn prisma migrate dev` to setup the database
1. Run `yarn dev` to start the development server
As part of the first-time bootstrap, Drop creates an invitation with the fixed id of 'admin'. So, to create an admin account, go to:
http://localhost:3000/auth/register?id=admin
### Tech Stack
This repo uses the Nuxt 3 + TailwindCSS stack, with the `yarn` package manager.
For the database, Drop uses Prisma connected to PostgreSQL.
## Submitting Pull Requests
### Getting started
@ -132,7 +161,7 @@ and [create an issue](#reporting-issues) or [submit a PR](#submitting-pull-reque
## Translation
If you want to help translate Drop, we would love to have your help! You can do so on our weblate instance. Please make sure to read the [message format syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) page before starting. Failure to do so may result in your translations causing errors in Drop.
If you want to help translate Drop, we would love to have your help! You can do so on our [weblate instance](https://translate.droposs.org/engage/drop/). Please make sure to **read** the [message format syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) page before starting. We use this special syntax to enable high quality translations, and failure to do so may result in your translations **causing errors** in Drop.
## Commit Guidelines

View File

@ -42,6 +42,8 @@ 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
RUN pnpm install prisma@6.11.1
# init prisma to download all required files
RUN pnpm prisma init
COPY --from=build-system /app/package.json ./
COPY --from=build-system /app/.output ./app

View File

@ -6,73 +6,32 @@
# Drop
[![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org)
[![Docs](https://img.shields.io/badge/DOCS-black?style=for-the-badge&logo=docusaurus)](https://docs.droposs.org/)
[![Static Badge](https://img.shields.io/badge/FORUM-blue?style=for-the-badge)](https://forum.droposs.org)
[![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9)
[![Open Collective](https://img.shields.io/badge/OpenCollective-1F87FF?style=for-the-badge&logo=OpenCollective&logoColor=white)](https://opencollective.com/drop-oss)
[![Weblate project translated](https://img.shields.io/weblate/progress/drop?server=https%3A%2F%2Ftranslate.droposs.org&style=for-the-badge&logo=weblate)
](https://translate.droposs.org/engage/drop/)
Drop is an open-source game distribution platform, like GameVault or Steam. It's designed to distribute and shared DRM-free game quickly, all while being incredibly flexible, beautiful and fast.
Drop is an open-source game distribution platform, similar to GameVault or Steam. It's designed to distribute and share DRM-free games quickly, all while being incredibly flexible, beautiful, and fast.
<div align="center">
<img src="https://droposs.org/_ipx/f_webp&q_80/images/carousel/store.png" alt="Drop Screenshot" width="900rem"/>
</div>
## Philosophy
1. Drop is flexible. While abstractions and interfaces can make the codebase more complicated, the flexibility is worth it.
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from a username/password to SSO.
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with complexity available to the users who want it.
1. Drop is flexible. While abstractions and interfaces can complicate the codebase, the flexibility is worth it.
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from username/password to SSO.
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with advanced features available to users who want them.
## Deployment
To just deploy Drop, we've set up a simple docker compose file in deploy-template.
1. Generate a [GiantBomb API Key](https://www.giantbomb.com/api/)
2. Navigate to the deploy-template directory in your terminal (`cd deploy-template`)
3. Edit the compose.yml file (`nano compose.yml`) and copy your GiamtBomb API Key into the GIANT_BOMB_API_KEY environment variable
4. Run `docker compose up -d`
Your drop server should now be running. To register the admin user, navigate to http://your.drop.server.ip:3000/register?id=admin
and fill in the required forms
### Adding a game
To add a game to the drop library, do as follows:
1. Ensure that the current user owns the library folder with `sudo chown -R $(id -u $(whoami)) library`
2. `cd library`
3. `mkdir <GAME_NAME>` with the name of the game which you would like to register
4. `cd <GAME_NAME>`
5. `mkdir <VERSION_NAME>` Upload files for the specific game version to this folder
6. Navigate to http://your.drop.server.ip:3000/
7. Import game metadata (uses GiantBomb API Key) by selecting the game and specifying which entry to import
8. Navigate to http://your.drop.server.ip:3000/admin/library
9. You should see the game which you have just imported listed in this menu. There should be a notification that "Drop has detected you have new verions of this game to import". Select import here.
10. Select the game version to import and thus fill in fields as required.
## Tech Stack
This repo uses the Nuxt 3 + TailwindCSS stack, with the `yarn` package manager.
For the database, Drop uses Prisma connected to PostgreSQL.
## Development
To get started with development, you need `yarn --optional` and `docker compose` installed (or know how to set up a PostgreSQL database).
### Note: `--optional` flag is **REQUIRED**
Drop uses a utility package called droplet that's written in Rust. It has builts for Linux (GNU) and Windows, and they are set up as optional packages. `npm` installs these by default, but `yarn` needs the `--optional` flag.
Steps:
1. Run `git submodule update --init --recursive` to setup submodules
1. Copy the `.env.example` to `.env` and add your GiantBomb metadata key (more metadata providers coming)
1. Create the `.data` directory with `mkdir .data`
1. Ensure that your user owns the `.data` directory with `sudo chown -R $(id -u $(whoami))`
1. Open up a terminal and navigate to `dev-tools`, and run `docker compose up`
1. Open up another terminal in the root directory of the project and run `yarn` and then `yarn dev` to start the dev server
As part of the first-time bootstrap, Drop creates an invitation with the fixed id of 'admin'. So, to create an admin account, go to:
http://localhost:3000/auth/register?id=admin
See our documentation on how to [deploy Drop](https://docs.droposs.org/docs/guides/quickstart) for more information.
## Contributing
Please see the [in-depth contributing guide](CONTRIBUTING.md)
Please see the [in-depth contributing guide](CONTRIBUTING.md). The guide includes information on how to set up the project, how to contribute code, how to report issues, and even how to effectively translate Drop.
[![Drop Translation Progress](https://translate.droposs.org/widget/drop/horizontal-auto.svg)](https://translate.droposs.org/engage/drop/)

42
app.vue
View File

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

View File

@ -45,6 +45,7 @@ import {
LockClosedIcon,
DevicePhoneMobileIcon,
WrenchScrewdriverIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
import { UserIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue";
@ -73,6 +74,12 @@ 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",

View File

@ -1,5 +1,6 @@
<template>
<svg
aria-label="Drop Logo"
class="text-blue-400"
viewBox="0 0 24 24"
fill="none"
@ -9,6 +10,16 @@
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="currentColor"
stroke-width="2"
stroke-dasharray="100"
:stroke-dashoffset="dashArray"
/>
</svg>
</template>
<script setup lang="ts">
const props = defineProps<{ progress?: number }>();
const dashArray = computed(() =>
props.progress === undefined ? 0 : ((100 - props.progress) / 100) * 50 + 50,
);
</script>

View File

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

View File

@ -22,21 +22,17 @@
<!-- import games button -->
<NuxtLink
:href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
:href="canImport ? `/admin/library/${game.id}/import` : ''"
type="button"
:class="[
unimportedVersions.length > 0
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',
]"
>
{{
unimportedVersions.length > 0
canImport
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
@ -124,10 +120,16 @@ import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
// TODO implement UI for this page
defineProps<{ unimportedVersions: string[] }>();
const props = defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
const hasDeleted = ref(false);
const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameAndVersions = GameModel & { versions: GameVersionModel[] };
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions>
@ -176,6 +178,7 @@ async function deleteVersion(versionName: string) {
game.value.versions.findIndex((e) => e.versionName === versionName),
1,
);
hasDeleted.value = true;
} catch (e) {
createModal(
ModalType.Notification,

View File

@ -3,7 +3,7 @@
<LanguageSelectorListbox />
<NuxtLink
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
to="https://translate.droposs.org/projects/drop/"
to="https://translate.droposs.org/engage/drop/"
target="_blank"
>
<i18n-t
@ -18,8 +18,12 @@
</i18n-t>
</NuxtLink>
<DevOnly
><h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
<DevOnly>
<h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
</DevOnly>
</div>
</template>
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
</script>

27
components/LogLine.vue Normal file
View File

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

View File

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

View File

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

View File

@ -292,7 +292,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="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
>
<!-- Your content -->
<GamePanel

55
components/TaskWidget.vue Normal file
View File

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

View File

@ -91,10 +91,11 @@
</div>
<div class="flex items-center justify-center xl:col-span-3 mt-8">
<p
<NuxtLink
:to="`https://github.com/Drop-OSS/drop/releases/tag/${versionInfo.version}`"
class="text-xs text-zinc-700 hover:text-zinc-400 transition-colors duration-200 cursor-default select-none"
>
<i18n-t keypath="footer.version" tag="p" scope="global">
<i18n-t keypath="footer.version" tag="span" scope="global">
<template #version>
<span>{{ versionInfo.version }}</span>
</template>
@ -102,7 +103,7 @@
<span>{{ versionInfo.gitRef }}</span>
</template>
</i18n-t>
</p>
</NuxtLink>
</div>
</div>
</div>

View File

@ -46,10 +46,28 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
});
const request = requestParts.join("/");
// If not in setup
if (!getCurrentInstance()?.proxy) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts);
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts);
} catch (e) {
if (import.meta.client && opts?.failTitle) {
console.warn(e);
createModal(
ModalType.Notification,
{
title: opts.failTitle,
description:
(e as FetchError)?.statusMessage ?? (e as string).toString(),
//buttonText: $t("common.close"),
},
(_, c) => c(),
);
}
throw e;
}
}
const id = request.toString();
@ -64,26 +82,10 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
}
const headers = useRequestHeaders(["cookie", "authorization"]);
try {
const data = await $fetch(request, {
...opts,
headers: { ...headers, ...opts?.headers },
});
if (import.meta.server) state.value = data;
return data;
} catch (e) {
if (import.meta.client && opts?.failTitle) {
createModal(
ModalType.Notification,
{
title: opts.failTitle,
description:
(e as FetchError)?.statusMessage ?? (e as string).toString(),
buttonText: $t("common.close"),
},
(_, c) => c(),
);
}
throw e;
}
const data = await $fetch(request, {
...opts,
headers: { ...headers, ...opts?.headers },
});
if (import.meta.server) state.value = data;
return data;
};

View File

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

View File

@ -19,7 +19,7 @@
"title": "Messages from the Crows' Nest",
"unread": "Unread Messages"
},
"settings": "Account Settings, savvy?",
"settings": "Settings",
"title": "Yer Own Coffer"
},
"actions": "Deeds",
@ -38,6 +38,10 @@
"requestedAccess": "\"{name}\" has requested passage to yer Drop coffer.",
"success": "Shiver me timbers, it worked!"
},
"code": {
"description": "Use the secret map to dock ye ship when lacking a web surfer.",
"title": "Dock ye ship"
},
"confirmPassword": "Confirm @:auth.password",
"displayName": "Yer Scallywag Name",
"email": "Salty Mail",
@ -71,6 +75,7 @@
"srComma": ", {0}"
},
"common": {
"add": "Append",
"cannotUndo": "This deed cannot be undone, ye hear!",
"close": "Shut yer trap!",
"create": "Forge!",
@ -83,9 +88,12 @@
"insert": "Insert",
"name": "Name, argh!",
"noResults": "No plunder found!",
"noSelected": "No cargo selected.",
"remove": "Walk the plank",
"save": "Stow it!",
"saved": "Preserved",
"servers": "Ships",
"srLoading": "Loading, loading, argh...",
"srLoading": "Loading, loading, argh",
"tags": "Marks",
"today": "Today"
},
@ -228,6 +236,8 @@
"header": {
"admin": {
"admin": "Cap'n",
"metadata": "Meta argh",
"settings": "Shape",
"tasks": "Duties",
"users": "Crew"
},
@ -263,16 +273,18 @@
},
"gameLibrary": "Game Treasure Hoard",
"import": {
"bulkImportDescription": "When importing ye versions, ye won't be sent to the import duty.",
"bulkImportTitle": "Plunder the imports",
"import": "Import, ye dog!",
"link": "Import {arrow}",
"loading": "Loadin' plunder results, arrr...",
"loading": "Loadin' plunder results, arrr",
"search": "Search",
"searchPlaceholder": "Fallout 4, savvy?",
"selectDir": "Pick a directory, ye landlubber...",
"selectDir": "Pick a directory, ye landlubber",
"selectGame": "Pick plunder to import",
"selectGamePlaceholder": "Pick a game, ye dog...",
"selectGamePlaceholder": "Pick a game, ye dog",
"selectGameSearch": "Pick game",
"selectPlatform": "Pick a ship, ye scallywag...",
"selectPlatform": "Pick a ship, ye scallywag",
"version": {
"advancedOptions": "Advanced options, savvy?",
"import": "Import version",
@ -280,7 +292,7 @@
"launchCmd": "Launch executable/command, argh!",
"launchDesc": "Executable to launch the game, matey!",
"launchPlaceholder": "game.exe, aye!",
"loadingVersion": "Loading version charts...",
"loadingVersion": "Loading version charts",
"noAdv": "No advanced options for this rig, argh.",
"noVersions": "No versions to import, savvy!",
"platform": "Ship type",
@ -298,6 +310,16 @@
},
"withoutMetadata": "Import without charts"
},
"metadata": {
"companies": {
"action": "Shape {arrow}",
"addGame": {
"developer": "Creator?",
"noGames": "No games to plunder",
"publisher": "Distributor?"
}
}
},
"metadataProvider": "Charts Provider",
"noGames": "No plunder imported, savvy!",
"openEditor": "Open in Editor {arrow}",
@ -345,7 +367,7 @@
"launcherOpen": "Open in Launcher, argh!",
"noGames": "No plunder in treasure hoard, savvy!",
"notFound": "Plunder not found, matey!",
"search": "Search treasure hoard, ye dog...",
"search": "Search treasure hoard, ye dog",
"subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!"
},
"lowest": "lowest",
@ -360,7 +382,7 @@
"preview": "Preview, matey!",
"shortDesc": "Short description",
"submit": "Submit, ye scurvy dog!",
"tagPlaceholder": "Add a mark, ye dog...",
"tagPlaceholder": "Add a mark, ye dog",
"titles": "Title, argh!",
"uploadCover": "Hoist cover image"
},
@ -376,7 +398,7 @@
"none": "No articles, savvy!",
"notFound": "Article not found, matey!",
"search": "Search articles, ye dog!",
"searchPlaceholder": "Search articles, argh...",
"searchPlaceholder": "Search articles, argh",
"subheader": "Stay up to date with the latest charts and announcements, savvy!",
"title": "Latest News from the High Seas"
},

View File

@ -19,6 +19,28 @@
"title": "Notifications",
"unread": "Unread Notifications"
},
"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",
"success": "Successfully created token.",
"successNote": "Make sure to copy it now, as it won't be shown again."
},
"settings": "Settings",
"title": "Account Settings"
},
@ -39,8 +61,8 @@
"success": "Successful!"
},
"code": {
"title": "Connect your Drop client",
"description": "Use a code to connect your Drop client if you are unable to open a web browser on your device."
"description": "Use a code to connect your Drop client if you are unable to open a web browser on your device.",
"title": "Connect your Drop client"
},
"confirmPassword": "Confirm @:auth.password",
"displayName": "Display Name",
@ -93,7 +115,7 @@
"save": "Save",
"saved": "Saved",
"servers": "Servers",
"srLoading": "Loading...",
"srLoading": "Loading",
"tags": "Tags",
"today": "Today"
},
@ -137,6 +159,10 @@
"usernameTaken": "Username already taken."
},
"backHome": "{arrow} Back to home",
"externalUrl": {
"subtitle": "This message is only visible to admins.",
"title": "Accessing over different EXTERNAL_URL. Please check the docs."
},
"game": {
"banner": {
"description": "Drop failed to update the banner image: {0}",
@ -178,7 +204,7 @@
},
"source": {
"delete": {
"desc": "Drop couldn't add delete this source: {0}",
"desc": "Drop couldn't delete this source: {0}",
"title": "Failed to delete library source"
}
}
@ -237,7 +263,11 @@
"admin": {
"admin": "Admin",
"metadata": "Meta",
"settings": "Settings",
"settings": {
"title": "Settings",
"store": "Store",
"tokens": "API tokens"
},
"tasks": "Tasks",
"users": "Users"
},
@ -273,18 +303,18 @@
},
"gameLibrary": "Game Library",
"import": {
"bulkImportDescription": "When on, this page won't redirect you to the import task, so you can import multiple games in succession.",
"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",
"link": "Import {arrow}",
"loading": "Loading game results...",
"loading": "Loading game results",
"search": "Search",
"searchPlaceholder": "Fallout 4",
"selectDir": "Please select a directory...",
"selectDir": "Please select a directory",
"selectGame": "Select game to import",
"selectGamePlaceholder": "Please select a game...",
"selectGamePlaceholder": "Please select a game",
"selectGameSearch": "Select game",
"selectPlatform": "Please select a platform...",
"selectPlatform": "Please select a platform",
"version": {
"advancedOptions": "Advanced options",
"import": "Import version",
@ -292,7 +322,7 @@
"launchCmd": "Launch executable/command",
"launchDesc": "Executable to launch the game",
"launchPlaceholder": "game.exe",
"loadingVersion": "Loading version metadata...",
"loadingVersion": "Loading version metadata",
"noAdv": "No advanced options for this configuration.",
"noVersions": "No versions to import",
"platform": "Version platform",
@ -323,15 +353,25 @@
"description": "Companies organize games by who they were developed or published by.",
"editor": {
"action": "Add Game {plus}",
"descriptionPlaceholder": "{'<'}description{'>'}",
"developed": "Developed",
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
"libraryTitle": "Game Library",
"noDescription": "(no description)",
"published": "Published",
"uploadBanner": "Upload banner",
"uploadIcon": "Upload icon"
"uploadIcon": "Upload icon",
"websitePlaceholder": "{'<'}website{'>'}"
},
"modals": {
"createDescription": "Create a company to further organize your games.",
"createFieldDescription": "Company Description",
"createFieldDescriptionPlaceholder": "A small indie studio that...",
"createFieldName": "Company Name",
"createFieldNamePlaceholder": "My New Company...",
"createFieldWebsite": "Company Website",
"createFieldWebsitePlaceholder": "https://example.com/",
"createTitle": "Create a company",
"nameDescription": "Edit the company's name. Used to match to new game imports.",
"nameTitle": "Edit company name",
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
@ -341,8 +381,8 @@
},
"noCompanies": "No companies",
"noGames": "No games",
"search": "Search companies...",
"searchGames": "Search company games...",
"search": "Search companies",
"searchGames": "Search company games",
"title": "Companies"
},
"tags": {
@ -358,6 +398,8 @@
},
"metadataProvider": "Metadata provider",
"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}",
@ -367,12 +409,15 @@
"create": "Create source",
"createDesc": "Drop will use this source to access your game library, and make them available.",
"desc": "Configure your library sources, where Drop will look for new games and versions to import.",
"documentationLink": "Documentation {arrow}",
"edit": "Edit source",
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
"fsFlatTitle": "Compatibility",
"fsPath": "Path",
"fsPathDesc": "An absolute path to your game library.",
"fsPathPlaceholder": "/mnt/games",
"fsTitle": "Drop-style",
"link": "Sources {arrow}",
"nameDesc": "The name of your source, for reference.",
"namePlaceholder": "My New Source",
@ -407,7 +452,7 @@
"launcherOpen": "Open in Launcher",
"noGames": "No games in library",
"notFound": "Game not found",
"search": "Search library...",
"search": "Search library",
"subheader": "Organize your games into collections for easy access, and access all your games."
},
"lowest": "lowest",
@ -422,7 +467,7 @@
"preview": "Preview",
"shortDesc": "Short description",
"submit": "Submit",
"tagPlaceholder": "Add a tag...",
"tagPlaceholder": "Add a tag",
"titles": "Title",
"uploadCover": "Upload cover image"
},
@ -438,7 +483,7 @@
"none": "No articles",
"notFound": "Article not found",
"search": "Search articles",
"searchPlaceholder": "Search articles...",
"searchPlaceholder": "Search articles",
"subheader": "Stay up to date with the latest updates and announcements.",
"title": "Latest News"
},
@ -499,11 +544,13 @@
"images": "Game Images",
"lookAt": "Check it out",
"noDevelopers": "No developers",
"noGame": "no game",
"noFeatured": "NO FEATURED GAMES",
"noGame": "NO GAME",
"noImages": "No images",
"noPublishers": "No publishers.",
"noTags": "No tags",
"openAdminDashboard": "Open in Admin Dashboard",
"openFeatured": "Star games in Admin Library {arrow}",
"platform": "Platform | Platform | Platforms",
"publishers": "Publishers | Publisher | Publishers",
"rating": "Rating",
@ -543,7 +590,9 @@
"cleanupSessionsName": "Clean up sessions."
},
"viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly scheduled tasks"
"weeklyScheduledTitle": "Weekly scheduled tasks",
"progress": "{0}%",
"execute": "{arrow} Execute"
}
},
"title": "Drop",
@ -568,7 +617,6 @@
"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.",
@ -580,6 +628,7 @@
"srOpenOptions": "Open options",
"title": "Authentication"
},
"authLink": "Authentication {arrow}",
"authoptionsHeader": "Auth Options",
"delete": "Delete",
"deleteUser": "Delete user {0}",
@ -592,7 +641,7 @@
"createInvitation": "Create invitation",
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
"expires": "Expires: {expiry}",
"invitationTitle": "invitations",
"invitationTitle": "Invitations",
"invite3Days": "3 days",
"invite6Months": "6 months",
"inviteAdminSwitchDescription": "Create this user as an administrator",

View File

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

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

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

View File

@ -200,7 +200,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
icon: RectangleStackIcon,
},
{
label: $t("header.admin.settings"),
label: $t("header.admin.settings.title"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: Cog6ToothIcon,

View File

@ -138,6 +138,7 @@ export default defineNuxtConfig({
scheduledTasks: {
"0 * * * *": ["dailyTasks"],
"*/30 * * * *": ["downloadCleanup"],
},
storage: {

View File

@ -1,6 +1,6 @@
{
"name": "drop",
"version": "0.3.1",
"version": "0.3.3",
"private": true,
"type": "module",
"license": "AGPL-3.0-or-later",
@ -21,7 +21,7 @@
},
"dependencies": {
"@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "1.6.0",
"@drop-oss/droplet": "3.0.1",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3",
@ -53,7 +53,7 @@
"stream-mime-type": "^2.0.0",
"turndown": "^7.2.0",
"unstorage": "^1.15.0",
"vite-plugin-static-copy": "^3.0.0",
"vite-plugin-static-copy": "^3.1.2",
"vue": "latest",
"vue-router": "latest",
"vue3-carousel": "^0.16.0",

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

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

View File

@ -242,11 +242,40 @@
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
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"
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
>
<span class="text-sm font-display font-bold uppercase">{{
$t("library.admin.libraryHint")
}}</span>
<NuxtLink
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
href="https://docs.droposs.org/docs/library"
target="_blank"
>
<i18n-t
keypath="library.admin.libraryHintDocsLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</p>
</ul>
</div>
</template>
@ -256,7 +285,11 @@ import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import { InformationCircleIcon, StarIcon } from "@heroicons/vue/20/solid";
import {
ArrowTopRightOnSquareIcon,
InformationCircleIcon,
StarIcon,
} from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n();

View File

@ -64,8 +64,14 @@
>
{{ source.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.backend }}
<td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/>
{{ optionsMetadata[source.backend].title }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon
@ -189,11 +195,34 @@
<RadioGroupLabel
as="span"
class="font-semibold text-zinc-100"
>{{ source }}</RadioGroupLabel
>{{ metadata.title }}
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
source
}}</span></RadioGroupLabel
>
<RadioGroupDescription
as="span"
class="text-zinc-400 text-xs"
>
<RadioGroupDescription as="span" class="text-zinc-400">
{{ metadata.description }}
</RadioGroupDescription>
<NuxtLink
:href="metadata.docsLink"
:external="true"
target="_blank"
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.sources.documentationLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</span>
</span>
<span
@ -269,6 +298,7 @@
*/
import {
DropLogo,
SourceOptionsFilesystem,
SourceOptionsFlatFilesystem,
} from "#components";
@ -279,8 +309,11 @@ import {
RadioGroupLabel,
RadioGroupOption,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/20/solid";
import { CheckIcon, DocumentIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import {
XCircleIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/vue/20/solid";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { FetchError } from "ofetch";
import type { Component } from "vue";
import type { LibraryBackend } from "~/prisma/client/enums";
@ -324,17 +357,23 @@ const optionUIs: { [key in LibraryBackend]: Component } = {
};
const optionsMetadata: {
[key in LibraryBackend]: {
title: string;
description: string;
docsLink: string;
icon: Component;
};
} = {
Filesystem: {
title: t("library.admin.sources.fsTitle"),
description: t("library.admin.sources.fsDesc"),
icon: DocumentIcon,
docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
icon: DocumentIcon,
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
},
};
const optionsMetadataIter = Object.entries(optionsMetadata);

View File

@ -30,7 +30,7 @@
{{ company.mName }}
<button @click="() => editName()">
<PencilIcon
class="transition duration-200 opacity-0 group-hover/name:opacity-100 size-8"
class="transition duration-200 xl:opacity-0 group-hover/name:opacity-100 size-8"
/>
</button>
</h1>
@ -43,17 +43,20 @@
}}
<button @click="() => editShortDescription()">
<PencilIcon
class="transition duration-200 opacity-0 group-hover/description:opacity-100 size-5"
class="transition duration-200 xl:opacity-0 group-hover/description:opacity-100 size-5"
/>
</button>
</p>
<p
class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3"
>
{{ company.mWebsite }}
{{
company.mWebsite ||
$t("library.admin.metadata.companies.editor.websitePlaceholder")
}}
<button @click="() => editWebsite()">
<PencilIcon
class="transition duration-200 opacity-0 group-hover/website:opacity-100 size-4"
class="transition duration-200 xl:opacity-0 group-hover/website:opacity-100 size-4"
/>
</button>
</p>

View File

@ -10,20 +10,12 @@
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/library/sources"
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createCompanyOpen = true)"
>
<i18n-t
keypath="library.admin.sources.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
{{ $t("common.create") }}
</button>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
@ -105,6 +97,10 @@
{{ $t("library.admin.metadata.companies.noCompanies") }}
</p>
</ul>
<ModalCreateCompany
v-model="createCompanyOpen"
@created="(company) => createCompany(company)"
/>
</div>
</template>
@ -122,9 +118,12 @@ useHead({
title: t("library.admin.metadata.companies.title"),
});
const createCompanyOpen = ref(false);
const searchQuery = ref("");
const companies = ref(await $dropFetch("/api/v1/admin/company"));
const rawCompanies = await $dropFetch("/api/v1/admin/company");
const companies = ref(rawCompanies);
const filteredCompanies = computed(() =>
companies.value.filter((e: CompanyModel) => {
@ -147,4 +146,8 @@ async function deleteCompany(id: string) {
const index = companies.value.findIndex((e) => e.id === id);
companies.value.splice(index, 1);
}
function createCompany(company: (typeof companies.value)[number]) {
companies.value.push(company);
}
</script>

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

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

View File

@ -1,68 +1,55 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-zinc-100">
{{ $t("settings.admin.title") }}
</h1>
<p class="mt-2 text-base text-zinc-400">
{{ $t("settings.admin.description") }}
</p>
</div>
<form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="pb-4 border-b border-zinc-700">
<h2 class="text-xl font-semibold text-zinc-100">
{{ $t("settings.admin.store.title") }}
</h2>
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
</h3>
<ul class="flex gap-3">
<li class="inline-block">
<OptionWrapper
:active="showGamePanelTextDecoration"
@click="setShowTitleDescription(true)"
>
<div class="flex">
<GamePanel
:animate="false"
:game="game"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
<li class="inline-block">
<OptionWrapper
:active="!showGamePanelTextDecoration"
@click="setShowTitleDescription(false)"
>
<div class="flex">
<GamePanel
:game="game"
:show-title-description="false"
:animate="false"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
</ul>
</div>
<form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="py-6 border-y 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>
</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">

View File

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

View File

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

View File

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

View File

@ -85,9 +85,15 @@
v-else-if="!useModal"
class="bg-zinc-950/30 flex items-center justify-center"
>
<p class="uppercase text-sm font-display text-zinc-700 font-bold">
<!-- <p class="uppercase text-sm font-display text-zinc-700 font-bold">
{{ $t("setup.noPage") }}
</p>
</p> -->
<img
src="/wallpapers/signin.jpg"
class="inset-0 h-full w-full object-cover"
alt=""
preload
/>
</div>
</div>
<div>

View File

@ -59,13 +59,30 @@
</VueCarousel>
<div
v-else
class="w-full h-full flex items-center justify-center bg-zinc-950/50 px-6 py-32 sm:px-12 sm:py-40 lg:px-16"
class="w-full h-full flex flex-col items-center justify-center bg-zinc-950/50 px-6 py-32 sm:px-12 sm:py-40 lg:px-16 gap-4"
>
<h2
class="uppercase text-xl font-bold tracking-tight text-zinc-700 sm:text-3xl"
>
{{ $t("store.noGame") }}
{{ $t("store.noFeatured") }}
</h2>
<NuxtLink
v-if="user?.admin"
to="/admin/library"
type="button"
class="inline-flex 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 duration-200 hover:scale-105 active:scale-95"
>
<i18n-t
keypath="store.openFeatured"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</div>
<StoreView />
@ -73,8 +90,12 @@
</template>
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
const recent = await $dropFetch("/api/v1/store/featured");
const user = useUser();
const { t } = useI18n();
useHead({

View File

@ -0,0 +1,15 @@
/*
Warnings:
- The primary key for the `Task` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "Task" DROP CONSTRAINT "Task_pkey",
ADD CONSTRAINT "Task_pkey" PRIMARY KEY ("id", "started");
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "APIToken" ADD COLUMN "expiresAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -45,6 +45,8 @@ model APIToken {
acls String[]
expiresAt DateTime?
@@index([token])
}

View File

@ -1,5 +1,5 @@
model Task {
id String @id
id String
taskGroup String
name String
@ -12,4 +12,6 @@ model Task {
log String[]
acls String[]
@@id([id, started])
}

View File

@ -1 +1,2 @@
User-agent: *
Disallow: /

View File

@ -0,0 +1,47 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import * as jdenticon from "jdenticon";
import { ObjectTransactionalHandler } from "~/server/internal/objects/transactional";
import prisma from "~/server/internal/db/database";
import { MetadataSource } from "~/prisma/client/enums";
const CompanyCreate = type({
name: "string",
description: "string",
website: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:create"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, CompanyCreate);
const obj = new ObjectTransactionalHandler();
const [register, pull, _] = obj.new({}, ["internal:read"]);
const icon = jdenticon.toPng(body.name, 512);
const logoId = register(icon);
const banner = jdenticon.toPng(body.description, 1024);
const bannerId = register(banner);
const company = await prisma.company.create({
data: {
metadataSource: MetadataSource.Manual,
metadataId: crypto.randomUUID(),
metadataOriginalQuery: "",
mName: body.name,
mShortDescription: body.description,
mDescription: "",
mLogoObjectId: logoId,
mBannerObjectId: bannerId,
mWebsite: body.website,
},
});
await pull();
return company;
});

View File

@ -17,11 +17,8 @@ export default defineEventHandler(async (h3) => {
orderBy: {
versionIndex: "asc",
},
select: {
versionIndex: true,
versionName: true,
platform: true,
delta: true,
omit: {
dropletManifest: true,
},
},
tags: true,

View File

@ -18,30 +18,55 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>(
const body = await readDropValidatedBody(h3, UpdateVersionOrder);
const gameId = body.id;
// We expect an array of the version names for this game
const versions = body.versions;
const unsortedVersions = await prisma.gameVersion.findMany({
where: {
versionName: { in: body.versions },
},
select: {
versionName: true,
versionIndex: true,
delta: true,
platform: true,
},
});
const newVersions = await prisma.$transaction(
versions.map((versionName, versionIndex) =>
const versions = body.versions
.map((e) => unsortedVersions.find((v) => v.versionName === e))
.filter((e) => e !== undefined);
if (versions.length !== unsortedVersions.length)
throw createError({
statusCode: 500,
statusMessage: "Sorting versions yielded less results, somehow.",
});
// Validate the new order
const has: { [key: string]: boolean } = {};
for (const version of versions) {
if (version.delta && !has[version.platform])
throw createError({
statusCode: 400,
statusMessage: `"${version.versionName}" requires a base version to apply the delta to.`,
});
has[version.platform] = true;
}
await prisma.$transaction(
versions.map((version, versionIndex) =>
prisma.gameVersion.update({
where: {
gameId_versionName: {
gameId: gameId,
versionName: versionName,
versionName: version.versionName,
},
},
data: {
versionIndex: versionIndex,
},
select: {
versionIndex: true,
versionName: true,
platform: true,
delta: true,
},
}),
),
);
return newVersions;
return versions;
},
);

View File

@ -7,8 +7,9 @@ export default defineEventHandler(async (h3) => {
const unimportedGames = await libraryManager.fetchUnimportedGames();
const games = await libraryManager.fetchGamesWithStatus();
const libraries = await libraryManager.fetchLibraries();
// Fetch other library data here
return { unimportedGames, games };
return { unimportedGames, games, hasLibraries: libraries.length > 0 };
});

View File

@ -1,5 +1,6 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import type { TaskMessage } from "~/server/internal/tasks";
import taskHandler from "~/server/internal/tasks";
export default defineEventHandler(async (h3) => {
@ -13,7 +14,7 @@ export default defineEventHandler(async (h3) => {
});
const runningTasks = (await taskHandler.runningTasks()).map((e) => e.id);
const historicalTasks = await prisma.task.findMany({
const historicalTasks = (await prisma.task.findMany({
where: {
OR: [
{
@ -28,7 +29,7 @@ export default defineEventHandler(async (h3) => {
ended: "desc",
},
take: 10,
});
})) as Array<TaskMessage>;
const dailyTasks = await taskHandler.dailyTasks();
const weeklyTasks = await taskHandler.weeklyTasks();

View File

@ -0,0 +1,31 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import taskHandler from "~/server/internal/tasks";
import type { TaskGroup } from "~/server/internal/tasks/group";
import { taskGroups } from "~/server/internal/tasks/group";
const StartTask = type({
taskGroup: type("string"),
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["task:start"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, StartTask);
const taskGroup = body.taskGroup as TaskGroup;
if (!taskGroups[taskGroup])
throw createError({
statusCode: 400,
statusMessage: "Invalid task group.",
});
const task = await taskHandler.runTaskGroupByName(taskGroup);
if (!task)
throw createError({
statusCode: 500,
statusMessage: "Could not start task.",
});
return { id: task };
});

View File

@ -0,0 +1,23 @@
import { APITokenMode } from "~/prisma/client/enums";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
if (!allowed) throw createError({ statusCode: 403 });
const id = h3.context.params?.id;
if (!id)
throw createError({
statusCode: 400,
statusMessage: "No id in router params",
});
const deleted = await prisma.aPIToken.delete({
where: { id: id, mode: APITokenMode.System },
})!;
if (!deleted)
throw createError({ statusCode: 404, statusMessage: "Token not found" });
return;
});

View File

@ -0,0 +1,9 @@
import aclManager from "~/server/internal/acls";
import { systemACLDescriptions } from "~/server/internal/acls/descriptions";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
if (!allowed) throw createError({ statusCode: 403 });
return systemACLDescriptions;
});

View File

@ -0,0 +1,15 @@
import { APITokenMode } from "~/prisma/client/enums";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
if (!allowed) throw createError({ statusCode: 403 });
const tokens = await prisma.aPIToken.findMany({
where: { mode: APITokenMode.System },
omit: { token: true },
});
return tokens;
});

View File

@ -0,0 +1,38 @@
import { type } from "arktype";
import { APITokenMode } from "~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager, { systemACLs } from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const CreateToken = type({
name: "string",
acls: "string[] > 0",
expiry: "string.date.iso.parse?",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, CreateToken);
const invalidACLs = body.acls.filter(
(e) => systemACLs.findIndex((v) => e == v) == -1,
);
if (invalidACLs.length > 0)
throw createError({
statusCode: 400,
statusMessage: `Invalid ACLs: ${invalidACLs.join(", ")}`,
});
const token = await prisma.aPIToken.create({
data: {
mode: APITokenMode.System,
name: body.name,
acls: body.acls,
expiresAt: body.expiry ?? null,
},
});
return token;
});

View File

@ -5,5 +5,6 @@ export default defineEventHandler((_h3) => {
appName: "Drop",
version: systemConfig.getDropVersion(),
gitRef: `#${systemConfig.getGitRef()}`,
external: systemConfig.getExternalUrl(),
};
});

View File

@ -0,0 +1,6 @@
import aclManager from "~/server/internal/acls";
export default defineEventHandler(async (h3) => {
const acls = await aclManager.fetchAllACLs(h3);
return acls;
});

View File

@ -1,30 +1,22 @@
import { type } from "arktype";
import { APITokenMode } from "~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager, { userACLs } from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const CreateToken = type({
name: "string",
acls: "string[] > 0",
expiry: "string.date.iso.parse?",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 });
const body = await readBody(h3);
const name: string = body.name;
const acls: string[] = body.acls;
const body = await readDropValidatedBody(h3, CreateToken);
if (!name || typeof name !== "string")
throw createError({
statusCode: 400,
statusMessage: "Token name required",
});
if (!acls || !Array.isArray(acls))
throw createError({ statusCode: 400, statusMessage: "ACLs required" });
if (acls.length == 0)
throw createError({
statusCode: 400,
statusMessage: "Token requires more than zero ACLs",
});
const invalidACLs = acls.filter(
const invalidACLs = body.acls.filter(
(e) => userACLs.findIndex((v) => e == v) == -1,
);
if (invalidACLs.length > 0)
@ -36,9 +28,10 @@ export default defineEventHandler(async (h3) => {
const token = await prisma.aPIToken.create({
data: {
mode: APITokenMode.User,
name: name,
name: body.name,
userId: userId,
acls: acls,
acls: body.acls,
expiresAt: body.expiry ?? null,
},
});

View File

@ -0,0 +1,86 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import contextManager from "~/server/internal/downloads/coordinator";
import libraryManager from "~/server/internal/library";
import { logger } from "~/server/internal/logging";
const GetChunk = type({
context: "string",
files: type({
filename: "string",
chunkIndex: "number",
}).array(),
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, GetChunk);
const context = await contextManager.fetchContext(body.context);
if (!context)
throw createError({
statusCode: 400,
statusMessage: "Invalid download context.",
});
const streamFiles = [];
for (const file of body.files) {
const manifestFile = context.manifest[file.filename];
if (!manifestFile)
throw createError({
statusCode: 400,
statusMessage: `Unknown file: ${file.filename}`,
});
const start = manifestFile.lengths
.slice(0, file.chunkIndex)
.reduce((a, b) => a + b, 0);
const end = start + manifestFile.lengths[file.chunkIndex];
streamFiles.push({ filename: file.filename, start, end });
}
setHeader(
h3,
"Content-Lengths",
streamFiles.map((e) => e.end - e.start).join(","),
); // Non-standard header, but we're cool like that 😎
for (const file of streamFiles) {
const gameReadStream = await libraryManager.readFile(
context.libraryId,
context.libraryPath,
context.versionName,
file.filename,
{ start: file.start, end: file.end },
);
if (!gameReadStream)
throw createError({
statusCode: 500,
statusMessage: "Failed to create read stream",
});
let length = 0;
await gameReadStream.pipeTo(
new WritableStream({
write(chunk) {
h3.node.res.write(chunk);
length += chunk.length;
},
}),
);
if (length != file.end - file.start) {
logger.warn(
`failed to read enough from ${file.filename}. read ${length}, required: ${file.end - file.start}`,
);
throw createError({
statusCode: 500,
statusMessage: "Failed to read enough from stream.",
});
}
}
await h3.node.res.end();
return;
});

View File

@ -0,0 +1,22 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import contextManager from "~/server/internal/downloads/coordinator";
const CreateContext = type({
game: "string",
version: "string",
}).configure(throwingArktype);
export default defineClientEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, CreateContext);
const context = await contextManager.createContext(body.game, body.version);
if (!context)
throw createError({
statusCode: 400,
statusMessage: "Invalid game or version",
});
return { context };
});

View File

@ -19,7 +19,7 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"notifications:read": "Fetch this account's notifications.",
"notifications:mark": "Mark notifications as read for this account.",
"notifications:listen": "Connect to a websocket to recieve notifications.",
"notifications:listen": "Connect to a websocket to receive notifications.",
"notifications:delete": "Delete this account's notifications.",
"screenshots:new": "Create screenshots for this account",
@ -36,7 +36,7 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"library:remove": "Remove a game from your library.",
"clients:read": "Read the clients connected to this account",
"clients:revoke": "",
"clients:revoke": "Remove clients connected to this account",
"news:read": "Read the server's news articles.",

View File

@ -16,7 +16,7 @@ Server sends redirect to `drop://handshake/[id]/[token]`, where the token is an
## 3. Client requests certificates
Client makes request: `POST /api/v1/client/auth/handshake` with the token recieved in the previous step.
Client makes request: `POST /api/v1/client/auth/handshake` with the token received in the previous step.
The server uses it's CA to generate a public-private key pair, the CN of the client ID. It then sends that pair, plus the CA's public key, to the client, which stores it all.

View File

@ -1,9 +1,68 @@
/*
The download co-ordinator's job is to keep track of all the currently online clients.
import prisma from "../db/database";
import type { DropManifest } from "./manifest";
When a client signs on and registers itself as a peer
const TIMEOUT = 1000 * 60 * 60 * 1; // 1 hour
*/
class DownloadContextManager {
private contexts: Map<
string,
{
timeout: Date;
manifest: DropManifest;
versionName: string;
libraryId: string;
libraryPath: string;
}
> = new Map();
// eslint-disable-next-line @typescript-eslint/no-extraneous-class, @typescript-eslint/no-unused-vars
class DownloadCoordinator {}
async createContext(game: string, versionName: string) {
const version = await prisma.gameVersion.findUnique({
where: {
gameId_versionName: {
gameId: game,
versionName,
},
},
include: {
game: {
select: {
libraryId: true,
libraryPath: true,
},
},
},
});
if (!version) return undefined;
const contextId = crypto.randomUUID();
this.contexts.set(contextId, {
timeout: new Date(),
manifest: JSON.parse(version.dropletManifest as string) as DropManifest,
versionName,
libraryId: version.game.libraryId!,
libraryPath: version.game.libraryPath,
});
return contextId;
}
async fetchContext(contextId: string) {
const context = this.contexts.get(contextId);
if (!context) return undefined;
context.timeout = new Date();
this.contexts.set(contextId, context);
return context;
}
async cleanup() {
for (const key of this.contexts.keys()) {
const context = this.contexts.get(key)!;
if (context.timeout.getTime() < Date.now() - TIMEOUT) {
this.contexts.delete(key);
}
}
}
}
export const contextManager = new DownloadContextManager();
export default contextManager;

View File

@ -5,7 +5,7 @@ export type DropChunk = {
permissions: number;
ids: string[];
checksums: string[];
lengths: string[];
lengths: number[];
};
export type DropManifest = {

View File

@ -13,13 +13,24 @@ import { parsePlatform } from "../utils/parseplatform";
import notificationSystem from "../notifications";
import { GameNotFoundError, type LibraryProvider } from "./provider";
import { logger } from "../logging";
import type { GameModel } from "~/prisma/client/models";
import { createHash } from "node:crypto";
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return createHash("md5")
.update(`import:${libraryId}:${libraryPath}`)
.digest("hex");
}
export function createVersionImportTaskId(gameId: string, versionName: string) {
return createHash("md5")
.update(`import:${gameId}:${versionName}`)
.digest("hex");
}
class LibraryManager {
private libraries: Map<string, LibraryProvider<unknown>> = new Map();
private gameImportLocks: Map<string, Array<string>> = new Map(); // Library ID to Library Path
private versionImportLocks: Map<string, Array<string>> = new Map(); // Game ID to Version Name
addLibrary(library: LibraryProvider<unknown>) {
this.libraries.set(library.id(), library);
}
@ -37,24 +48,30 @@ class LibraryManager {
return libraryWithMetadata;
}
async fetchGamesByLibrary() {
const results: { [key: string]: { [key: string]: GameModel } } = {};
const games = await prisma.game.findMany({});
for (const game of games) {
const libraryId = game.libraryId!;
const libraryPath = game.libraryPath!;
results[libraryId] ??= {};
results[libraryId][libraryPath] = game;
}
return results;
}
async fetchUnimportedGames() {
const unimportedGames: { [key: string]: string[] } = {};
const instanceGames = await this.fetchGamesByLibrary();
for (const [id, library] of this.libraries.entries()) {
const games = await library.listGames();
const validGames = await prisma.game.findMany({
where: {
libraryId: id,
libraryPath: { in: games },
},
select: {
libraryPath: true,
},
});
const providerUnimportedGames = games.filter(
(e) =>
validGames.findIndex((v) => v.libraryPath == e) == -1 &&
!(this.gameImportLocks.get(id) ?? []).includes(e),
const providerGames = await library.listGames();
const providerUnimportedGames = providerGames.filter(
(libraryPath) =>
!instanceGames[id]?.[libraryPath] &&
!taskHandler.hasTask(createGameImportTaskId(id, libraryPath)),
);
unimportedGames[id] = providerUnimportedGames;
}
@ -84,7 +101,7 @@ class LibraryManager {
const unimportedVersions = versions.filter(
(e) =>
game.versions.findIndex((v) => v.versionName == e) == -1 &&
!(this.versionImportLocks.get(game.id) ?? []).includes(e),
!taskHandler.hasTask(createVersionImportTaskId(game.id, e)),
);
return unimportedVersions;
} catch (e) {
@ -99,7 +116,11 @@ class LibraryManager {
async fetchGamesWithStatus() {
const games = await prisma.game.findMany({
include: {
versions: true,
versions: {
select: {
versionName: true,
},
},
library: true,
},
orderBy: {
@ -150,6 +171,8 @@ class LibraryManager {
".sh",
// No extension is common for Linux binaries
"",
// AppImages
".appimage",
],
Windows: [".exe", ".bat"],
macOS: [
@ -168,7 +191,8 @@ class LibraryManager {
for (const filename of files) {
const basename = path.basename(filename);
const dotLocation = filename.lastIndexOf(".");
const ext = dotLocation == -1 ? "" : filename.slice(dotLocation);
const ext =
dotLocation == -1 ? "" : filename.slice(dotLocation).toLowerCase();
for (const [platform, checkExts] of Object.entries(fileExts)) {
for (const checkExt of checkExts) {
if (checkExt != ext) continue;
@ -206,70 +230,6 @@ class LibraryManager {
}
*/
/**
* Locks the game so you can't be imported
* @param libraryId
* @param libraryPath
*/
async lockGame(libraryId: string, libraryPath: string) {
let games = this.gameImportLocks.get(libraryId);
if (!games) this.gameImportLocks.set(libraryId, (games = []));
if (!games.includes(libraryPath)) games.push(libraryPath);
this.gameImportLocks.set(libraryId, games);
}
/**
* Unlocks the game, call once imported
* @param libraryId
* @param libraryPath
*/
async unlockGame(libraryId: string, libraryPath: string) {
let games = this.gameImportLocks.get(libraryId);
if (!games) this.gameImportLocks.set(libraryId, (games = []));
if (games.includes(libraryPath))
games.splice(
games.findIndex((e) => e === libraryPath),
1,
);
this.gameImportLocks.set(libraryId, games);
}
/**
* Locks a version so it can't be imported
* @param gameId
* @param versionName
*/
async lockVersion(gameId: string, versionName: string) {
let versions = this.versionImportLocks.get(gameId);
if (!versions) this.versionImportLocks.set(gameId, (versions = []));
if (!versions.includes(versionName)) versions.push(versionName);
this.versionImportLocks.set(gameId, versions);
}
/**
* Unlocks the version, call once imported
* @param libraryId
* @param libraryPath
*/
async unlockVersion(gameId: string, versionName: string) {
let versions = this.versionImportLocks.get(gameId);
if (!versions) this.versionImportLocks.set(gameId, (versions = []));
if (versions.includes(gameId))
versions.splice(
versions.findIndex((e) => e === versionName),
1,
);
this.versionImportLocks.set(gameId, versions);
}
async importVersion(
gameId: string,
versionName: string,
@ -286,7 +246,7 @@ class LibraryManager {
umuId: string;
},
) {
const taskId = `import:${gameId}:${versionName}`;
const taskId = createVersionImportTaskId(gameId, versionName);
const platform = parsePlatform(metadata.platform);
if (!platform) return undefined;
@ -300,8 +260,6 @@ class LibraryManager {
const library = this.libraries.get(game.libraryId);
if (!library) return undefined;
await this.lockVersion(gameId, versionName);
taskHandler.create({
id: taskId,
taskGroup: "import:game",
@ -378,9 +336,6 @@ class LibraryManager {
progress(100);
},
async finally() {
await libraryManager.unlockVersion(gameId, versionName);
},
});
return taskId;
@ -394,7 +349,7 @@ class LibraryManager {
) {
const library = this.libraries.get(libraryId);
if (!library) return undefined;
return library.peekFile(game, version, filename);
return await library.peekFile(game, version, filename);
}
async readFile(
@ -406,7 +361,7 @@ class LibraryManager {
) {
const library = this.libraries.get(libraryId);
if (!library) return undefined;
return library.readFile(game, version, filename, options);
return await library.readFile(game, version, filename, options);
}
}

View File

@ -7,12 +7,14 @@ import {
import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs";
import path from "path";
import droplet from "@drop-oss/droplet";
import droplet, { DropletHandler } from "@drop-oss/droplet";
export const FilesystemProviderConfig = type({
baseDir: "string",
});
export const DROPLET_HANDLER = new DropletHandler();
export class FilesystemProvider
implements LibraryProvider<typeof FilesystemProviderConfig.infer>
{
@ -57,7 +59,7 @@ export class FilesystemProvider
const versionDirs = fs.readdirSync(gameDir);
const validVersionDirs = versionDirs.filter((e) => {
const fullDir = path.join(this.config.baseDir, game, e);
return droplet.hasBackendForPath(fullDir);
return DROPLET_HANDLER.hasBackendForPath(fullDir);
});
return validVersionDirs;
}
@ -65,7 +67,7 @@ export class FilesystemProvider
async versionReaddir(game: string, version: string): Promise<string[]> {
const versionDir = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
return droplet.listFiles(versionDir);
return DROPLET_HANDLER.listFiles(versionDir);
}
async generateDropletManifest(
@ -77,10 +79,16 @@ export class FilesystemProvider
const versionDir = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
const manifest = await new Promise<string>((r, j) =>
droplet.generateManifest(versionDir, progress, log, (err, result) => {
if (err) return j(err);
r(result);
}),
droplet.generateManifest(
DROPLET_HANDLER,
versionDir,
progress,
log,
(err, result) => {
if (err) return j(err);
r(result);
},
),
);
return manifest;
}
@ -88,7 +96,7 @@ export class FilesystemProvider
async peekFile(game: string, version: string, filename: string) {
const filepath = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(filepath)) return undefined;
const stat = droplet.peekFile(filepath, filename);
const stat = DROPLET_HANDLER.peekFile(filepath, filename);
return { size: Number(stat) };
}
@ -100,13 +108,17 @@ export class FilesystemProvider
) {
const filepath = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(filepath)) return undefined;
const stream = droplet.readFile(
filepath,
filename,
options?.start ? BigInt(options.start) : undefined,
options?.end ? BigInt(options.end) : undefined,
);
if (!stream) return undefined;
let stream;
while (!(stream instanceof ReadableStream)) {
const v = DROPLET_HANDLER.readFile(
filepath,
filename,
options?.start ? BigInt(options.start) : undefined,
options?.end ? BigInt(options.end) : undefined,
);
if (!v) return undefined;
stream = v.getStream() as ReadableStream<unknown>;
}
return stream;
}

View File

@ -5,6 +5,7 @@ import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs";
import path from "path";
import droplet from "@drop-oss/droplet";
import { DROPLET_HANDLER } from "./filesystem";
export const FlatFilesystemProviderConfig = type({
baseDir: "string",
@ -46,7 +47,7 @@ export class FlatFilesystemProvider
const versionDirs = fs.readdirSync(this.config.baseDir);
const validVersionDirs = versionDirs.filter((e) => {
const fullDir = path.join(this.config.baseDir, e);
return droplet.hasBackendForPath(fullDir);
return DROPLET_HANDLER.hasBackendForPath(fullDir);
});
return validVersionDirs;
}
@ -63,7 +64,7 @@ export class FlatFilesystemProvider
async versionReaddir(game: string, _version: string) {
const versionDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
return droplet.listFiles(versionDir);
return DROPLET_HANDLER.listFiles(versionDir);
}
async generateDropletManifest(
@ -75,17 +76,23 @@ export class FlatFilesystemProvider
const versionDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
const manifest = await new Promise<string>((r, j) =>
droplet.generateManifest(versionDir, progress, log, (err, result) => {
if (err) return j(err);
r(result);
}),
droplet.generateManifest(
DROPLET_HANDLER,
versionDir,
progress,
log,
(err, result) => {
if (err) return j(err);
r(result);
},
),
);
return manifest;
}
async peekFile(game: string, _version: string, filename: string) {
const filepath = path.join(this.config.baseDir, game);
if (!fs.existsSync(filepath)) return undefined;
const stat = droplet.peekFile(filepath, filename);
const stat = DROPLET_HANDLER.peekFile(filepath, filename);
return { size: Number(stat) };
}
async readFile(
@ -96,7 +103,7 @@ export class FlatFilesystemProvider
) {
const filepath = path.join(this.config.baseDir, game);
if (!fs.existsSync(filepath)) return undefined;
const stream = droplet.readFile(
const stream = DROPLET_HANDLER.readFile(
filepath,
filename,
options?.start ? BigInt(options.start) : undefined,
@ -104,6 +111,6 @@ export class FlatFilesystemProvider
);
if (!stream) return undefined;
return stream;
return stream.getStream();
}
}

View File

@ -18,7 +18,7 @@ import taskHandler, { wrapTaskContext } from "../tasks";
import { randomUUID } from "crypto";
import { fuzzy } from "fast-fuzzy";
import { logger } from "~/server/internal/logging";
import libraryManager from "../library";
import { createGameImportTaskId } from "../library";
import type { GameTagModel } from "~/prisma/client/models";
export class MissingMetadataProviderConfig extends Error {
@ -185,11 +185,9 @@ export class MetadataHandler {
});
if (existing) return undefined;
await libraryManager.lockGame(libraryId, libraryPath);
const gameId = randomUUID();
const taskId = `import:${gameId}`;
const taskId = createGameImportTaskId(libraryId, libraryPath);
await taskHandler.create({
name: `Import game "${result.name}" (${libraryPath})`,
id: taskId,
@ -280,9 +278,6 @@ export class MetadataHandler {
logger.info(`Finished game import.`);
progress(100);
},
async finally() {
await libraryManager.unlockGame(libraryId, libraryPath);
},
});
return taskId;

View File

@ -1,5 +1,5 @@
/*
The notification system handles the recieving, creation and sending of notifications in Drop
The notification system handles the receiving, creation and sending of notifications in Drop
Design goals:
1. Nonce-based notifications; notifications should only be created once

View File

@ -195,7 +195,7 @@ export class ObjectHandler {
* @returns
* @description If we need to fetch a remote resource, it doesn't make sense
* to immediately fetch the object, *then* check permissions.
* Instead the caller can pass a simple anonymous funciton, like
* Instead the caller can pass a simple anonymous function, like
* () => $dropFetch('/my-image');
* And if we actually have permission to write, it fetches it then.
*/

View File

@ -14,6 +14,9 @@ export const taskGroups = {
"import:game": {
concurrency: true,
},
debug: {
concurrency: true,
},
} as const;
export type TaskGroup = keyof typeof taskGroups;

View File

@ -41,7 +41,7 @@ type TaskPoolEntry = FinishedTask & {
* easily without re-inventing the wheel every time.
*/
class TaskHandler {
// registry of schedualed tasks to be created
// registry of scheduled tasks to be created
private taskCreators: Map<TaskGroup, () => Task> = new Map();
// list of all currently running tasks
@ -53,6 +53,7 @@ class TaskHandler {
"cleanup:invitations",
"cleanup:sessions",
"check:update",
"debug",
];
private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"];
@ -62,6 +63,7 @@ class TaskHandler {
this.saveScheduledTask(cleanupSessions);
this.saveScheduledTask(checkUpdate);
this.saveScheduledTask(cleanupObjects);
//this.saveScheduledTask(debug);
}
/**
@ -73,6 +75,8 @@ class TaskHandler {
}
async create(task: Task) {
if (this.hasTask(task.id)) throw new Error("Task with ID already exists.");
let updateCollectTimeout: NodeJS.Timeout | undefined;
let updateCollectResolves: Array<(value: unknown) => void> = [];
let logOffset: number = 0;
@ -160,6 +164,13 @@ class TaskHandler {
// You can configure timestamp, level, etc. here
timestamp: pino.stdTimeFunctions.isoTime,
base: null, // Remove pid/hostname if not needed
formatters: {
level(label) {
return {
level: label,
};
},
},
},
logStream,
);
@ -206,8 +217,6 @@ class TaskHandler {
};
}
if (task.finally) await task.finally();
taskEntry.endTime = new Date().toISOString();
await updateAllClients();
@ -247,7 +256,10 @@ class TaskHandler {
) {
const task =
this.taskPool.get(taskId) ??
(await prisma.task.findUnique({ where: { id: taskId } }));
(await prisma.task.findFirst({
where: { id: taskId },
orderBy: { started: "desc" },
}));
if (!task) {
peer.send(
`error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`,
@ -324,6 +336,10 @@ class TaskHandler {
.toArray();
}
hasTask(id: string) {
return this.taskPool.has(id);
}
dailyTasks() {
return this.dailyScheduledTasks;
}
@ -332,13 +348,15 @@ class TaskHandler {
return this.weeklyScheduledTasks;
}
runTaskGroupByName(name: TaskGroup) {
const task = this.taskCreators.get(name);
if (!task) {
async runTaskGroupByName(name: TaskGroup) {
const taskConstructor = this.taskCreators.get(name);
if (!taskConstructor) {
logger.warn(`No task found for group ${name}`);
return;
}
this.create(task());
const task = taskConstructor();
await this.create(task);
return task.id;
}
/**
@ -429,7 +447,6 @@ export interface Task {
taskGroup: TaskGroup;
name: string;
run: (context: TaskRunContext) => Promise<void>;
finally?: () => Promise<void> | void;
acls: GlobalACL[];
}
@ -438,7 +455,7 @@ export type TaskMessage = {
name: string;
success: boolean;
progress: number;
error: undefined | { title: string; description: string };
error: null | undefined | { title: string; description: string };
log: string[];
reset?: boolean;
};
@ -464,6 +481,7 @@ interface DropTask {
export const TaskLog = type({
timestamp: "string",
message: "string",
level: "string",
});
// /**
@ -493,8 +511,6 @@ export const TaskLog = type({
// }
export function defineDropTask(buildTask: BuildTask): DropTask {
// TODO: only let one task with the same taskGroup run at the same time if specified
return {
taskGroup: buildTask.taskGroup,
build: () => ({

View File

@ -0,0 +1,18 @@
import { defineDropTask } from "..";
export default defineDropTask({
buildId: () => `debug:${new Date().toISOString()}`,
name: "Debug Task",
acls: ["system:maintenance:read"],
taskGroup: "debug",
async run({ progress, logger }) {
const amount = 1000;
for (let i = 0; i < amount; i++) {
progress((i / amount) * 100);
logger.info(`dajksdkajd ${i}`);
logger.warn("warning");
logger.error("error\nmultiline and stuff\nwoah more lines");
await new Promise((r) => setTimeout(r, 1500));
}
},
});

View File

@ -0,0 +1,3 @@
export default defineEventHandler(async () => {
// await new Promise((r) => setTimeout(r, 700));
});

View File

@ -3,7 +3,7 @@ import prisma from "~/server/internal/db/database";
export default defineNitroPlugin(async (_nitro) => {
// Ensure system user exists
// The system user owns any user-based code
// that we want to re-use for the app
// that we want to reuse for the app
// e.g. notifications
await prisma.user.upsert({
where: {

View File

@ -0,0 +1,11 @@
import contextManager from "../internal/downloads/coordinator";
export default defineTask({
meta: {
name: "downloadCleanup",
},
async run() {
await contextManager.cleanup();
return { result: true };
},
});

View File

@ -1,10 +1,31 @@
import type { TaskLog } from "~/server/internal/tasks";
export function parseTaskLog(logStr: string): typeof TaskLog.infer {
const labelNumberMap = {
100: "silent",
60: "fatal",
50: "error",
40: "warn",
30: "info",
20: "debug",
10: "trace",
0: "off",
};
export function parseTaskLog(
logStr?: string | undefined,
): typeof TaskLog.infer {
if (!logStr) return { message: "", timestamp: "", level: "" };
const log = JSON.parse(logStr);
if (typeof log.level === "number") {
log.level = labelNumberMap[
log.level as keyof typeof labelNumberMap
] as string;
}
return {
message: log.msg,
timestamp: log.time,
level: log.level,
};
}

122
yarn.lock
View File

@ -342,71 +342,71 @@
jsonfile "^5.0.0"
universalify "^0.1.2"
"@drop-oss/droplet-darwin-arm64@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-1.6.0.tgz#9697e38c46b02192e8e180b7deaaa20a389a9b0d"
integrity sha512-EqTx+Mk5SHP17n19r5coacUDd7lklT4opJ2keNQyGsQjrcf+9FeCX1O5Y+PGIjpQK6UkAVdnBqM+jR7NeFmkAQ==
"@drop-oss/droplet-darwin-arm64@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-3.0.1.tgz#37acbeaedcf28623c18b545aa2ed9205533a7128"
integrity sha512-LXe8vsXUBL96boI78H6oXpSaPVwF4cCwJ5l/QVtsOWMebNo6gk9wICDZ+5IoR/Ol32t1a1lk+DjbD1zeGenPxg==
"@drop-oss/droplet-darwin-universal@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-1.6.0.tgz#2f780416052ac7d1752b0a7828dc3ef9d1789c92"
integrity sha512-TxVpoVDI9aGuBCHA8HktbrIkS/C1gu5laM5+ZbIZkXnIUpTicJIbHRyneXJ4MLnW703gUbW8LTISgm7xKwZJsg==
"@drop-oss/droplet-darwin-universal@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-3.0.1.tgz#8e90214758ae03e2e37501a107e5a8acaeec6d32"
integrity sha512-Mf2gjC24u6s8djV/3slZvwdr4+h0qBu2OYXBUSDfR4H/VJwV5TstnWVKF+U8d1hjmHE9eLO8elbGNnpQmSoTOQ==
"@drop-oss/droplet-darwin-x64@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-1.6.0.tgz#5d6a3c596eca706e40b35cdf49ada65e59c51b8d"
integrity sha512-V/1xh4s16AmesDOEHiQ4vj9XQq6AWmXRY5RQf4RKBQqkxsHzmQoa37CTLK25Wf9OUoiJFGpnjViqKOFG4y5Q+g==
"@drop-oss/droplet-darwin-x64@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-3.0.1.tgz#602cf4e7cb1ceda4ef95673f61542025b9215e9a"
integrity sha512-4IIDl/E+hzZ2Vt9m4FMPlZEXwj1EwE6qXyUidACK6TTFqpjLpsEHKuhv1FOxGyJ8qkvagtyPCc+cs1TxoZD6FA==
"@drop-oss/droplet-linux-arm64-gnu@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-1.6.0.tgz#265d5e7854c4c61081b8fd74b3e8305ea2c7b5ac"
integrity sha512-WjaRl9VW0qE+YkOCaYuNIXzyBbps2lopbpeXELZ9/f/1jBfzfmIe4m6C2hMy4NWUcWnrBbiVTEjnq2cHj/TaBA==
"@drop-oss/droplet-linux-arm64-gnu@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-3.0.1.tgz#a49d1998229fafbd42ac4b8fc5f67754ab1ac49c"
integrity sha512-klGvlLf1xSMT3iYsIAaBbmbir1ZJWtcVyOMUlsfc1lkJ8mgyB+PrW4BsnYj7Pp4G34n7WsOChjC8TdJDBBuBWg==
"@drop-oss/droplet-linux-arm64-musl@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-1.6.0.tgz#7126e194e5ef9018d61ef7dd0cc3af80734e00e2"
integrity sha512-B8KoBYk0YVUZIL+etCcOc99NuoBcTm6KDOIQkN9SHWC4YLRu8um3w8DHzv4VV3arUnEGjyDHuraaOSONfP6NqA==
"@drop-oss/droplet-linux-arm64-musl@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-3.0.1.tgz#3e0ffee4f0aba051c244236aecdb5c1221c1b999"
integrity sha512-oOjvGETlrJGC1RlNhUoVS9N89Rn/0DqBauVz3BBFjJTKSd5jU3/gLzwgmfkKDGVEU5lyGPAn2WQroiESEG9wdA==
"@drop-oss/droplet-linux-riscv64-gnu@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-1.6.0.tgz#40d060eafaca08b47a468950d7dc5ec4f1fb2a5a"
integrity sha512-nbNr/38EX8Mjj20+paohlOD35apmaNKZan4OO97KOwvq5oZ/pXbkjOGC0zkpsizyxbwKx7Jl4Se7teRVPWWVWw==
"@drop-oss/droplet-linux-riscv64-gnu@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-3.0.1.tgz#2208f1a038d54ced68d1537c4daa964b115d4e5c"
integrity sha512-Zf3gUsWq9Hqb275MOi7PJDhmJz7Qa/Y1XMen880bxPaOeDFqFOoKUxUr2/qv1MYp6tT3zO27NprGsHirYWqsyA==
"@drop-oss/droplet-linux-x64-gnu@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-1.6.0.tgz#c3a8408644194e59ac2110229e9a99885b3bc533"
integrity sha512-n/zA1ftqGey5yQK/1HiCok3MaLA4stVTzQEuRUzyq8BQ1BC6TmKCgdFnI4Q3tuGm3/Mz2CCbfbHY4bYwND9qOQ==
"@drop-oss/droplet-linux-x64-gnu@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-3.0.1.tgz#ffe2e39f978d32858a003f0c28614a8a4d1bdeef"
integrity sha512-sskblycJdtNJVnRHjPHhwHkQUfQNaDIWDzXOzEaBPOcDKqYA7od7VMDAseqBkrKDn7l8bBUtRXFAipdsO8hffw==
"@drop-oss/droplet-linux-x64-musl@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-1.6.0.tgz#206b5c85b02b7fdf53bc5f0cdf68a9d9a7d501cd"
integrity sha512-egZWqKK1+vHoVKNuMle2Kn8WbbJ7Y9WJScUNXjF8hdUDNo9eHwJT/DfnA+BhvFQuJXkU58vwv6MqZ5VLdOsGiA==
"@drop-oss/droplet-linux-x64-musl@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-3.0.1.tgz#4bd501eeeddfdaf3c49e6508cc1798419b0c78cc"
integrity sha512-lh+1M6UAf5+ET1/ZEFRsB3shFHjkT/9Ql9akr/vyUue91TWPmP71meqVkCugWDhP6lxBt56jg2VVrJfmPAsK6w==
"@drop-oss/droplet-win32-arm64-msvc@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-1.6.0.tgz#fbb0387536f5b2a88f03877d730f7f863646ce08"
integrity sha512-AwGYHae8ZmQV2QGp+3B0DhsBdYynrZ4AS1xNc+U1tXt5CiMp9wLLM/4a+WySYHX7XrEo8pKmRRa0I8QdAdxk5A==
"@drop-oss/droplet-win32-arm64-msvc@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-3.0.1.tgz#9308c75d22773fbb78bba0286c101870b3eaf5f6"
integrity sha512-caQDPoDNJyyJXUEijw+hGTy0wmCrW5efTqBwnvMcQ282EOilg1d5WeJ31pfEcuLYF4MK1t9uaLcG6jZ9YLtzEQ==
"@drop-oss/droplet-win32-x64-msvc@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-1.6.0.tgz#600058775641b4c5c051291e5a13135aa1ae28bb"
integrity sha512-Viz+J87rF7I++nLpPBvdhsjUQAHivA6wSHrBXa+4MwIymUvlQXcvNReFqzObRH4eiuiY4e3s3t9X7+paqd847Q==
"@drop-oss/droplet-win32-x64-msvc@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-3.0.1.tgz#3f50f1328bd7aafd8dfe7edd0413f13217cbc9ce"
integrity sha512-bp8KwewF/T3JkVeJWkg86U3b0cGQD9i8k92x6HYPtnF5nLPAb2UIUEJgmYYFNPFe36RECBV7PIIG0ujdT1ELQw==
"@drop-oss/droplet@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-1.6.0.tgz#b6aa382dc5df494c4233a2bd8f19721878edad71"
integrity sha512-nTZvLo+GFLlpxgFlObP4zitVctz02bRD3ZSVDiMv7jXxYK0V/GktITJFcKK0J87ZRxneoFHYbLs1lH3MFYoSIw==
"@drop-oss/droplet@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-3.0.1.tgz#e7f6772aa1f94010d41086fc8a1f396a5d392184"
integrity sha512-YhtgpwNqEHO8R03yf9Xb5LXuaLWkQvY+2lxOD1PwzpGI5V9PKlDE+x1IJBmdBF5bDPDGk9MxQidGtnYQuAEBEA==
optionalDependencies:
"@drop-oss/droplet-darwin-arm64" "1.6.0"
"@drop-oss/droplet-darwin-universal" "1.6.0"
"@drop-oss/droplet-darwin-x64" "1.6.0"
"@drop-oss/droplet-linux-arm64-gnu" "1.6.0"
"@drop-oss/droplet-linux-arm64-musl" "1.6.0"
"@drop-oss/droplet-linux-riscv64-gnu" "1.6.0"
"@drop-oss/droplet-linux-x64-gnu" "1.6.0"
"@drop-oss/droplet-linux-x64-musl" "1.6.0"
"@drop-oss/droplet-win32-arm64-msvc" "1.6.0"
"@drop-oss/droplet-win32-x64-msvc" "1.6.0"
"@drop-oss/droplet-darwin-arm64" "3.0.1"
"@drop-oss/droplet-darwin-universal" "3.0.1"
"@drop-oss/droplet-darwin-x64" "3.0.1"
"@drop-oss/droplet-linux-arm64-gnu" "3.0.1"
"@drop-oss/droplet-linux-arm64-musl" "3.0.1"
"@drop-oss/droplet-linux-riscv64-gnu" "3.0.1"
"@drop-oss/droplet-linux-x64-gnu" "3.0.1"
"@drop-oss/droplet-linux-x64-musl" "3.0.1"
"@drop-oss/droplet-win32-arm64-msvc" "3.0.1"
"@drop-oss/droplet-win32-x64-msvc" "3.0.1"
"@emnapi/core@^1.4.3":
version "1.4.5"
@ -8591,9 +8591,9 @@ tmp-promise@^3.0.2:
tmp "^0.2.0"
tmp@^0.2.0:
version "0.2.3"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae"
integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==
version "0.2.4"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.4.tgz#c6db987a2ccc97f812f17137b36af2b6521b0d13"
integrity sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==
to-regex-range@^5.0.1:
version "5.0.1"
@ -9086,10 +9086,10 @@ vite-plugin-inspect@^11.3.0:
unplugin-utils "^0.2.4"
vite-dev-rpc "^1.1.0"
vite-plugin-static-copy@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.1.tgz#25d6f52c9a760d2d2e84d0803a37e3310aed644a"
integrity sha512-oR53SkL5cX4KT1t18E/xU50vJDo0N8oaHza4EMk0Fm+2/u6nQivxavOfrDk3udWj+dizRizB/QnBvJOOQrTTAQ==
vite-plugin-static-copy@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.2.tgz#5d5e6ce965e5da6a326d47a5feb5033d52db43ca"
integrity sha512-aVmYOzptLVOI2b1jL+cmkF7O6uhRv1u5fvOkQgbohWZp2CbR22kn9ZqkCUIt9umKF7UhdbsEpshn1rf4720QFg==
dependencies:
chokidar "^3.6.0"
fs-extra "^11.3.0"