19 Commits

Author SHA1 Message Date
fa9620eac1 Use 7zip for archive backend (#264)
* feat: use 7zip for archive backend

* fix: lint
2025-10-13 13:02:27 +11:00
a201b62c04 chore(deps): bump axios from 1.11.0 to 1.12.0 (#246)
Bumps [axios](https://github.com/axios/axios) from 1.11.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.11.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  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-10-13 11:36:59 +11:00
9bf164ab77 chore(deps): bump tar-fs from 2.1.3 to 2.1.4 (#256)
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 2.1.3 to 2.1.4.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.3...v2.1.4)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 2.1.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-10-13 11:36:31 +11:00
97c6f3490c Add store sort options (#238) (#261)
This commit adds the option
to sort store items by name,
and to choose the sort order.

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-10-13 11:20:48 +11:00
f5cb856d3d Carousel UI improvements (#258)
* make carousel pagination clickable

* make carousel in game pages wrap around

* make items in store fit the row when the filter menu is visible

---------

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-10-13 11:18:52 +11:00
67de1f6c02 Add Steam metadata provider (#232) (#250)
* feat(metadata): add Steam metadata provider (#232)

* style(steam): remove emojis from log messages
2025-09-21 10:43:35 +10:00
1002265000 Update CONTRIBUTING.md 2025-09-10 10:40:21 +10:00
37a2dff0dd chore(deps): bump vite from 6.3.5 to 6.3.6 (#245)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.3.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-10 10:39:11 +10:00
799cd6c394 Translations update from Weblate (#195)
* Translated using Weblate (German)

Currently translated at 66.5% (314 of 472 strings)

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

* Translated using Weblate (French)

Currently translated at 93.1% (465 of 499 strings)

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

* Translated using Weblate (Russian)

Currently translated at 16.0% (80 of 499 strings)

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

* Translated using Weblate (German)

Currently translated at 62.9% (314 of 499 strings)

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

* Translated using Weblate (German)

Currently translated at 62.9% (314 of 499 strings)

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

* Translated using Weblate (German)

Currently translated at 62.9% (314 of 499 strings)

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

* Translated using Weblate (German)

Currently translated at 81.7% (408 of 499 strings)

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

* Translated using Weblate (German)

Currently translated at 81.7% (408 of 499 strings)

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

* Translated using Weblate (German)

Currently translated at 81.7% (408 of 499 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (499 of 499 strings)

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

---------

Co-authored-by: Niklas Eifler <droposs@eiflerstrom.de>
Co-authored-by: pVDWNwffCRw2B2inHGs# <farmouss@gmail.com>
Co-authored-by: D3 <sl4yerenter@protonmail.com>
Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org>
Co-authored-by: Kuschiniko <nico.kusch@outlook.de>
Co-authored-by: Hicks <hicksgaming99+weblate@gmail.com>
2025-09-10 10:38:16 +10:00
2a005a2222 chore(deps): bump devalue from 5.1.1 to 5.3.2 (#219)
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.1.1 to 5.3.2.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.1.1...v5.3.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-07 17:31:24 +10:00
3942d5c442 chore(deps): bump tmp from 0.2.3 to 0.2.5 (#228)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.3 to 0.2.5.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.3...v0.2.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-07 17:31:10 +10:00
a520d52ad3 Preallocate download streams (#229)
* feat: pre-allocate streams for high-latency downloads

* fix: update drop-base

* fix: remove debug latency

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

* feat: fully switch to pnpm

* ci: prettier ignore pnpm lock

* chore: dedupe lockfile

* chore: update pnpm
2025-08-31 09:53:55 +10:00
bfeacbbdfe Delete yarn.lock 2025-08-31 09:45:04 +10:00
afce9f159a Update drop-base commit 2025-08-26 09:19:22 +10:00
fd828d5b50 Update droplet & other small features, and bump version for v0.3.3 (#212)
* fix: bump version and fix context timeout issues

* fix: bump droplet

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

* feat: api token creation and management

* fix: lint

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 13:49:31 +10:00
48 changed files with 14874 additions and 9979 deletions

View File

@ -21,17 +21,20 @@ jobs:
with:
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "yarn"
cache: "pnpm"
- name: Install dependencies
run: yarn install --immutable --network-timeout 1000000
run: pnpm install
- name: Typecheck
run: yarn typecheck
run: pnpm run typecheck
lint:
name: Lint
@ -42,14 +45,17 @@ jobs:
with:
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "yarn"
cache: "pnpm"
- name: Install dependencies
run: yarn install --immutable --network-timeout 1000000
run: pnpm install
- name: Lint
run: yarn lint
run: pnpm run lint

View File

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

View File

@ -1,271 +1,3 @@
# CONTRIBUTING GUIDELINES
# Contributing
Drop is a community-driven project. Contribution is welcome, encouraged, and appreciated.
It is also essential for the development of the project.
First, please take a moment to review our [code of conduct](CODE_OF_CONDUCT.md).
These guidelines are an attempt at better addressing pending
issues and pull requests. Please read them closely.
Foremost, be so kind as to [search](#use-the-search-luke). This ensures any contribution
you would make is not already covered.
<!-- TOC updateonsave:true depthfrom:2 -->
- [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)
<!-- /TOC -->
## Reporting Issues
### You have a problem
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your problem.
If you find one, comment on it, so we know more people are experiencing it.
<!--
TODO: Add Troubleshooting
If not, look at the [Troubleshooting](https://github.com/Drop-OSS/docs/Troubleshooting)
page for instructions on how to gather data to better debug your problem.
-->
If you cannot find an existing issue, you can go ahead and create an issue with as much
detail as you can provide.
It should include the data gathered as indicated above, along with the following:
1. How to reproduce the problem
2. What the correct behavior should be
3. What the actual behavior is
Please copy to anyone relevant (e.g. plugin maintainers) by mentioning their GitHub handle
(starting with `@`) in your message.
We will do our very best to help you.
### You have a suggestion
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your suggestion.
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
You should be familiar with the basics of
[contributing on GitHub](https://help.github.com/articles/using-pull-requests)
<!--and have a fork
[properly set up](https://github.com/drop/docs/Contribution-Technical-Practices).
-->
You MUST always create PRs with _a dedicated branch_ based on the latest upstream tree.
If you create your own PR, please make sure you do it right. Also be so kind as to reference
any issue that would be solved in the PR description body,
[for instance](https://help.github.com/articles/closing-issues-via-commit-messages/)
_"Fixes #XXXX"_ for issue number XXXX.
### You have a solution
Please be so kind as to [search](#use-the-search-luke) for any open issue already covering
your [problem](#you-have-a-problem), and any pending/merged/rejected PR covering your solution.
If the solution is already reported, try it out and +1 the pull request if the
solution works ok. On the other hand, if you think your solution is better, post
it with reference to the other one so we can have both solutions to compare.
If not, then go ahead and submit a PR. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
### You have an addition
We are absolutely accepting more contributions or features to drop, but please, make sure
that it is reasonable. Contributions that only cover a very small niche are likely to not
be added.
Please be so kind as to [search](#use-the-search-luke) for any pending, merged or rejected Pull Requests
covering or related to what you want to add.
If you find one, try it out and work with the author on a common solution.
If not, then go ahead and submit a PR. Please copy to anyone relevant (e.g. plugin
maintainers) by mentioning their GitHub handle (starting with `@`) in your message.
For any extensive change, such as API changes, you will have to find testers to +1 your PR.
---
## Use the Search, Luke
_May the Force (of past experiences) be with you_
GitHub offers [many search features](https://help.github.com/articles/searching-github/)
to help you check whether a similar contribution to yours already exists. Please search
before making any contribution, it avoids duplicates and eases maintenance. Trust me,
that works 90% of the time.
You can also take a look at the [FAQ](https://github.com/Drop-OSS/docs/wiki/FAQ)
to be sure your contribution has not already come up.
If all fails, your thing has probably not been reported yet, so you can go ahead
and [create an issue](#reporting-issues) or [submit a PR](#submitting-pull-requests).
---
## Translation
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
Drop uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
specification. The automatic changelog tool uses these to automatically generate
a changelog based on the commit messages. Here's a guide to writing a commit message
to allow this:
### Format
```
type(scope)!: subject
```
- `type`: the type of the commit is one of the following:
- `feat`: new features.
- `fix`: bug fixes.
- `docs`: documentation changes.
- `refactor`: refactor of a particular code section without introducing
new features or bug fixes.
- `style`: code style improvements.
- `perf`: performance improvements.
- `test`: changes to the test suite.
- `ci`: changes to the CI system.
- `build`: changes to the build system.
- `chore`: for other changes that don't match previous types. This doesn't appear
in the changelog.
- `scope`: section of the codebase that the commit makes changes to. If it makes changes to
many sections, or if no section in particular is modified, leave blank without the parentheses.
Examples:
- Commit that changes the `git` plugin:
```
feat(git): add alias for `git commit`
```
- Commit that changes many plugins:
```
style: fix inline declaration of arrays
```
For changes to plugins or themes, the scope should be the plugin or theme name:
- ✅ `fix(agnoster): commit subject`
- ❌ `fix(theme/agnoster): commit subject`
- `!`: this goes after the `scope` (or the `type` if scope is empty), to indicate that the commit
introduces breaking changes.
Optionally, you can specify a message that the changelog tool will display to the user to indicate
what's changed and what they can do to deal with it. You can use multiple lines to type this message;
the changelog parser will keep reading until the end of the commit message or until it finds an empty
line.
Example (made up):
```
style(agnoster)!: change dirty git repo glyph
BREAKING CHANGE: the glyph to indicate when a git repository is dirty has
changed from a Powerline character to a standard UTF-8 emoji. You can
change it back by setting `ZSH_THEME_DIRTY_GLYPH`.
Fixes #420
Co-authored-by: Username <email>
```
- `subject`: a brief description of the changes. This will be displayed in the changelog. If you need
to specify other details, you can use the commit body, but it won't be visible.
Formatting tricks: the commit subject may contain:
- Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool:
```
feat(archlinux): add support for aura AUR helper (#9467)
```
- Formatted inline code by using backticks: the text between backticks will also be highlighted by
the changelog tool:
```
feat(shell-proxy): enable unexported `DEFAULT_PROXY` setting (#9774)
```
### Style
Try to keep the first commit line short. It's harder to do using this commit style but try to be
concise, and if you need more space, you can use the commit body. Try to make sure that the commit
subject is clear and precise enough that users will know what changed by just looking at the changelog.
---
<!--
## Volunteer
Very nice!! :)
Please have a look at the [Volunteer](https://github.com/ohmyzsh/ohmyzsh/wiki/Volunteers)
page for instructions on where to start and more.
-->
## Reference
This contributing guide is adapted from the
[oh-my-zsh contribution guide](https://github.com/ohmyzsh/ohmyzsh/blob/master/CONTRIBUTING.md).
If there are any issues with this, please email admin@deepcore.dev.
Check out our contributing guidelines on our developer docs: [https://developer.droposs.org/contributing](https://developer.droposs.org/contributing).

View File

@ -1,40 +1,45 @@
# syntax=docker/dockerfile:1
FROM node:lts-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
# so corepack knows pnpm's version
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# prevent prompt to download
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# setup for offline
RUN corepack pack
# don't call out to network anymore
ENV COREPACK_ENABLE_NETWORK=0
### Unified deps builder
# FROM node:lts-alpine AS deps
# WORKDIR /app
# COPY package.json yarn.lock ./
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --network-timeout 1000000 --ignore-scripts
FROM base AS deps
RUN pnpm install --frozen-lockfile --ignore-scripts
### Build for app
FROM node:lts-alpine AS build-system
# setup workdir - has to be the same filepath as app because fuckin' Prisma
WORKDIR /app
FROM base AS build-system
ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
# ENV YARN_CACHE_FOLDER=/root/.yarn
# add git so drop can determine its git ref at build
# pnpm for build
RUN apk add --no-cache git pnpm
RUN apk add --no-cache git
# copy deps and rest of project files
# COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BUILD_DROP_VERSION
ARG BUILD_GIT_REF
# build
RUN pnpm import
RUN pnpm install --shamefully-hoist
RUN pnpm run build
# RUN --mount=type=cache,target=/root/.yarn yarn postinstall && yarn build
RUN pnpm run postinstall && pnpm run build
### create run environment for Drop
FROM node:lts-alpine AS run-system
WORKDIR /app
FROM base AS run-system
ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
@ -42,6 +47,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

@ -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

@ -4,9 +4,10 @@
v-for="(_, i) in amount"
:key="i"
:class="[
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
carousel.currentSlide === i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
'transition-all cursor-pointer h-2 rounded-full',
]"
@click="slideTo(i)"
/>
</div>
</template>
@ -18,8 +19,8 @@ const carousel = inject(injectCarousel)!;
const amount = carousel.maxSlide - carousel.minSlide + 1;
// function slideTo(index: number) {
// const offsetIndex = index + carousel.minSlide;
// carousel.nav.slideTo(offsetIndex);
// }
function slideTo(index: number) {
const offsetIndex = index + carousel.minSlide;
carousel.nav.slideTo(offsetIndex);
}
</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

@ -176,9 +176,12 @@
active ? 'bg-zinc-900 outline-hidden' : '',
'w-full text-left block px-4 py-2 text-sm',
]"
@click="() => (currentSort = option.param)"
@click.prevent="handleSortClick(option, $event)"
>
{{ option.name }}
<span v-if="currentSort === option.param">
{{ sortOrder === 'asc' ? '↑' : '↓' }}
</span>
</button>
</MenuItem>
</div>
@ -292,7 +295,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-6 2xl:grid-cols-7 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-5 2xl:grid-cols-6 gap-4"
>
<!-- Your content -->
<GamePanel
@ -389,8 +392,13 @@ const sorts: Array<StoreSortOption> = [
name: "Recently Added",
param: "recent",
},
{
name: "Name",
param: "name",
},
];
const currentSort = ref(sorts[0].param);
const sortOrder = ref<"asc" | "desc">("desc");
const options: Array<StoreFilterOption> = [
...(tags.length > 0
@ -466,7 +474,7 @@ async function updateGames(query: string, resetGames: boolean) {
results: Array<SerializeObject<GameModel>>;
count: number;
}>(
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}&order=${sortOrder.value}${query ? "&" + query : ""}`,
);
if (resetGames) {
games.value = newValues.results;
@ -483,6 +491,20 @@ watch(filterQuery, (newUrl) => {
watch(currentSort, (_) => {
updateGames(filterQuery.value, true);
});
watch(sortOrder, (_) => {
updateGames(filterQuery.value, true);
});
await updateGames(filterQuery.value, true);
</script>
function handleSortClick(option: StoreSortOption, event: MouseEvent) {
event.stopPropagation();
if (currentSort.value === option.param) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
} else {
currentSort.value = option.param;
sortOrder.value = option.param === 'name' ? 'asc' : 'desc';
}
}
</script>

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,17 +1,18 @@
{
"account": {
"devices": {
"capabilities": "Möglichkeiten",
"capabilities": "Funktionen",
"lastConnected": "Zuletzt verbunden",
"noDevices": "Keine Geräte sind mit deinem Konto verbunden.",
"platform": "Plattform",
"revoke": "Wiederrufen",
"subheader": "Geräte verwalten, die auf Ihr Drop Konto zugreifen dürfen.",
"title": "Geräte"
},
"notifications": {
"all": "Alle anzeigen {arrow}",
"desc": "Benachrichtigungen anzeigen und verwalten.",
"markAllAsRead": "Alles als gelesen markieren",
"all": "Alles anzeigen {arrow}",
"desc": "Anzeigen und Verwalten deiner Benachrichtigung.",
"markAllAsRead": "Markiere alle als gelesen",
"markAsRead": "Als gelesen Markieren",
"none": "Keine Benachrichtigungen",
"notifications": "Benachrichtigungen",
@ -19,17 +20,37 @@
"unread": "Ungelesene Benachrichtigungen"
},
"settings": "Einstellungen",
"title": "Kontoeinstellungen"
"title": "Kontoeinstellungen",
"token": {
"acls": "Berechtigungen (ACLs/Scopes)",
"aclsDesc": "Definiert, wozu dieses Schlüssel berechtigt ist. Du solltest vermeiden, alle ACLs auszuwählen, wenn dies nicht notwendig ist.",
"expiry": "Ablaufdatum",
"expiry3Month": "3 Monate",
"expiry5Year": "5 Jahre",
"expiry6Month": "6 Monate",
"expiryMonth": "Ein Monat",
"expiryYear": "Ein Jahr",
"name": "API-Schlüssel Name",
"nameDesc": "Der Name des Schlüssels, als Referenz.",
"namePlaceholder": "Mein neuer Schlüssel",
"noExpiry": "Unbegrenzt gültig",
"noTokens": "Keine Schlüssel mit deinem Konto verbunden.",
"revoke": "Wiederrufen",
"subheader": "Verwalte deine API-Schlüssel und deren Zugriffsrechte.",
"success": "Schlüssel erfolgreich erstellt.",
"successNote": "Bitte jetzt kopieren, da es nicht noch einmal angezeigt wird.",
"title": "API-Schlüssel"
}
},
"actions": "Aktionen",
"add": "Hinzufügen",
"adminTitle": "Administrator Dashbord - Drop",
"adminTitleTemplate": "{0} - Administrator - Drop",
"adminTitle": "Admin Dashboard - Drop",
"adminTitleTemplate": "{0} - Admin - Drop",
"auth": {
"callback": {
"authClient": "Client autorisieren?",
"authorize": "Autorisieren",
"authorizedClient": "Drop hat den Client erfolgreich autorisiert. Du darfst dieses Fenster nun schließen.",
"authorizedClient": "Drop hat den Client erfolgreich autorisiert. Du kannst dieses Fenster nun schließen.",
"issues": "Probleme?",
"learn": "Mehr erfahren {arrow}",
"paste": "Füge diesen Code in den Client ein, um fortzufahren:",
@ -38,9 +59,10 @@
"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"
"description": "Verwende einen Code, um dein Drop Client zu verbinden, wenn dein Gerät kein Webbrowser öffnen kann.",
"title": "Verbinde deinen Drop Client"
},
"confirmPassword": "Bestätige @:auth.password",
"displayName": "Anzeigename",
"email": "E-Mail",
"password": "Passwort",
@ -55,10 +77,10 @@
"signin": {
"externalProvider": "Bei externem Anbieter anmelden {arrow}",
"forgot": "Passwort vergessen?",
"noAccount": "Noch kein Konto? Bitten den Administrator, eines für dich zu erstellt.",
"noAccount": "Noch kein Konto? Bitte den Admin, eines für dich zu erstellen.",
"or": "ODER",
"pageTitle": "Bei Drop anmelden",
"rememberMe": "An mich erinnern",
"rememberMe": "Erinnere mich",
"signin": "Anmelden",
"title": "Melde dich bei deinem Konto an"
},
@ -124,9 +146,9 @@
}
},
"auth": {
"disabled": "Ungültiges oder deaktiviertes Konto. Bitte kontaktiere einen Server Administrator.",
"disabled": "Ungültiges oder deaktiviertes Konto. Bitte kontaktiere einen Server Admin.",
"invalidInvite": "Ungültige oder abgelaufene Einladung",
"invalidPassState": "Ungültiger Passwortzustand. Bitte kontaktiere einen Server Administrator.",
"invalidPassState": "Ungültiger Passwortzustand. Bitte kontaktiere einen Server Admin.",
"invalidUserOrPass": "Ungültiger Nutzername oder Passwort.",
"inviteIdRequired": "id erforderlich beim Abrufen der Einladung",
"method": {
@ -135,6 +157,10 @@
"usernameTaken": "Nutzername bereits vergeben."
},
"backHome": "{arrow} Zurück zur Startseite",
"externalUrl": {
"subtitle": "Diese Nachricht ist nur sichtbar für Admins.",
"title": "Zugriff über eine andere EXTERNAL_URL. Bitte die Dokumentation prüfen."
},
"game": {
"banner": {
"description": "Das Aktualisieren des Banners ist fehlgeschlagen: {0}",
@ -192,6 +218,8 @@
"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",
"revokeClient": "Client konnte nicht widerrufen werden",
"revokeClientFull": "Client konnte nicht widerrufen werden {0}",
"signIn": "Anmelden {arrow}",
"support": "Support Discord",
"unknown": "Ein unbekannter Fehler ist aufgetreten",
@ -231,8 +259,13 @@
},
"header": {
"admin": {
"admin": "Administrator",
"settings": "Einstellungen",
"admin": "Admin",
"metadata": "Metadaten",
"settings": {
"store": "Store",
"title": "Einstellungen",
"tokens": "API-Schlüssel"
},
"tasks": "Aufgaben",
"users": "Benutzer"
},
@ -240,6 +273,7 @@
"openSidebar": "Öffne Seitenleiste"
},
"helpUsTranslate": "Hilf uns Drop zu übersetzen {arrow}",
"highest": "Höchste",
"home": "Startseite",
"library": {
"addGames": "Alle Spiele",
@ -249,50 +283,117 @@
"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."
"addDescriptionNoImages": "Keine Bilder zum hinzufügen.",
"addImageCarousel": "Aus der Bilderbibliothek hinzufügen",
"currentBanner": "Banner",
"currentCover": "Cover",
"deleteImage": "Bild löschen",
"editGameDescription": "Spielbeschreibung",
"editGameName": "Spielname",
"imageCarousel": "Bilderkarussell",
"imageCarouselDescription": "Anpassen, welche Bilder und in welcher Reihenfolge sie auf der Shop-Seite angezeigt werden.",
"imageCarouselEmpty": "Es wurden noch keine Bilder zum Karussell hinzugefügt.",
"imageLibrary": "Bilderbibliothek",
"imageLibraryDescription": "Bitte beachten: Alle hochgeladenen Bilder sind für alle Nutzer über die Browser-Entwicklertools zugänglich.",
"removeImageCarousel": "Bild entfernen",
"setBanner": "Als Banner festlegen",
"setCover": "Als Cover festlegen"
},
"gameLibrary": "Spielebibliothek",
"import": {
"bulkImportDescription": "Auf dieser Seite wirst du nicht zur Importaufgabe weitergeleitet, sodass du mehrere Spiele nacheinander importieren kannst.",
"bulkImportTitle": "Massenimport Modus",
"import": "Import",
"link": "Import {arrow}",
"loading": "Spieldaten werden geladen…",
"search": "Suche",
"searchPlaceholder": "Fallout 4",
"selectDir": "Bitte wähle ein Verzeichnis aus…",
"selectGame": "Spiel zum Import auswählen",
"selectGamePlaceholder": "Bitte wähle ein Spiel aus…",
"selectGameSearch": "Spiel auswählen",
"selectPlatform": "Bitte wähle eine Plattform aus…",
"version": {
"advancedOptions": "Erweiterte Optionen",
"import": "Version Importieren",
"installDir": "(Installationsverzeichnis)/",
"launchCmd": "Programm/Befehl starten",
"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",
"platform": "Plattformversion",
"setupCmd": "Installationsprogramm oder Befehl ausführen",
"setupDesc": "Wird einmal ausgeführt, wenn das Spiel installiert wird",
"setupMode": "Einrichtungsmodus",
"setupModeDesc": "Wenn aktiviert, hat diese Version keinen Startbefehl und führt einfach die ausführbare Datei auf dem Computer des Nutzers aus. Nützlich für Spiele, die nur einen Installer bereitstellen und keine portablen Dateien.",
"setupPlaceholder": "setup.exe",
"umuLauncherId": "UMU Launcher ID",
"umuOverride": "Überschreibe UMU Launcher Spiel ID",
"umuOverrideDesc": "Standardmäßig verwendet Drop beim Start über den UMU Launcher eine Nicht-ID. Um die richtigen Patches für manche Spiele zu erhalten, musst du dieses Feld eventuell manuell setzen.",
"updateMode": "Aktualisierungsmodus",
"updateModeDesc": "Wenn aktiviert, werden diese Dateien über die vorherige Version installiert (überschrieben). Werden mehrere Update-Modi hintereinander verwendet, werden sie in der angegebenen Reihenfolge angewendet.",
"version": "Wähle die Version für den Import aus"
},
"withoutMetadata": "Ohne Metadaten importieren"
},
"libraryHint": "Keine Bibliotheken konfiguriert.",
"libraryHintDocsLink": "Was bedeutet das? {arrow}",
"metadata": {
"companies": {
"action": "Verwalte {arrow}",
"addGame": {
"description": "Wähle ein Spiel aus, das dem Unternehmen hinzugefügt werden soll, und lege fest, ob es als Entwickler, Publisher oder beides geführt werden soll.",
"developer": "Entwickler?",
"noGames": "Keine Spiele zum hinzufügen",
"publisher": "Publisher?"
"publisher": "Publisher?",
"title": "Verbinde das Spiel mit diesem Unternehmen"
},
"description": "Unternehmen organisieren Spiele danach, wer sie entwickelt oder veröffentlicht hat.",
"editor": {
"action": "Spiel hinzufügen {plus}",
"descriptionPlaceholder": "{'<'}Beschreibung{'>'}",
"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"
"uploadIcon": "Icon hochladen",
"websitePlaceholder": "{'<'}Webseite{'>'}"
},
"modals": {
"createDescription": "Erstelle ein Unternehmen, um deine Spiele besser zu organisieren.",
"createFieldDescription": "Unternehmensbeschreibung",
"createFieldDescriptionPlaceholder": "Ein kleines Indie-Studio, das…",
"createFieldName": "Unternehmensname",
"createFieldNamePlaceholder": "Mein neues Unternehmen…",
"createFieldWebsite": "Unternehmenswebseite",
"createFieldWebsitePlaceholder": "https://beispiel.de/",
"createTitle": "Unternehmen erstellen",
"nameDescription": "Bearbeite den Namen des Unternehmens. Wird verwendet, um neue Spielimporte zuzuordnen.",
"nameTitle": "Bearbeite Firmenname",
"shortDeckDescription": "Bearbeite die Firmenbeschreibung. Beeinträchtigt nicht die Lange (markdown) Beschreibung.",
"shortDeckTitle": "Bearbeite Firmenbeschreibung"
}
"shortDeckTitle": "Bearbeite Firmenbeschreibung",
"websiteDescription": "„Bearbeite die Webseite des Unternehmens. Hinweis: Dies wird ein Link sein und bietet keinen Redirect-Schutz.",
"websiteTitle": "Unternehmenswebseite bearbeiten"
},
"noCompanies": "Keine Unternehmen",
"noGames": "Keine Spiele",
"search": "Suche Unternehmen…",
"searchGames": "Unternehmensspiele durchsuchen…",
"title": "Unternehmen"
},
"tags": {
"action": "Verwalte {arrow}",
"create": "Erstellen"
"create": "Erstellen",
"description": "Tags werden automatisch aus importierten Genres erstellt. Du kannst eigene Tags hinzufügen, um deine Spielbibliothek zu kategorisieren.",
"modal": {
"description": "Erstelle einen Tag, um deine Bibliothek zu organisieren.",
"title": "Tag erstellen"
},
"title": "Tags"
}
},
"metadataProvider": "Metadatenanbieter",
@ -301,13 +402,31 @@
"offlineTitle": "Spiel offline",
"openEditor": "Im Editor öffnen {arrow}",
"openStore": "Im Store öffnen",
"shortDesc": "Kurzbeschreibung",
"sources": {
"create": "Quelle erstellen",
"createDesc": "Drop wird diese Quelle verwenden, um auf deine Spielbibliothek zuzugreifen und die Spiele verfügbar zu machen.",
"desc": "Konfiguriere deine Bibliotheksquellen, wo Drop nach neuen Spielen und Versionen zum Import suchen wird.",
"documentationLink": "Dokumentation {arrow}",
"edit": "Quelle bearbeiten",
"fsDesc": "Importiert Spiele von einem Pfad auf der Festplatte. Benötigt eine versionsbasierte Ordnerstruktur und unterstützt archivierte Spiele.",
"fsFlatDesc": "Importiert Spiele von einem Pfad auf der Festplatte, jedoch ohne separate Unterordner für Versionen. Nützlich beim Migrieren einer bestehenden Bibliothek zu Drop.",
"fsFlatTitle": "Kompatibilität",
"fsPath": "Pfad",
"fsPathDesc": "Absoluter Pfad zur Spielebibliothek.",
"fsPathPlaceholder": "/mnt/spiele"
"fsPathPlaceholder": "/mnt/spiele",
"fsTitle": "Drop-Stil",
"link": "Quellen {arrow}",
"nameDesc": "Der Name deiner Quelle, als Referenz.",
"namePlaceholder": "Meine neue Quelle",
"sources": "Bibliotheksquellen",
"typeDesc": "Der Typ deiner Quelle. Ändert die erforderlichen Optionen.",
"working": "Funktioniert es?"
},
"subheader": "Wenn du Ordner zu deinen Bibliotheksquellen hinzufügst, erkennt Drop diese und fordert dich auf, sie zu importieren. Jedes Spiel muss importiert werden, bevor du eine Version importieren kannst.",
"title": "Bibliotheken",
"version": {
"delta": "Upgrade Modus",
"noVersions": "Du hast keine verfügbare Version dieses Spiels.",
"noVersionsAdded": "keine Versionen hinzugefügt"
},
@ -331,19 +450,30 @@
"launcherOpen": "Im Launcher öffnen",
"noGames": "Keine Spiele in der Bibliothek",
"notFound": "Spiel nicht gefunden",
"search": "Durchsuche Bibliothek…"
"search": "Durchsuche Bibliothek…",
"subheader": "Verwalte deine Spiele in Sammlungen für einen einfacheren Zugriff auf alle deine Spiele."
},
"lowest": "Niedrigste",
"news": {
"article": {
"add": "Hinzufügen",
"content": "Inhalt (Markdown)",
"create": "Neuen Artikel erstellen",
"editor": "Editor",
"editorGuide": "Verwende die obigen Shortcuts oder schreibe direkt in Markdown. Unterstützt **fett**, *kursiv*, [Links](URL) und mehr.",
"new": "Neuer Artikel",
"preview": "Vorschau",
"titles": "Titel"
"shortDesc": "Kurzbeschreibung",
"submit": "Absenden",
"tagPlaceholder": "Tag hinzufügen…",
"titles": "Titel",
"uploadCover": "Cover hochladen"
},
"back": "Zurück zu Neuigkeiten",
"checkLater": "Schaue später für Updates vorbei.",
"delete": "Artikel löschen",
"filter": {
"all": "Gesamt",
"month": "Diesen Monat",
"week": "Diese Woche",
"year": "Dieses Jahr"
@ -351,7 +481,9 @@
"none": "Keine Artikel",
"notFound": "Artikel nicht gefunden",
"search": "Suche Artikel",
"searchPlaceholder": "Suche Artikel…"
"searchPlaceholder": "Suche Artikel…",
"subheader": "Bleibe auf dem Laufenden über die neuesten Updates und Ankündigungen.",
"title": "Neueste Neuigkeiten"
},
"options": "Einstellungen",
"security": "Sicherheit",
@ -359,30 +491,42 @@
"settings": {
"admin": {
"description": "Konfiguriere Drop Einstellungen",
"store": {
"dropGameAltPlaceholder": "Beispiel Spielsymbol",
"dropGameDescriptionPlaceholder": "Dies ist ein exemplarisches Spiel. Es wird ersetzt wenn du ein Spiel importierst.",
"dropGameNamePlaceholder": "Beispielspiel",
"showGamePanelTextDecoration": "Zeige Titel und Beschreibung auf den Spielkacheln (Standard: an)",
"title": "Store"
},
"title": "Einstellungen"
}
},
"setup": {
"auth": {
"description": "Die Authentifizierung in Drop erfolgt über mehrere konfigurierte Provider. Jeder Provider ermöglicht es Nutzern, sich über seine Methode anzumelden. Um zu starten, sollte mindestens ein Authentifizierungs-Provider aktiviert sein und ein Konto über diesen erstellt werden.",
"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.",
"skip": "Ich habe ein OIDC Nutzer",
"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.",
"register": "Als Admin registrieren {arrow}",
"title": "Einfache Authentifizierung"
},
"title": "Authentifizierung"
},
"finish": "Los geht's {arrow}",
"noPage": "keine Seite",
"stages": {
"account": {
"description": "Du benötigst mindestens ein Konto, um Drop zu benutzen.",
"name": "Richte dein Administratorkonto ein."
},
"library": {
"description": "Füge mindestens eine Bibliotheksquelle hinzu, um Drop zu nutzen.",
"name": "Erstelle eine Bibliothek."
}
},
@ -391,79 +535,127 @@
},
"store": {
"about": "Über",
"commingSoon": "Demnächst verfügbar",
"developers": "Entwickler | Entwickler | Entwickler",
"exploreMore": "Mehr entdecken {arrow}",
"featured": "Empfohlen",
"images": "Spielbilder",
"lookAt": "Schau es dir an",
"noDevelopers": "Keine Entwickler",
"noGame": "Kein Spiel",
"noFeatured": "KEINE HERVORGEHOBENEN SPIELE",
"noGame": "KEIN SPIEL",
"noImages": "Keine Bilder",
"noPublishers": "Kein Publisher.",
"noTags": "Keine Tags",
"openAdminDashboard": "Im Admin Dashboard öffnen",
"openFeatured": "Spiele in der Admin-Bibliothek markieren {arrow}",
"platform": "Plattform | Plattform | Plattform",
"publishers": "Publisher | Publisher | Publisher",
"rating": "Bewertung",
"readLess": "Weniger anzeigen",
"readMore": "Mehr anzeigen",
"recentlyAdded": "Kürzlich hinzugefügt",
"recentlyReleased": "Kürzlich veröffentlicht",
"recentlyUpdated": "Kürzlich aktualisiert",
"released": "Veröffentlicht",
"reviews": "({0} Bewertungen)",
"tags": "Tags",
"title": "Store",
"view": {
"sort": "Sortieren",
"srFilters": "Filter",
"srGames": "Spiele"
"srGames": "Spiele",
"srViewGrid": "Raster anzeigen"
},
"viewInStore": "Im Store ansehen",
"website": "Webseite"
},
"tasks": {
"admin": {
"back": "{arrow} Zurück zu den Aufgaben",
"completedTasksTitle": "Abgeschlossene Aufgaben",
"dailyScheduledTitle": "Tägliche Aufgaben",
"execute": "{arrow} Ausführen",
"noTasksRunning": "Keine laufenden Aufgaben",
"progress": "{0}%",
"runningTasksTitle": "Laufende Aufgaben",
"scheduled": {
"checkUpdateDescription": "Drop auf Updates überprüfen.",
"checkUpdateName": "Auf Updates prüfen.",
"cleanupInvitationsDescription": "Bereinigt abgelaufene Einladungen aus der Datenbank, um Speicherplatz zu sparen.",
"cleanupInvitationsName": "Einladungen bereinigen",
"cleanupObjectsDescription": "Erkennt und löscht nicht referenzierte und ungenutzte Objekte, um Speicherplatz zu sparen.",
"cleanupObjectsName": "Objekte bereinigen",
"cleanupSessionsDescription": "Bereinigt abgelaufene Sitzungen, um Speicherplatz zu sparen und die Sicherheit zu gewährleisten.",
"cleanupSessionsName": "Sitzungen bereinigen."
}
},
"viewTask": "Ansehen {arrow}",
"weeklyScheduledTitle": "Wöchentliche Aufgaben"
}
},
"title": "Drop",
"titleTemplate": "{0} - Drop",
"todo": "Todo",
"type": "Typ",
"upload": "Hochladen",
"uploadFile": "Datei hochladen",
"userHeader": {
"closeSidebar": "Seitenleiste schließen",
"links": {
"library": "Bibliothek"
"community": "Community",
"library": "Bibliothek",
"news": "Neuigkeiten"
},
"profile": {
"admin": "Admin Dashboard",
"settings": "Kontoeinstellungen"
}
},
"users": {
"admin": {
"adminHeader": "Administrator?",
"adminUserLabel": "Admin",
"authLink": "Authentifizierung {arrow}",
"authentication": {
"configure": "Konfigurieren",
"description": "Drop unterstützt eine Vielzahl von Authentifizierungsmechanismen. Wenn du sie aktivierst oder deaktivierst, werden sie auf dem Anmeldebildschirm angezeigt, damit Benutzer sie auswählen können. Klicke auf die Drei-Punkte, um den Authentifizierungsmechanismus zu konfigurieren.",
"disabled": "Deaktiviert",
"enabled": "Aktiviert",
"enabledKey": "Aktiviert?",
"oidc": "OpenID Connect",
"simple": "Einfach (Nutzername/Passwort)",
"srOpenOptions": "Einstellungen öffnen",
"title": "Authentifizierung"
},
"authoptionsHeader": "Authentifizierungseinstellungen",
"delete": "Löschen",
"deleteUser": "Benutzer löschen {0}",
"description": "Verwalte Benutzer auf deiner Drop-Instanz und konfiguriere deine Authentifizierungsmethode.",
"displayNameHeader": "Anzeigename",
"emailHeader": "E-Mail",
"normalUserLabel": "Normaler Benutzer",
"simple": {
"adminInvitation": "Admin Einladung",
"createInvitation": "Einladung erstellen",
"description": "Die einfache Authentifizierung verwendet ein Einladungssystem zur Erstellung von Benutzern. Du kannst eine Einladung erstellen und optional einen Benutzernamen oder eine E-Mail-Adresse für den Benutzer angeben. Daraufhin wird eine magische URL generiert, mit der ein Konto erstellt werden kann.",
"expires": "Läuft ab: {expiry}",
"invitationTitle": "Einladungen",
"invite3Days": "3 Tage",
"invite6Months": "6 Monate",
"inviteAdminSwitchDescription": "Erstelle diesen Benutzer als Administrator",
"inviteAdminSwitchLabel": "Admin Einladung",
"inviteButton": "Einladung",
"inviteDescription": "Drop erstellt eine URL, die du an die Person senden kannst, die du einladen möchtest. Du kannst optional einen Benutzernamen oder eine E-Mail-Adresse angeben, die sie verwenden soll.",
"inviteEmailDescription": "Muss im Format nutzer{'@'}beispiel.de sein",
"inviteEmailLabel": "E-Mail-Adresse (optional)",
"inviteEmailPlaceholder": "ich{'@'}beispiel.de",
"inviteExpiryLabel": "Läuft ab",
"inviteMonth": "1 Monat",
"inviteNever": "Niemals",
"inviteTitle": "Ein Benutzer zu Drop einladen",
"inviteUsernameFormat": "Muss mindestens 5 Zeichen lang sein",
"inviteUsernameLabel": "Nutzername (optional)",
"inviteUsernamePlaceholder": "meinNutzername",
"inviteWeek": "1 Woche",
"inviteYear": "1 Jahr",
"neverExpires": "Läuft niemals ab.",
@ -476,5 +668,6 @@
"srEditLabel": "Bearbeiten",
"usernameHeader": "Nutzername"
}
}
},
"welcome": "Deutsche, willkommen!"
}

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"
},
@ -241,7 +263,11 @@
"admin": {
"admin": "Admin",
"metadata": "Meta",
"settings": "Settings",
"settings": {
"title": "Settings",
"store": "Store",
"tokens": "API tokens"
},
"tasks": "Tasks",
"users": "Users"
},

View File

@ -20,7 +20,27 @@
"unread": "Notifications Non Lues"
},
"settings": "Paramètres",
"title": "Paramètres du Compte"
"title": "Paramètres du Compte",
"token": {
"acls": "ACLs/scopes",
"aclsDesc": "Définir les permissions du Token. Il n'est pas recommandé de sélectionner toutes les ACLs, à moins que ce soit nécessaire.",
"expiry": "Expiration",
"expiry3Month": "3 mois",
"expiry5Year": "5 Années",
"expiry6Month": "6 mois",
"expiryMonth": "Un mois",
"expiryYear": "Une année",
"name": "Nom du Token API",
"nameDesc": "Le nom du Token, comme référence.",
"namePlaceholder": "Mon nouveau Token",
"noExpiry": "Pas d'expiration",
"noTokens": "Aucun Token connecté à votre compte.",
"revoke": "Révoquer",
"subheader": "Gérer vos Tokens et leurs permissions associées.",
"success": "Token créé avec succès.",
"successNote": "Assurez vous de le sauvegarder maintenant, il ne sera plus disponible après.",
"title": "API Tokens"
}
},
"actions": "Actions",
"add": "Ajouter",

View File

@ -5,6 +5,7 @@
"lastConnected": "Последнее подключение",
"noDevices": "К вашей учетной записи не подключено ни одного устройства.",
"platform": "Платформа",
"revoke": "Аннулировать",
"subheader": "Управляйте устройствами, имеющими доступ к вашей учетной записи Drop.",
"title": "Устройства"
},
@ -19,7 +20,27 @@
"unread": "Непрочитанные уведомления"
},
"settings": "Настройки",
"title": "Настройки учетной записи"
"title": "Настройки учетной записи",
"token": {
"acls": "Доступ и права",
"aclsDesc": "Определяет, какие действия разрешены для этого токена. Не выбирайте все ACL, если это не требуется.",
"expiry": "Истечение срока",
"expiry3Month": "3 месяца",
"expiry5Year": "5 лет",
"expiry6Month": "6 месяцев",
"expiryMonth": "Месяц",
"expiryYear": "Год",
"name": "Название API-токена",
"nameDesc": "Название токена для справки.",
"namePlaceholder": "Мои новые токены",
"noExpiry": "Без срока действия",
"noTokens": "На вашем аккаунте нет подключённых токенов.",
"revoke": "Аннулировать",
"subheader": "Управляйте своими API-токенами и их доступом.",
"success": "Токен успешно создан.",
"successNote": "Скопируйте токен сейчас, позже его не будет видно.",
"title": "API-Токены"
}
},
"actions": "Действия",
"add": "Добавить",
@ -34,7 +55,50 @@
"learn": "Узнать больше {arrow}",
"paste": "Вставьте этот код в клиент, чтобы продолжить:",
"permWarning": "Принятие этого запроса позволит \"{name}\" на \"{platform}\" выполнять следующие действия:",
"requestedAccess": "\"{name}\" запросил доступ к вашей учетной записи Drop."
}
"requestedAccess": "\"{name}\" запросил доступ к вашей учетной записи Drop.",
"success": "Успешно!"
},
"email": "Элетронная почка",
"password": "Пароль",
"register": {
"confirmPasswordFormat": "Должно совпадать с выше."
},
"signin": {
"forgot": "Забыли пароль?",
"noAccount": "Нет аккаунта? Попросите администратора создать его для вас.",
"or": "Или",
"signin": "Войти",
"title": "Войдите в свой аккаунт"
},
"signout": "Выход",
"username": "Имя пользователя"
},
"cancel": "Отмена",
"common": {
"add": "Добавить",
"close": "Закрыть",
"create": "Создать",
"date": "Дата",
"deleteConfirm": "Вы точно хотите удалить \"{0}\"?",
"edit": "Редактировать",
"friends": "Друзья",
"groups": "Группы",
"name": "Имя",
"noResults": "Нет результатов",
"noSelected": "Не выбранные предметы.",
"remove": "Удалить",
"save": "Сохранить",
"saved": "Сохранено",
"servers": "Сервера",
"srLoading": "Загрузка…",
"tags": "Теги",
"today": "Сегодня"
},
"delete": "Удалить",
"drop": {
"drop": "Уронить"
},
"editor": {
"link": "Ссылка"
}
}

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

@ -74,7 +74,8 @@ export default defineNuxtConfig({
vite: {
plugins: [
tailwindcss(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tailwindcss() as any,
// only used in dev server, not build because nitro sucks
// see build hook below
viteStaticCopy({
@ -84,7 +85,8 @@ export default defineNuxtConfig({
dest: "twemoji",
},
],
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
],
},
@ -254,6 +256,7 @@ export default defineNuxtConfig({
"https://www.giantbomb.com",
"https://images.pcgamingwiki.com",
"https://images.igdb.com",
"https://*.steamstatic.com",
],
},
strictTransportSecurity: false,

View File

@ -1,6 +1,6 @@
{
"name": "drop",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"type": "module",
"license": "AGPL-3.0-or-later",
@ -14,14 +14,14 @@
"preview": "nuxt preview",
"postinstall": "nuxt prepare && prisma generate",
"typecheck": "nuxt typecheck",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint": "pnpm run lint:eslint && pnpm run lint:prettier",
"lint:eslint": "eslint .",
"lint:prettier": "prettier . --check",
"lint:fix": "eslint . --fix && prettier --write --list-different ."
},
"dependencies": {
"@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "2.3.1",
"@drop-oss/droplet": "3.2.0",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3",
@ -33,7 +33,7 @@
"@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.0",
"arktype": "^2.1.10",
"axios": "^1.7.7",
"axios": "^1.12.0",
"bcryptjs": "^3.0.2",
"cheerio": "^1.0.0",
"cookie-es": "^2.0.0",
@ -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",
@ -89,5 +89,6 @@
},
"prisma": {
"schema": "./prisma"
}
},
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
}

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>

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

@ -72,7 +72,7 @@
{{ $t("store.images") }}
</h2>
<div class="relative">
<VueCarousel :items-to-show="1">
<VueCarousel :items-to-show="1" :wrap-around="true">
<VueSlide
v-for="image in game.mImageCarouselObjectIds"
:key="image"

View File

@ -183,7 +183,7 @@
{{ game.mShortDescription }}
</p>
<div class="mt-6 py-4 rounded">
<VueCarousel :items-to-show="1">
<VueCarousel :items-to-show="1" :wrap-around="true">
<VueSlide
v-for="image in game.mImageCarouselObjectIds"
:key="image"

12336
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,4 @@
overrides:
droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet
shamefullyHoist: true

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

@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "MetadataSource" ADD VALUE 'Steam';
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- 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,6 +1,7 @@
enum MetadataSource {
Manual
GiantBomb
Steam
PCGamingWiki
IGDB
Metacritic

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

@ -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

@ -18,7 +18,8 @@ const StoreRead = type({
company: "string?",
companyActions: "string = 'published,developed'",
sort: "'default' | 'newest' | 'recent' = 'default'",
sort: "'default' | 'newest' | 'recent' | 'name' = 'default'",
order: "'asc' | 'desc' = 'desc'",
});
export default defineEventHandler(async (h3) => {
@ -101,10 +102,13 @@ export default defineEventHandler(async (h3) => {
switch (options.sort) {
case "default":
case "newest":
sort.mReleased = "desc";
sort.mReleased = options.order;
break;
case "recent":
sort.created = "desc";
sort.created = options.order;
break;
case "name":
sort.mName = options.order;
break;
}
@ -119,4 +123,4 @@ export default defineEventHandler(async (h3) => {
]);
return { results, count };
});
});

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

@ -9,7 +9,10 @@ const GetChunk = type({
files: type({
filename: "string",
chunkIndex: "number",
}).array(),
})
.array()
.atLeastLength(1)
.atMostLength(256),
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
@ -46,21 +49,27 @@ export default defineEventHandler(async (h3) => {
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",
});
const streams = await Promise.all(
streamFiles.map(async (file) => {
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",
});
return { ...file, stream: gameReadStream };
}),
);
for (const file of streams) {
let length = 0;
await gameReadStream.pipeTo(
await file.stream.pipeTo(
new WritableStream({
write(chunk) {
h3.node.res.write(chunk);

View File

@ -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

@ -57,7 +57,7 @@ class DownloadContextManager {
async cleanup() {
for (const key of this.contexts.keys()) {
const context = this.contexts.get(key)!;
if (context.timeout.getDate() + TIMEOUT < Date.now()) {
if (context.timeout.getTime() < Date.now() - TIMEOUT) {
this.contexts.delete(key);
}
}

View File

@ -171,6 +171,8 @@ class LibraryManager {
".sh",
// No extension is common for Linux binaries
"",
// AppImages
".appimage",
],
Windows: [".exe", ".bat"],
macOS: [

View File

@ -191,7 +191,7 @@ export class GiantBombProvider implements MetadataProvider {
const res = await publisher(pub.name);
if (res === undefined) {
context?.logger.warn(`Failed to import publisher "${pub}"`);
context?.logger.warn(`Failed to import publisher "${pub.name}"`);
continue;
}
context?.logger.info(`Imported publisher "${pub.name}"`);
@ -208,10 +208,10 @@ export class GiantBombProvider implements MetadataProvider {
const res = await developer(dev.name);
if (res === undefined) {
context?.logger.warn(`Failed to import developer "${dev}"`);
context?.logger.warn(`Failed to import developer "${dev.name}"`);
continue;
}
context?.logger.info(`Imported developer "${dev}"`);
context?.logger.info(`Imported developer "${dev.name}"`);
developers.push(res);
}
}

View File

@ -306,7 +306,7 @@ export class MetadataHandler {
result = await provider.fetchCompany({ query, createObject });
if (result === undefined) {
throw new Error(
`${provider.source()} failed to find a company for "${query}`,
`${provider.source()} failed to find a company for "${query}"`,
);
}
} catch (e) {

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -5,11 +5,13 @@ import { GiantBombProvider } from "../internal/metadata/giantbomb";
import { IGDBProvider } from "../internal/metadata/igdb";
import { ManualMetadataProvider } from "../internal/metadata/manual";
import { PCGamingWikiProvider } from "../internal/metadata/pcgamingwiki";
import { SteamProvider } from "../internal/metadata/steam";
import { logger } from "~/server/internal/logging";
export default defineNitroPlugin(async (_nitro) => {
const metadataProviders = [
GiantBombProvider,
SteamProvider,
PCGamingWikiProvider,
IGDBProvider,
];

9479
yarn.lock

File diff suppressed because it is too large Load Diff