4 Commits

Author SHA1 Message Date
4fb0a185f6 feat: preliminary peer api 2025-05-13 12:28:18 +10:00
70db79b50f feat: set data dir for headscale 2025-05-12 09:44:08 +10:00
ac7ef6303b feat: new unified data folder 2025-05-12 09:18:42 +10:00
efbc86e73e feat: add new HeadscaleManager 2025-05-12 09:04:28 +10:00
324 changed files with 11416 additions and 29521 deletions

View File

@ -1,9 +1,3 @@
Dockerfile
.github
.vscode
*.md
#### gitignore below
# Nuxt dev/build outputs
.output
.data
@ -14,7 +8,6 @@ dist
# Node dependencies
node_modules
.yarn
# Logs
logs
@ -31,13 +24,3 @@ logs
!.env.example
.data
# deploy template
deploy-template/*
!deploy-template/compose.yml
# generated prisma client
/prisma/client
/prisma/validate

View File

@ -1,5 +1,8 @@
DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
CLIENT_CERTIFICATES="./.data/ca"
FS_BACKEND_PATH="./.data/objects"
GIANT_BOMB_API_KEY=""
EXTERNAL_URL="http://localhost:3000"

View File

@ -1,15 +1,6 @@
name: CI
on:
push:
branches:
- develop
pull_request:
branches:
- develop
permissions:
contents: read
on: [pull_request, push]
jobs:
typecheck:
@ -21,20 +12,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: "pnpm"
cache: "yarn"
- name: Install dependencies
run: pnpm install
run: yarn install --immutable --network-timeout 1000000
- name: Typecheck
run: pnpm run typecheck
run: yarn typecheck
lint:
name: Lint
@ -45,17 +33,14 @@ 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: "pnpm"
cache: "yarn"
- name: Install dependencies
run: pnpm install
run: yarn install --immutable --network-timeout 1000000
- name: Lint
run: pnpm run lint
run: yarn lint

View File

@ -20,29 +20,8 @@ jobs:
uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 3 # fix for when this gets triggered by tag
fetch-tags: true
ref: ${{ github.ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine final version
id: get_final_ver
run: |
BASE_VER=v$(jq -r '.version' package.json)
TODAY=$(date +'%Y.%m.%d')
echo "Today will be: $TODAY"
echo "today=$TODAY" >> $GITHUB_OUTPUT
if [[ "${{ github.event_name }}" == "release" ]]; then
FINAL_VER="$BASE_VER"
else
FINAL_VER="${BASE_VER}-nightly.$TODAY"
fi
echo "Drop's release tag will be: $FINAL_VER"
echo "final_ver=$FINAL_VER" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -67,7 +46,6 @@ jobs:
ghcr.io/drop-OSS/drop
tags: |
type=schedule,pattern=nightly
type=schedule,pattern=nightly.${{ steps.get_final_ver.outputs.today }}
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
@ -77,33 +55,14 @@ jobs:
# set latest tag for stable releases
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
- name: Cache
uses: actions/cache@v4
id: cache
with:
path: cache-mount
key: cache-mount-${{ hashFiles('Dockerfile') }}
- name: Restore Docker cache mounts
uses: reproducible-containers/buildkit-cache-dance@v3
with:
builder: ${{ steps.setup-buildx.outputs.name }}
cache-dir: cache-mount
dockerfile: Dockerfile
skip-extraction: ${{ steps.cache.outputs.cache-hit }}
- name: Build and push image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILD_DROP_VERSION=${{ steps.get_final_ver.outputs.final_ver }}

3
.gitignore vendored
View File

@ -33,5 +33,4 @@ deploy-template/*
!deploy-template/compose.yml
# generated prisma client
/prisma/client
/prisma/validate
/prisma/client

2
.gitmodules vendored
View File

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

View File

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

View File

@ -1,12 +0,0 @@
{
"recommendations": [
"lokalise.i18n-ally",
"esbenp.prettier-vscode",
"Prisma.prisma",
"bradlc.vscode-tailwindcss",
"Vue.volar",
"arktypeio.arkdark",
"EditorConfig.EditorConfig",
"dbaeumer.vscode-eslint"
]
}

18
.vscode/settings.json vendored
View File

@ -17,21 +17,5 @@
"strings": "on"
},
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"],
// i18n Ally settings
"i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true,
"i18n-ally.extract.autoDetect": true,
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.extract.ignored": [
"string >= 14",
"string.alphanumeric >= 5",
"/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}"
],
"i18n-ally.extract.ignoredByFiles": {
"pages/admin/library/sources/index.vue": ["Filesystem"],
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
"server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"]
}
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
}

View File

@ -1,3 +1,242 @@
# Contributing
# CONTRIBUTING GUIDELINES
Check out our contributing guidelines on our developer docs: [https://developer.droposs.org/contributing](https://developer.droposs.org/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)
- [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)
- [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.
## 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).
---
## 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.

View File

@ -1,61 +1,30 @@
# syntax=docker/dockerfile:1
# pull pre-configured and updated build environment
FROM debian:testing-20250317-slim AS build-system
FROM node:lts-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# setup workdir - has to be the same filepath as app because fuckin' Prisma
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 base AS deps
RUN pnpm install --frozen-lockfile --ignore-scripts
### Build for app
FROM base AS build-system
ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
# add git so drop can determine its git ref at build
RUN apk add --no-cache git
# copy deps and rest of project files
COPY --from=deps /app/node_modules ./node_modules
# install dependencies and build
RUN apt-get update -y
RUN apt-get install node-corepack -y
RUN corepack enable
COPY . .
RUN NUXT_TELEMETRY_DISABLED=1 yarn install --network-timeout 1000000
RUN NUXT_TELEMETRY_DISABLED=1 yarn prisma generate
RUN NUXT_TELEMETRY_DISABLED=1 yarn build
ARG BUILD_DROP_VERSION
ARG BUILD_GIT_REF
# create run environment for Drop
FROM node:lts-slim AS run-system
# build
RUN pnpm run postinstall && pnpm run build
WORKDIR /app
### create run environment for Drop
FROM base AS run-system
ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
RUN apk add --no-cache pnpm
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
COPY --from=build-system /app/prisma ./prisma
COPY --from=build-system /app/package.json ./
COPY --from=build-system /app/build ./startup
ENV LIBRARY="/library"
ENV DATA="/data"
# OpenSSL as a dependency for Drop (TODO: seperate build environment)
RUN apt-get update -y && apt-get install -y openssl
RUN yarn global add prisma@6.7.0
CMD ["sh", "/app/startup/launch.sh"]
CMD ["/app/startup/launch.sh"]

View File

@ -6,32 +6,72 @@
# 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, 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>
Drop is an open-source game distribution platform, like GameVault or Steam. It's designed to distribute and shared DRM-free game quickly, all while being incredibly flexible, beautiful and fast.
## Philosophy
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.
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.
## Deployment
See our documentation on how to [deploy Drop](https://docs.droposs.org/docs/guides/quickstart) for more information.
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
## Contributing
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/)
Please see the [in-depth contributing guide](CONTRIBUTING.md)

42
app.vue
View File

@ -4,52 +4,10 @@
<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

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<template>
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<UserIcon class="size-5" /> {{ $t("account.title") }}
<UserIcon class="size-5" /> Account Settings
</span>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
@ -45,43 +45,35 @@ import {
LockClosedIcon,
DevicePhoneMobileIcon,
WrenchScrewdriverIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
import { UserIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue";
const notifications = useNotifications();
const { t } = useI18n();
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
{ label: "Home", route: "/account", icon: HomeIcon, prefix: "/account" },
{
label: t("security"),
label: "Security",
route: "/account/security",
prefix: "/account/security",
icon: LockClosedIcon,
},
{
label: t("account.devices.title"),
label: "Devices",
route: "/account/devices",
prefix: "/account/devices",
icon: DevicePhoneMobileIcon,
},
{
label: t("account.notifications.notifications"),
label: "Notifications",
route: "/account/notifications",
prefix: "/account/notifications",
icon: BellIcon,
count: notifications.value.length,
},
{
label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{
label: t("account.settings"),
label: "Settings",
route: "/account/settings",
prefix: "/account/settings",
icon: WrenchScrewdriverIcon,

View File

@ -8,7 +8,7 @@
class="transition w-full inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
@click="() => toggleLibrary()"
>
{{ inLibrary ? $t("library.inLib") : $t("library.addToLib") }}
{{ inLibrary ? "In Library" : "Add to Library" }}
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</LoadingButton>
@ -36,7 +36,7 @@
<div
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
>
{{ $t("library.collection.collections") }}
Collections
</div>
<div
class="flex flex-col gap-y-2 py-1 max-h-[150px] overflow-y-auto"
@ -45,7 +45,7 @@
v-if="collections.length === 0"
class="px-3 py-2 text-sm text-zinc-500"
>
{{ $t("library.collection.noCollections") }}
No collections
</div>
<MenuItem
v-for="(collection, collectionIdx) in collections"
@ -75,7 +75,7 @@
@click="createCollectionModal = true"
>
<PlusIcon class="mr-2 h-4 w-4" />
{{ $t("library.collection.addToNew") }}
Add to new collection
</LoadingButton>
</div>
</div>
@ -84,7 +84,7 @@
</Menu>
</div>
<ModalCreateCollection
<CreateCollectionModal
v-model="createCollectionModal"
:game-id="props.gameId"
/>
@ -100,7 +100,6 @@ const props = defineProps<{
const isLibraryLoading = ref(false);
const { t } = useI18n();
const createCollectionModal = ref(false);
const collections = await useCollections();
const library = await useLibrary();
@ -122,9 +121,18 @@ async function toggleLibrary() {
body: {
id: props.gameId,
},
failTitle: t("errors.library.add.title"),
});
await refreshLibrary();
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to add game to library",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
},
(_, c) => c(),
);
} finally {
isLibraryLoading.value = false;
}
@ -136,18 +144,24 @@ async function toggleCollection(id: string) {
if (!collection) return;
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
await $dropFetch(`/api/v1/collection/:id/entry`, {
await $dropFetch(`/api/v1/collection/${id}/entry`, {
method: index == -1 ? "POST" : "DELETE",
params: { id },
body: {
id: props.gameId,
},
failTitle: t("errors.library.add.title"),
});
await refreshCollection(id);
} finally {
/* empty */
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to add game to library",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
},
(_, c) => c(),
);
}
}
</script>

View File

@ -1,18 +1,10 @@
<template>
<div class="flex">
<a
:href="`/auth/oidc?redirect=${route.query.redirect ?? '/'}`"
href="/auth/oidc"
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
>
<i18n-t keypath="auth.signin.externalProvider" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
Sign in with external provider &rarr;
</a>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
</script>

View File

@ -4,7 +4,7 @@
<label
for="username"
class="block text-sm font-medium leading-6 text-zinc-300"
>{{ $t("auth.username") }}</label
>Username</label
>
<div class="mt-2">
<input
@ -23,7 +23,7 @@
<label
for="password"
class="block text-sm font-medium leading-6 text-zinc-300"
>{{ $t("auth.password") }}</label
>Password</label
>
<div class="mt-2">
<input
@ -50,23 +50,19 @@
<label
for="remember-me"
class="ml-3 block text-sm leading-6 text-zinc-400"
>{{ $t("auth.signin.rememberMe") }}</label
>Remember me</label
>
</div>
<div class="text-sm leading-6">
<NuxtLink
to="#"
class="font-semibold text-blue-600 hover:text-blue-500"
>{{ $t("auth.signin.forgot") }}</NuxtLink
<NuxtLink to="#" class="font-semibold text-blue-600 hover:text-blue-500"
>Forgot password?</NuxtLink
>
</div>
</div>
<div>
<LoadingButton class="w-full" :loading="loading">{{
$t("auth.signin.signin")
}}</LoadingButton>
<LoadingButton class="w-full" :loading="loading"> Sign in</LoadingButton>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
@ -86,7 +82,7 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { UserModel } from "~/prisma/client/models";
import type { User } from "~/prisma/client";
const username = ref("");
const password = ref("");
@ -97,7 +93,6 @@ const error = ref<string | undefined>();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
function signin_wrapper() {
loading.value = true;
@ -106,7 +101,7 @@ function signin_wrapper() {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => {
const message = response.statusMessage || t("errors.unknown");
const message = response.statusMessage || "An unknown error occurred";
error.value = message;
})
.finally(() => {
@ -124,6 +119,6 @@ async function signin() {
},
});
const user = useUser();
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
user.value = await $dropFetch<User | null>("/api/v1/user");
}
</script>

View File

@ -4,10 +4,9 @@
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>
@ -19,8 +18,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

@ -3,10 +3,11 @@
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.collection.create") }}
Create collection
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.collection.createDesc") }}
Collections can used to organise your games and find them more easily,
especially if you have a large library.
</p>
</div>
<div class="mt-2">
@ -14,7 +15,7 @@
<input
v-model="collectionName"
type="text"
:placeholder="$t('library.collection.namePlaceholder')"
placeholder="Collection name"
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
<button class="hidden" type="submit" />
@ -29,7 +30,7 @@
class="w-full sm:w-fit"
@click="() => createCollection()"
>
{{ $t("common.create") }}
Create
</LoadingButton>
<button
ref="cancelButtonRef"
@ -37,7 +38,7 @@
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") }}
Cancel
</button>
</template>
</ModalTemplate>
@ -46,7 +47,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { CollectionEntryModel, GameModel } from "~/prisma/client/models";
import type { CollectionEntry, Game } from "~/prisma/client";
import type { SerializeObject } from "nitropack";
const props = defineProps<{
@ -59,7 +60,6 @@ const emit = defineEmits<{
const open = defineModel<boolean>({ required: true });
const { t } = useI18n();
const collectionName = ref("");
const createCollectionLoading = ref(false);
const collections = await useCollections();
@ -79,7 +79,7 @@ async function createCollection() {
// Add the game if provided
if (props.gameId) {
const entry = await $dropFetch<
CollectionEntryModel & { game: SerializeObject<GameModel> }
CollectionEntry & { game: SerializeObject<Game> }
>(`/api/v1/collection/${response.id}/entry`, {
method: "POST",
body: { id: props.gameId },
@ -101,10 +101,8 @@ async function createCollection() {
createModal(
ModalType.Notification,
{
title: t("errors.library.collection.create.title"),
description: t("errors.library.collection.create.desc", [
err?.statusMessage ?? t("errors.unknown"),
]),
title: "Failed to create collection",
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
},
(_, c) => c(),
);

View File

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

View File

@ -6,13 +6,13 @@
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("news.delete") }}
Delete Article
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [article?.title]) }}
Are you sure you want to delete "{{ article?.title }}"?
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
This action cannot be undone.
</p>
</div>
</template>
@ -22,13 +22,13 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteArticle()"
>
{{ $t("delete") }}
Delete
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (article = undefined)"
>
{{ $t("cancel") }}
Cancel
</button>
</template>
</ModalTemplate>
@ -45,7 +45,6 @@ interface Article {
const article = defineModel<Article | undefined>();
const deleteLoading = ref(false);
const router = useRouter();
const { t } = useI18n();
const news = useNews();
if (!news.value) {
news.value = await fetchNews();
@ -69,11 +68,9 @@ async function deleteArticle() {
createModal(
ModalType.Notification,
{
title: t("errors.news.article.delete.title"),
description: t("errors.news.article.delete.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
title: "Failed to delete article",
// @ts-expect-error attempt to display statusMessage on error
description: `Drop couldn't delete this article: ${e?.statusMessage}`,
},
(_, c) => c(),
);

View File

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

View File

@ -1,15 +0,0 @@
<template>
<img ref="emojiEl" class="inline-block emoji" :src="url" :alt="emoji" />
</template>
<script lang="ts" setup>
import twemoji from "@discordapp/twemoji";
const props = defineProps<{
emoji: string;
}>();
const url = computed(() => {
return `/twemoji/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
});
</script>

View File

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

View File

@ -1,196 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game && unimportedVersions">
<div class="grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
>
<!-- version manager -->
<div>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
{{ $t("library.admin.versionPriority") }}
<!-- import games button -->
<NuxtLink
:href="canImport ? `/admin/library/${game.id}/import` : ''"
type="button"
:class="[
canImport
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
canImport
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</h3>
</div>
</div>
<div class="mt-4 text-center w-full text-sm text-zinc-600">
{{ $t("lowest") }}
</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
>
<template
#item="{ element: item }: { element: GameVersionModel }"
>
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon
class="cursor-move w-6 h-6 text-zinc-400 handle"
/>
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="grow w-full flex items-center justify-center">
<div class="flex flex-col items-center">
<ExclamationCircleIcon
class="h-12 w-12 text-red-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
{{ $t("library.admin.offlineTitle") }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ $t("library.admin.offline") }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { GameModel, GameVersionModel } from "~/prisma/client/models";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
// TODO implement UI for this page
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>
>;
if (!game.value)
throw createError({
statusCode: 500,
statusMessage: "Game not provided to editor component",
});
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: game.value.id,
versions: game.value.versions.map((e) => e.versionName),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.order.title"),
description: t("errors.version.order.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function deleteVersion(versionName: string) {
try {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: game.value.id,
versionName: versionName,
},
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName),
1,
);
hasDeleted.value = true;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.delete.title"),
description: t("errors.version.delete.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
</script>

View File

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

View File

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

View File

@ -1,29 +0,0 @@
<template>
<div>
<LanguageSelectorListbox />
<NuxtLink
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
to="https://translate.droposs.org/engage/drop/"
target="_blank"
>
<i18n-t
keypath="helpUsTranslate"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1 hover:underline"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
<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>

View File

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

View File

@ -1,7 +1,7 @@
<template>
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<Bars3Icon class="size-6" /> {{ $t("userHeader.links.library") }}
<Bars3Icon class="size-6" /> Library
</span>
<!-- Search bar -->
@ -13,7 +13,7 @@
name="search"
autocomplete="off"
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
:placeholder="$t('library.search')"
placeholder="Search library..."
/>
<MagnifyingGlassIcon
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
@ -31,11 +31,11 @@
<li v-for="game in filteredLibrary" :key="game.id" class="flex">
<NuxtLink
:to="`/library/game/${game.id}`"
class="flex flex-row items-center w-full p-2 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
class="flex flex-row items-center w-full p-1 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
>
<img
:src="useObject(game.mIconObjectId)"
class="h-5 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
:src="useObject(game.mCoverObjectId)"
class="h-9 w-9 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
alt=""
/>
<div class="min-w-0 flex-1 pl-2.5">
@ -53,7 +53,7 @@
v-else
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
>
{{ !!searchQuery ? $t("common.noResults") : $t("library.noGames") }}
{{ !!searchQuery ? "No results" : "No games in library" }}
</p>
</div>
</template>

View File

@ -1,27 +0,0 @@
<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

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

View File

@ -1,148 +0,0 @@
<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

@ -1,78 +0,0 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.tags.modal.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.tags.modal.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => createTag()">
<input
v-model="tagName"
type="text"
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="createTagLoading"
:disabled="!tagName"
class="w-full sm:w-fit"
@click="() => createTag()"
>
{{ $t("common.create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { GameTagModel } from "~/prisma/client/models";
const emit = defineEmits<{
created: [tag: GameTagModel];
}>();
const open = defineModel<boolean>({ required: true });
const tagName = ref("");
const createTagLoading = ref(false);
async function createTag() {
if (!tagName.value || createTagLoading.value) return;
createTagLoading.value = true;
// Create the collection
const tag = await $dropFetch("/api/v1/admin/tags", {
method: "POST",
body: { name: tagName.value },
failTitle: "Failed to create tag",
});
// Reset and emit
tagName.value = "";
open.value = false;
emit("created", tag);
createTagLoading.value = false;
}
</script>

View File

@ -1,267 +0,0 @@
<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

@ -1,75 +0,0 @@
<template>
<ModalTemplate :model-value="!!user">
<template #default>
<div>
<DialogTitle
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("users.admin.deleteUser", [user?.username]) }}
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [user?.username]) }}
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
</p>
</div>
</template>
<template #buttons>
<LoadingButton
:loading="deleteLoading"
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteUser()"
>
{{ $t("delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (user = undefined)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { DialogTitle } from "@headlessui/vue";
import type { UserModel } from "~/prisma/client/models";
const user = defineModel<UserModel | undefined>();
const deleteLoading = ref(false);
const router = useRouter();
const { t } = useI18n();
async function deleteUser() {
try {
if (!user.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/admin/users/${user.value.id}`, {
method: "DELETE",
});
user.value = undefined;
await fetchUsers();
router.push("/admin/users");
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.admin.user.delete.title"),
description: t("errors.admin.user.delete.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally {
deleteLoading.value = false;
}
}
</script>

View File

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

View File

@ -11,18 +11,18 @@
class="h-5 w-5 transition-transform duration-200"
:class="{ 'rotate-90': modalOpen }"
/>
<span>{{ $t("news.article.new") }}</span>
<span>New article</span>
</button>
<ModalTemplate v-model="modalOpen" size-class="sm:max-w-[80vw]">
<h3 class="text-lg font-semibold text-zinc-100 mb-4">
{{ $t("news.article.create") }}
Create New Article
</h3>
<form class="space-y-4" @submit.prevent="() => createArticle()">
<div>
<label for="title" class="block text-sm font-medium text-zinc-400">{{
$t("news.article.titles")
}}</label>
<label for="title" class="block text-sm font-medium text-zinc-400"
>Title</label
>
<input
id="title"
v-model="newArticle.title"
@ -34,10 +34,8 @@
</div>
<div>
<label
for="excerpt"
class="block text-sm font-medium text-zinc-400"
>{{ $t("news.article.shortDesc") }}</label
<label for="excerpt" class="block text-sm font-medium text-zinc-400"
>Short description</label
>
<input
id="excerpt"
@ -49,10 +47,8 @@
</div>
<div>
<label
for="content"
class="block text-sm font-medium text-zinc-400"
>{{ $t("news.article.content") }}</label
<label for="content" class="block text-sm font-medium text-zinc-400"
>Content (Markdown)</label
>
<div class="mt-1 flex flex-col gap-4">
<!-- Markdown shortcuts -->
@ -73,9 +69,7 @@
>
<!-- Editor -->
<div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">{{
$t("news.article.editor")
}}</span>
<span class="text-sm text-zinc-500 mb-2">Editor</span>
<textarea
id="content"
ref="contentEditor"
@ -88,9 +82,7 @@
<!-- Preview -->
<div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">{{
$t("news.article.preview")
}}</span>
<span class="text-sm text-zinc-500 mb-2">Preview</span>
<div
class="flex-1 p-4 rounded-md bg-zinc-900 border border-zinc-700 overflow-y-auto"
>
@ -103,7 +95,8 @@
</div>
</div>
<p class="mt-2 text-sm text-zinc-500">
{{ $t("news.article.editorGuide") }}
Use the shortcuts above or write Markdown directly. Supports
**bold**, *italic*, [links](url), and more.
</p>
</div>
@ -121,7 +114,7 @@
/>
<span
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>{{ $t("news.article.uploadCover") }}</span
>Upload cover image</span
>
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
{{ currentFile.name }}
@ -132,14 +125,14 @@
accept="image/*"
class="hidden"
type="file"
@change="(e: Event) => (file = (e.target as any)?.files)"
@change="(e) => (file = (e.target as any)?.files)"
/>
</div>
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2">{{
$t("common.tags")
}}</label>
<label class="block text-sm font-medium text-zinc-400 mb-2"
>Tags</label
>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="tag in newArticle.tags"
@ -160,7 +153,7 @@
<input
v-model="newTagInput"
type="text"
:placeholder="$t('news.article.tagPlaceholder')"
placeholder="Add a tag..."
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
@keydown.enter.prevent="addTag"
/>
@ -169,7 +162,7 @@
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
@click="addTag"
>
{{ $t("news.article.add") }}
Add
</button>
</div>
</div>
@ -193,16 +186,15 @@
<LoadingButton
:loading="loading"
class="bg-blue-600 text-white hover:bg-blue-500"
:disabled="!isValidArticle"
@click="() => createArticle()"
>
{{ $t("news.article.submit") }}
Submit
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (modalOpen = !modalOpen)"
>
{{ $t("cancel") }}
Cancel
</button>
</template>
</ModalTemplate>
@ -236,13 +228,6 @@ const newArticle = ref({
tags: [] as string[],
});
const isValidArticle = computed(
() =>
newArticle.value.title &&
newArticle.value.description &&
newArticle.value.content,
);
const markdownPreview = computed(() => {
// TODO: maybe?? add https://github.com/cure53/DOMPurify
// micromark says its safe, but this is straight html we are injecting
@ -251,49 +236,18 @@ const markdownPreview = computed(() => {
const file = ref<FileList | undefined>();
const currentFile = computed(() => file.value?.item(0));
const { t } = useI18n();
const error = ref<string | undefined>();
const contentEditor = ref<HTMLTextAreaElement>();
const markdownShortcuts = [
{
label: t("editor.bold"),
prefix: "**",
suffix: "**",
placeholder: t("editor.boldPlaceholder"),
},
{
label: t("editor.italic"),
prefix: "_",
suffix: "_",
placeholder: t("editor.italicPlaceholder"),
},
{
label: t("editor.link"),
prefix: "[",
suffix: "](url)",
placeholder: t("editor.linkPlaceholder"),
},
{
label: t("editor.code"),
prefix: "`",
suffix: "`",
placeholder: t("editor.codePlaceholder"),
},
{
label: t("editor.listItem"),
prefix: "- ",
suffix: "",
placeholder: t("editor.listItemPlaceholder"),
},
{
label: t("editor.heading"),
prefix: "## ",
suffix: "",
placeholder: t("editor.headingPlaceholder"),
},
{ label: "Bold", prefix: "**", suffix: "**", placeholder: "bold text" },
{ label: "Italic", prefix: "_", suffix: "_", placeholder: "italic text" },
{ label: "Link", prefix: "[", suffix: "](url)", placeholder: "link text" },
{ label: "Code", prefix: "`", suffix: "`", placeholder: "code" },
{ label: "List Item", prefix: "- ", suffix: "", placeholder: "list item" },
{ label: "Heading", prefix: "## ", suffix: "", placeholder: "heading" },
];
function handleContentKeydown(e: KeyboardEvent) {
@ -415,7 +369,7 @@ async function createArticle() {
modalOpen.value = false;
} catch (e) {
// @ts-expect-error attempt to get statusMessage on error
error.value = e?.statusMessage ?? t("errors.unknown");
error.value = e?.statusMessage ?? "An unknown error occured.";
} finally {
loading.value = false;
}

View File

@ -6,7 +6,7 @@
<!-- Search and filters -->
<div class="space-y-6">
<div>
<label for="search" class="sr-only">{{ $t("news.search") }}</label>
<label for="search" class="sr-only">Search articles</label>
<div class="relative">
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
@ -21,35 +21,31 @@
v-model="searchQuery"
type="text"
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
:placeholder="$t('news.searchPlaceholder')"
placeholder="Search articles..."
/>
</div>
</div>
<div class="pt-2">
<label
for="date"
class="block text-sm font-medium text-zinc-400 mb-2"
>{{ $t("common.date") }}</label
<label for="date" class="block text-sm font-medium text-zinc-400 mb-2"
>Date</label
>
<select
id="date"
v-model="dateFilter"
class="mt-1 block w-full rounded-md border-0 bg-zinc-800 py-2 pl-3 pr-10 text-zinc-100 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
>
<option value="all">{{ $t("news.filter.all") }}</option>
<option value="today">{{ $t("common.today") }}</option>
<option value="week">{{ $t("news.filter.week") }}</option>
<option value="month">{{ $t("news.filter.month") }}</option>
<option value="year">{{ $t("news.filter.year") }}</option>
<option value="all">All time</option>
<option value="today">Today</option>
<option value="week">This week</option>
<option value="month">This month</option>
<option value="year">This year</option>
</select>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2">
{{ $t("common.tags") }}
</label>
<label class="block text-sm font-medium text-zinc-400 mb-2">Tags</label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in availableTags"
@ -91,7 +87,9 @@
:src="useObject(article.imageObjectId)"
class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110"
/>
<div class="absolute inset-0 bg-zinc-900/50" />
<div
class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-800 transition-all duration-200"
/>
</div>
<h3 class="relative text-sm font-medium text-zinc-100">
@ -104,9 +102,9 @@
<div
class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"
>
<time :datetime="article.publishedAt">
{{ $d(new Date(article.publishedAt), "short") }}
</time>
<time :datetime="article.publishedAt">{{
formatDate(article.publishedAt)
}}</time>
</div>
</div>
</NuxtLink>
@ -148,9 +146,20 @@ const toggleTag = (tag: string) => {
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const formatExcerpt = (excerpt: string) => {
// Convert markdown to HTML, micromark is safe
return micromark(excerpt);
// TODO: same as one in NewsArticleCreateButton
// Convert markdown to HTML
const html = micromark(excerpt);
// Strip HTML tags using regex
return html.replace(/<[^>]*>/g, "");
};
const filteredArticles = computed(() => {

View File

@ -33,7 +33,7 @@
class="inline-flex rounded-md text-zinc-400 hover:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="() => deleteMe()"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<span class="sr-only">Close</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
@ -44,16 +44,13 @@
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/solid";
import type { NotificationModel } from "~/prisma/client/models";
import type { Notification } from "~/prisma/client";
const props = defineProps<{ notification: NotificationModel }>();
const props = defineProps<{ notification: Notification }>();
async function deleteMe() {
await $dropFetch(`/api/v1/notifications/:id`, {
await $dropFetch(`/api/v1/notifications/${props.notification.id}`, {
method: "DELETE",
params: {
id: props.notification.id,
},
});
const notifications = useNotifications();
const indexOfMe = notifications.value.findIndex(

View File

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

View File

@ -15,7 +15,7 @@
/>
<span class="ml-3 block truncate">{{ model }}</span>
</span>
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
<span v-else>Please select a platform...</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2"
>

View File

@ -1,32 +0,0 @@
<template>
<div class="relative inline-block group/relative-time">
<!-- Visible relative time -->
<time :datetime="isoDate" class="text-sm text-muted-foreground">
{{ DateTime.fromJSDate(date).toRelative({ locale: $i18n.locale }) }}
</time>
<!-- Custom tooltip that shows on hover -->
<div
role="tooltip"
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 rounded bg-zinc-900 text-white text-xs whitespace-nowrap shadow z-10 opacity-0 group-hover/relative-time:opacity-100 transition-opacity pointer-events-none"
aria-hidden="true"
>
{{ $d(date, "long") }}
</div>
</div>
</template>
<script setup lang="ts">
import { DateTime } from "luxon";
import { computed } from "vue";
const props = defineProps<{
date: string | Date;
}>();
const date = computed(() =>
typeof props.date === "string" ? new Date(props.date) : props.date,
);
const isoDate = computed(() => date.value.toISOString());
</script>

View File

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

View File

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

View File

@ -1,27 +0,0 @@
<template>
<div>
<label
for="path"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.sources.fsPath") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.fsPathDesc") }}
</p>
<div class="mt-2">
<input
id="path"
v-model="model!.baseDir"
name="path"
type="text"
autocomplete="path"
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</template>
<script setup lang="ts">
const model = defineModel<{ baseDir: string }>();
</script>

View File

@ -1,27 +0,0 @@
<template>
<div>
<label
for="path"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.sources.fsPath") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.fsPathDesc") }}
</p>
<div class="mt-2">
<input
id="path"
v-model="model!.baseDir"
name="path"
type="text"
autocomplete="path"
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</template>
<script setup lang="ts">
const model = defineModel<{ baseDir: string }>();
</script>

View File

@ -1,518 +0,0 @@
<i18n>
{
"en": {
"↓": "↓",
"↑": "↑"
}
}
</i18n>
<template>
<div>
<div>
<!-- Mobile filter dialog -->
<TransitionRoot as="template" :show="mobileFiltersOpen">
<Dialog
class="relative z-100 lg:hidden"
@close="mobileFiltersOpen = false"
>
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/25" />
</TransitionChild>
<div class="fixed inset-0 z-40 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="translate-x-full"
>
<DialogPanel
class="relative ml-auto flex size-full max-w-sm flex-col overflow-y-auto bg-zinc-900 pt-4 pb-6 shadow-xl"
>
<div class="flex items-center justify-between px-4">
<h2 class="text-lg font-medium text-zinc-100">
{{ $t("store.view.srFilters") }}
</h2>
<button
type="button"
class="relative -mr-2 flex size-10 items-center justify-center rounded-md bg-zinc-900 p-2 text-zinc-500 hover:bg-zinc-800 focus:ring-2 focus:ring-blue-500 focus:outline-hidden"
@click="mobileFiltersOpen = false"
>
<span class="absolute -inset-0.5" />
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-6" aria-hidden="true" />
</button>
</div>
<!-- Filters -->
<form class="mt-4 border-t border-zinc-700">
<Disclosure
v-for="section in options"
v-slot="{ open }"
:key="section.param"
as="div"
class="border-t border-zinc-700 px-4 py-6"
>
<h3 class="-mx-2 -my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 px-2 py-3 text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div
v-if="section.options.length <= 10"
class="gap-3 grid grid-cols-2"
>
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${option}`"
v-model="
(optionValues[section.param] as any)[
option.param
]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-900 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@update:value="
() =>
(optionValues[section.param] = option.param)
"
/>
</div>
</div>
<label
:for="`filter-mobile-${section.param}-${optionIdx}`"
class="min-w-0 flex-1 text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<main class="mx-auto px-4 sm:px-6 lg:px-8">
<div
class="flex items-baseline justify-between border-b border-zinc-700 py-6"
>
<div />
<div class="flex items-center">
<Menu as="div" class="relative inline-block text-left">
<div>
<MenuButton
class="group inline-flex justify-center text-sm font-medium text-zinc-400 hover:text-zinc-100"
>
{{ $t("store.view.sort") }}
<ChevronDownIcon
class="-mr-1 ml-1 size-5 shrink-0 text-gray-400 group-hover:text-zinc-100"
aria-hidden="true"
/>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-zinc-950 shadow-2xl ring-1 ring-white/5 focus:outline-hidden"
>
<div class="py-1">
<MenuItem
v-for="option in sorts"
:key="option.param"
v-slot="{ active }"
>
<button
:class="[
currentSort == option.param
? 'font-medium text-zinc-100'
: 'text-zinc-400',
active ? 'bg-zinc-900 outline-hidden' : '',
'w-full text-left block px-4 py-2 text-sm',
]"
@click.prevent="handleSortClick(option, $event)"
>
{{ option.name }}
<span v-if="currentSort === option.param">
{{ sortOrder === "asc" ? $t("↑") : $t("↓") }}
</span>
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
<button
v-if="false"
type="button"
class="-m-2 ml-5 p-2 text-zinc-500 hover:text-zinc-400 sm:ml-7"
>
<span class="sr-only">{{ $t("store.view.srViewGrid") }}</span>
<Squares2X2Icon class="size-5" aria-hidden="true" />
</button>
<button
type="button"
:class="[
'-m-2 ml-4 p-2 sm:ml-6 lg:hidden',
filterQuery
? 'text-zinc-100 hover:text-zinc-200'
: 'text-zinc-500 hover:text-zinc-400',
]"
@click="mobileFiltersOpen = true"
>
<span class="sr-only"> {{ $t("store.view.srFilters") }} </span>
<FunnelIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
<section aria-labelledby="games-heading" class="pt-6 pb-24">
<h2 id="games-heading" class="sr-only">
{{ $t("store.view.srGames") }}
</h2>
<div class="grid grid-cols-1 gap-x-8 gap-y-10 lg:grid-cols-5">
<!-- Filters -->
<form class="hidden lg:block">
<Disclosure
v-for="section in options"
:key="section.param"
v-slot="{ open }"
as="div"
class="border-b border-zinc-700 py-6"
>
<h3 class="-my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 py-3 text-sm text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div v-if="section.options.length <= 10" class="space-y-4">
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${optionIdx}`"
v-model="
(optionValues[section.param] as any)[option.param]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}-${optionIdx}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="radio"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@input="optionValues[section.param] = option.param"
/>
</div>
</div>
<label
:for="`filter-${section.param}-${optionIdx}`"
class="text-sm text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
<!-- Product grid -->
<div
v-if="games?.length ?? 0 > 0"
ref="product-grid"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4"
>
<!-- Your content -->
<GamePanel
v-for="game in games"
:key="game.id"
:game="game"
:href="`/store/${game.id}`"
:show-title-description="showGamePanelTextDecoration"
/>
<div
v-if="loading"
class="absolute inset-0 bg-zinc-900/40 flex items-center justify-center"
>
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
</div>
<div v-else class="flex lg:col-span-4 items-start justify-center">
<span class="uppercase text-zinc-700 font-display font-bold">{{
$t("common.noResults")
}}</span>
</div>
</div>
</section>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import {
Dialog,
DialogPanel,
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { XMarkIcon } from "@heroicons/vue/24/outline";
import {
ChevronDownIcon,
FunnelIcon,
MinusIcon,
PlusIcon,
Squares2X2Icon,
} from "@heroicons/vue/20/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel, GameTagModel } from "~/prisma/client/models";
import MultiItemSelector from "./MultiItemSelector.vue";
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const mobileFiltersOpen = ref(false);
const props = defineProps<{
params?: { [key: string]: string };
extraOptions?: Array<StoreFilterOption>;
prefilled?: {
[key: string]: { [key: string]: string | { [key: string]: boolean } };
};
}>();
const tags =
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
const sorts: Array<StoreSortOption> = [
{
name: "Default",
param: "default",
},
{
name: "Newest",
param: "newest",
},
{
name: "Recently Added",
param: "recent",
},
{
name: "Name",
param: "name",
},
];
const currentSort = ref(sorts[0].param);
const sortOrder = ref<"asc" | "desc">("desc");
const options: Array<StoreFilterOption> = [
...(tags.length > 0
? [
{
name: "Tags",
param: "tags",
multiple: true,
options: tags.map((e) => ({ name: e.name, param: e.id })),
},
]
: []),
{
name: "Platform",
param: "platform",
multiple: true,
options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })),
},
...(props.extraOptions ?? []),
];
const optionValues = ref<{
[key: string]: string | undefined | { [key: string]: boolean | undefined };
}>(
Object.fromEntries(
options.map((v) => [v.param, v.multiple ? {} : undefined]),
),
);
Object.assign(optionValues.value, props.prefilled);
const filterQuery = computed(() => {
const query = Object.entries(optionValues.value)
.filter(
([_, v]) =>
v &&
(typeof v !== "object" || Object.values(v).filter((e) => e).length > 0),
)
.map(([n, v]) => {
if (typeof v === "string") return [`${n}=${v}`];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const enabledOptions = Object.entries(v as any).filter(([_, e]) => e);
return `${n}=${enabledOptions.map(([k, _]) => k).join(",")}`;
})
.join("&");
const extraFilters = props.params
? Object.entries(props.params)
.map(([k, v]) => `${k}=${v}`)
.join("&")
: props.params;
return `${query}${extraFilters ? (query ? "&" : "") + extraFilters : ""}`;
});
const games = ref<Array<SerializeObject<GameModel>>>();
const loading = ref(false);
const productGrid = useTemplateRef<HTMLElement>("product-grid");
const { reset } = useInfiniteScroll(
productGrid,
async () => await updateGames(filterQuery.value, false),
{
distance: 10,
canLoadMore: () => {
return canLoadMore.value;
},
},
);
const canLoadMore = ref(true);
async function updateGames(query: string, resetGames: boolean) {
loading.value = true;
games.value ??= [];
const newValues = await $dropFetch<{
results: Array<SerializeObject<GameModel>>;
count: number;
}>(
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}&order=${sortOrder.value}${query ? "&" + query : ""}`,
);
if (resetGames) {
games.value = newValues.results;
if (import.meta.client) await reset();
} else {
games.value.push(...newValues.results);
}
canLoadMore.value = games.value.length < newValues.count;
loading.value = false;
}
watch(filterQuery, (newUrl) => {
updateGames(newUrl, true);
});
watch(currentSort, (_) => {
updateGames(filterQuery.value, true);
});
watch(sortOrder, (_) => {
updateGames(filterQuery.value, true);
});
await updateGames(filterQuery.value, true);
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

@ -1,55 +0,0 @@
<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

@ -49,38 +49,31 @@
/>
<span
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>{{ $t("uploadFile") }}</span
>Upload file</span
>
<div v-if="currentFileList">
<p
v-for="currentFile in currentFileList"
:key="currentFile"
class="mt-1 text-[10px] text-zinc-500 whitespace-nowrap"
>
{{ currentFile }}
</p>
</div>
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
{{ currentFile.name }}
</p>
</label>
<input
id="file-upload"
:accept="props.accept"
class="hidden"
type="file"
:multiple="props.multiple"
@change="(e: Event) => (file = (e.target as any)?.files)"
@change="(e) => (file = (e.target as any)?.files)"
/>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<LoadingButton
:disabled="currentFiles == undefined"
:disabled="currentFile == undefined"
type="button"
:loading="uploadLoading"
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => uploadFile_wrapper()"
>
{{ $t("upload") }}
Upload
</LoadingButton>
<button
ref="cancelButtonRef"
@ -88,7 +81,7 @@
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="open = false"
>
{{ $t("cancel") }}
Cancel
</button>
</div>
<div v-if="uploadError" class="mt-3 rounded-md bg-red-600/10 p-4">
@ -129,21 +122,11 @@ const open = defineModel<boolean>({
required: true,
});
const { t } = useI18n();
const file = ref<FileList | undefined>();
const currentFiles = computed(() => file.value);
const currentFileList = computed(() => {
if (!currentFiles.value) return undefined;
const list = [];
for (const file of currentFiles.value) {
list.push(file.name);
}
return list;
});
const currentFile = computed(() => file.value?.item(0));
const props = defineProps<{
endpoint: string;
accept: string;
multiple?: boolean;
options?: { [key: string]: string };
}>();
const emit = defineEmits(["upload"]);
@ -151,12 +134,10 @@ const emit = defineEmits(["upload"]);
const uploadLoading = ref(false);
const uploadError = ref<string | undefined>();
async function uploadFile() {
if (!currentFiles.value) return;
if (!currentFile.value) return;
const form = new FormData();
for (const file of currentFiles.value) {
form.append(file.name, file);
}
form.append("file", currentFile.value);
if (props.options) {
for (const [key, value] of Object.entries(props.options)) {
@ -177,7 +158,7 @@ function uploadFile_wrapper() {
uploadLoading.value = true;
uploadFile()
.catch((error) => {
uploadError.value = error.statusMessage ?? t("errors.unknown");
uploadError.value = error.statusMessage ?? "An unknown error occurred.";
})
.finally(() => {
uploadLoading.value = false;

View File

@ -1,17 +1,14 @@
<template>
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
<h2 id="footer-heading" class="sr-only">{{ $t("footer.footer") }}</h2>
<h2 id="footer-heading" class="sr-only">Footer</h2>
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
<!-- Drop Info -->
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div class="space-y-8">
<DropWordmark class="h-10" />
<p class="text-sm leading-6 text-zinc-300">
{{ $t("drop.desc") }}
An open-source game distribution platform built for speed,
flexibility and beauty.
</p>
<LanguageSelector />
<div class="flex space-x-6">
<NuxtLink
v-for="item in navigation.social"
@ -25,14 +22,10 @@
</NuxtLink>
</div>
</div>
<!-- Foot links -->
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("footer.games") }}
</h3>
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.games" :key="item.name">
<NuxtLink
@ -45,7 +38,7 @@
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("userHeader.links.community") }}
Community
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.community" :key="item.name">
@ -61,7 +54,7 @@
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("footer.documentation") }}
Documentation
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.documentation" :key="item.name">
@ -74,9 +67,7 @@
</ul>
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("footer.about") }}
</h3>
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.about" :key="item.name">
<NuxtLink
@ -89,22 +80,6 @@
</div>
</div>
</div>
<div class="flex items-center justify-center xl:col-span-3 mt-8">
<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="span" scope="global">
<template #version>
<span>{{ versionInfo.version }}</span>
</template>
<template #gitRef>
<span>{{ versionInfo.gitRef }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
</div>
</footer>
@ -113,49 +88,45 @@
<script setup lang="ts">
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
const { t } = useI18n();
const versionInfo = await $dropFetch("/api/v1");
const navigation = computed(() => ({
const navigation = {
games: [
{ name: t("store.recentlyAdded"), href: "#" },
{ name: t("store.recentlyReleased"), href: "#" },
{ name: t("footer.topSellers"), href: "#" },
{ name: t("footer.findGame"), href: "#" },
{ name: "Newly Added", href: "#" },
{ name: "New Releases", href: "#" },
{ name: "Top Sellers", href: "#" },
{ name: "Find a Game", href: "#" },
],
community: [
{ name: t("common.friends"), href: "#" },
{ name: t("common.groups"), href: "#" },
{ name: t("common.servers"), href: "#" },
{ name: "Friends", href: "#" },
{ name: "Groups", href: "#" },
{ name: "Servers", href: "#" },
],
documentation: [
// TODO: public API docs
// { name: t("footer.api"), href: "https://api.droposs.org/" },
{ name: "API", href: "https://api.droposs.org/" },
{
name: t("footer.docs.server"),
href: "https://docs.droposs.org/docs/guides/quickstart",
name: "Server Docs",
href: "https://wiki.droposs.org/guides/quickstart.html",
},
{
name: t("footer.docs.client"),
href: "https://docs.droposs.org/docs/guides/client",
name: "Client Docs",
href: "https://wiki.droposs.org/guides/client.html",
},
],
about: [
{ name: t("footer.aboutDrop"), href: "https://droposs.org/" },
{ name: t("footer.comparison"), href: "https://droposs.org/comparison" },
{ name: "About Drop", href: "https://droposs.org/" },
{ name: "Features", href: "https://droposs.org/features" },
{ name: "FAQ", href: "https://droposs.org/faq" },
],
social: [
{
name: t("footer.social.github"),
name: "GitHub",
href: "https://github.com/Drop-OSS",
icon: IconsGithubLogo,
},
{
name: t("footer.social.discord"),
name: "Discord",
href: "https://discord.gg/NHx46XKJWA",
icon: IconsDiscordLogo,
},
],
}));
};
</script>

View File

@ -76,7 +76,7 @@
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
<span class="sr-only">Open sidebar</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
@ -125,9 +125,7 @@
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">{{
$t("userHeader.closeSidebar")
}}</span>
<span class="sr-only">Close sidebar</span>
<XMarkIcon class="h-6 w-6 text-zinc-400" aria-hidden="true" />
</button>
</div>
@ -174,9 +172,6 @@
<BellIcon class="h-5" />
</UserHeaderWidget>
</li>
<li class="w-full">
<UserHeaderWidget class="w-full" />
</li>
</div>
</nav>
</div>
@ -203,33 +198,32 @@ import { Bars3Icon } from "@heroicons/vue/24/outline";
import { XMarkIcon } from "@heroicons/vue/24/solid";
const router = useRouter();
const { t } = useI18n();
const homepageURL = "/store";
const navigation: Ref<Array<NavigationItem>> = computed(() => [
const navigation: Array<NavigationItem> = [
{
prefix: "/store",
route: "/store",
label: t("store.title"),
label: "Store",
},
{
prefix: "/library",
route: "/library",
label: t("userHeader.links.library"),
label: "Library",
},
{
prefix: "/community",
route: "/community",
label: t("userHeader.links.community"),
label: "Community",
},
{
prefix: "/news",
route: "/news",
label: t("userHeader.links.news"),
label: "News",
},
]);
];
const currentPageIndex = useCurrentNavigationIndex(navigation.value);
const currentPageIndex = useCurrentNavigationIndex(navigation);
const notifications = useNotifications();
const unreadNotifications = computed(() =>

View File

@ -6,7 +6,7 @@
>
<div class="ml-4 mt-2">
<h3 class="text-base font-semibold text-zinc-100 text-sm">
{{ $t("account.notifications.unread") }}
Unread notifications
</h3>
</div>
<div class="ml-4 mt-2 shrink-0">
@ -15,15 +15,7 @@
type="button"
class="text-sm text-zinc-400"
>
<i18n-t
keypath="account.notifications.all"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
View all &rarr;
</NuxtLink>
</div>
</div>
@ -40,13 +32,13 @@
v-if="props.notifications.length == 0"
class="text-sm text-zinc-400 p-3 text-center w-full"
>
{{ $t("account.notifications.none") }}
No notifications
</div>
</PanelWidget>
</template>
<script setup lang="ts">
import type { NotificationModel } from "~/prisma/client/models";
import type { Notification } from "~/prisma/client";
const props = defineProps<{ notifications: Array<NotificationModel> }>();
const props = defineProps<{ notifications: Array<Notification> }>();
</script>

View File

@ -46,30 +46,29 @@
hydrate-on-visible
as="div"
>
<NuxtLink
:to="nav.route"
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
<button
:href="nav.route"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'w-full text-left transition block px-4 py-2 text-sm',
]"
@click="close"
@click="() => navigateTo(nav.route, close)"
>
{{ nav.label }}
</NuxtLink>
</button>
</MenuItem>
<MenuItem v-slot="{ active, close }" hydrate-on-visible as="div">
<NuxtLink
to="/auth/signout"
<MenuItem v-slot="{ active }" hydrate-on-visible as="div">
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
<a
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'w-full text-left transition block px-4 py-2 text-sm',
]"
:data-comment="'external=true is required because we implemented the signout as a route on the server for performance'"
:external="true"
@click="close"
href="/auth/signout"
>
{{ $t("auth.signout") }}
</NuxtLink>
Signout
</a>
</MenuItem>
</div>
</PanelWidget>
@ -86,25 +85,18 @@ import type { NavigationItem } from "~/composables/types";
const user = useUser();
const navigation = computed<NavigationItem[]>(() =>
[
user.value?.admin
? {
label: $t("userHeader.profile.admin"),
route: "/admin",
prefix: "",
}
: undefined,
{
label: $t("userHeader.profile.settings"),
route: "/account",
prefix: "",
},
{
label: "Authorize client",
route: "/client/code",
prefix: "",
},
].filter((e) => e !== undefined),
);
const navigation: NavigationItem[] = [
user.value?.admin
? {
label: "Admin Dashboard",
route: "/admin",
prefix: "",
}
: undefined,
{
label: "Account settings",
route: "/account",
prefix: "",
},
].filter((e) => e !== undefined);
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +0,0 @@
import type { SerializeObject } from "nitropack";
import type { UserModel } from "~/prisma/client/models";
import type { AuthMec } from "~/prisma/client/enums";
export const useUsers = () =>
useState<
| Array<
SerializeObject<
UserModel & {
authMecs?: Array<{ id: string; mec: AuthMec }>;
}
>
>
| undefined
>("users", () => undefined);
export const fetchUsers = async () => {
const users = useUsers();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore forget why this ignor exists
const newValue: User[] = await $dropFetch("/api/v1/admin/users");
users.value = newValue;
return newValue;
};

View File

@ -1,7 +1,6 @@
services:
postgres:
# using alpine image to reduce image size
image: postgres:alpine
image: postgres:14-alpine
ports:
- 5432:5432
healthcheck:
@ -17,10 +16,7 @@ services:
- POSTGRES_USER=drop
- POSTGRES_DB=drop
drop:
image: ghcr.io/drop-oss/drop:nightly
stdin_open: true
tty: true
init: true
image: registry.deepcore.dev/drop-oss/drop/main:latest
depends_on:
postgres:
condition: service_healthy
@ -28,6 +24,11 @@ services:
- 3000:3000
volumes:
- ./library:/library
- ./data:/data
- ./certs:/certs
- ./objects:/objects
environment:
- DATABASE_URL=postgres://drop:drop@postgres:5432/drop
- FS_BACKEND_PATH=/objects
- CLIENT_CERTIFICATES=/certs
- LIBRARY=/library
- GIANT_BOMB_API_KEY=REPLACE_WITH_YOUR_KEY

View File

@ -8,12 +8,13 @@ const props = defineProps({
},
});
const { t } = useI18n();
const route = useRoute();
const user = useUser();
const statusCode = props.error?.statusCode;
const message =
props.error?.message || props.error?.statusMessage || t("errors.unknown");
props.error?.statusMessage ||
props.error?.message ||
"An unknown error occurred.";
const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
async function signIn() {
@ -21,18 +22,13 @@ async function signIn() {
redirect: `/auth/signin?redirect=${encodeURIComponent(route.fullPath)}`,
});
}
switch (statusCode) {
case 401:
case 403:
await signIn();
}
useHead({
title: t("errors.pageTitle", [statusCode ?? message]),
title: `${statusCode ?? message} | Drop`,
});
if (import.meta.client) {
console.warn(props.error);
console.log(props.error);
}
</script>
@ -55,7 +51,7 @@ if (import.meta.client) {
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
{{ $t("errors.ohNo") }}
Oh no!
</h1>
<p
v-if="message"
@ -64,32 +60,24 @@ if (import.meta.client) {
{{ message }}
</p>
<p class="mt-6 text-base leading-7 text-zinc-400">
{{ $t("errors.occurred") }}
An error occurred while responding to your request. If you believe
this to be a bug, please report it. Try signing in and see if it
resolves the issue.
</p>
<!-- <p>{{ error. }}</p> -->
<div class="mt-10">
<!-- full app reload to fix errors -->
<NuxtLink
v-if="user && !showSignIn"
to="/"
class="text-sm font-semibold leading-7 text-blue-600"
><span aria-hidden="true">&larr;</span> Back to home</NuxtLink
>
<i18n-t keypath="errors.backHome" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
v-else
class="text-sm font-semibold leading-7 text-blue-600"
@click="signIn"
>
<i18n-t keypath="errors.signIn" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
Sign in <span aria-hidden="true">&rarr;</span>
</button>
</div>
</div>
@ -99,7 +87,7 @@ if (import.meta.client) {
<nav
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
>
<NuxtLink href="/docs">{{ $t("footer.documentation") }}</NuxtLink>
<NuxtLink href="/docs">Documentation</NuxtLink>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
@ -108,7 +96,7 @@ if (import.meta.client) {
<circle cx="1" cy="1" r="1" />
</svg>
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
{{ $t("errors.support") }}
Support Discord
</NuxtLink>
</nav>
</div>

View File

@ -1,33 +1,5 @@
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
export default withNuxt([
eslintConfigPrettier,
// vue-i18n plugin
...vueI18n.configs.recommended,
{
rules: {
// Optional.
"@intlify/vue-i18n/no-dynamic-keys": "error",
"@intlify/vue-i18n/no-unused-keys": [
"off",
{
extensions: [".js", ".vue", ".ts"],
},
],
"@intlify/vue-i18n/no-missing-keys": "error",
},
settings: {
"vue-i18n": {
localeDir: "./i18n/locales/*.{json,json5,ts,js}", // extension is glob formatting!
// Specify the version of `vue-i18n` you are using.
// If not specified, the message will be parsed twice.
messageSyntaxVersion: "^11.0.0",
},
},
},
]);
export default withNuxt([eslintConfigPrettier]);

View File

@ -1,35 +0,0 @@
export default defineI18nConfig(() => {
const defaultDateTimeFormat = {
short: {
year: "numeric",
month: "long",
day: "numeric",
},
long: {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "numeric",
},
} as const;
return {
// https://i18n.nuxtjs.org/docs/guide/locale-fallback
fallbackLocale: "en-us",
// https://vue-i18n.intlify.dev/guide/essentials/datetime.html
datetimeFormats: {
"en-us": defaultDateTimeFormat,
"en-gb": defaultDateTimeFormat,
"en-au": defaultDateTimeFormat,
"en-pirate": defaultDateTimeFormat,
fr: defaultDateTimeFormat,
de: defaultDateTimeFormat,
it: defaultDateTimeFormat,
es: defaultDateTimeFormat,
zh: defaultDateTimeFormat,
"zh-tw": defaultDateTimeFormat,
},
};
});

View File

@ -1,25 +0,0 @@
// https://i18n.nuxtjs.org/docs/guide/server-side-translations
// Detect based on query, cookie, header
export default defineI18nLocaleDetector((event, config) => {
// try to get locale from query
const query = tryQueryLocale(event, { lang: "" }); // disable locale default value with `lang` option
if (query) {
return query.toString();
}
// try to get locale from cookie
const cookie = tryCookieLocale(event, { lang: "", name: "i18n_redirected" }); // disable locale default value with `lang` option
if (cookie) {
return cookie.toString();
}
// try to get locale from header (`accept-header`)
const header = tryHeaderLocale(event, { lang: "" }); // disable locale default value with `lang` option
if (header) {
return header.toString();
}
// If the locale cannot be resolved up to this point, it is resolved with the value `defaultLocale` of the locale config passed to the function
return config.defaultLocale;
});

View File

@ -1,673 +0,0 @@
{
"account": {
"devices": {
"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": "Alles anzeigen {arrow}",
"desc": "Anzeigen und Verwalten deiner Benachrichtigung.",
"markAllAsRead": "Markiere alle als gelesen",
"markAsRead": "Als gelesen Markieren",
"none": "Keine Benachrichtigungen",
"notifications": "Benachrichtigungen",
"title": "Benachrichtigungen",
"unread": "Ungelesene Benachrichtigungen"
},
"settings": "Einstellungen",
"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": "Admin Dashboard - Drop",
"adminTitleTemplate": "{0} - Admin - Drop",
"auth": {
"callback": {
"authClient": "Client autorisieren?",
"authorize": "Autorisieren",
"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:",
"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 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",
"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? Bitte den Admin, eines für dich zu erstellen.",
"or": "ODER",
"pageTitle": "Bei Drop anmelden",
"rememberMe": "Erinnere mich",
"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 Admin.",
"invalidInvite": "Ungültige oder abgelaufene Einladung",
"invalidPassState": "Ungültiger Passwortzustand. Bitte kontaktiere einen Server Admin.",
"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",
"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}",
"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",
"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",
"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": "Admin",
"metadata": "Metadaten",
"settings": {
"store": "Store",
"title": "Einstellungen",
"tokens": "API-Schlüssel"
},
"tasks": "Aufgaben",
"users": "Benutzer"
},
"back": "Zurück",
"openSidebar": "Öffne Seitenleiste"
},
"helpUsTranslate": "Hilf uns Drop zu übersetzen {arrow}",
"highest": "Höchste",
"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.",
"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?",
"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",
"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",
"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",
"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",
"noGames": "Keine Spiele importiert",
"offline": "Drop konnte auf dieses Spiel nicht zugreifen.",
"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",
"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"
},
"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…",
"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",
"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"
},
"none": "Keine Artikel",
"notFound": "Artikel nicht gefunden",
"search": "Suche Artikel",
"searchPlaceholder": "Suche Artikel…",
"subheader": "Bleibe auf dem Laufenden über die neuesten Updates und Ankündigungen.",
"title": "Neueste Neuigkeiten"
},
"options": "Einstellungen",
"security": "Sicherheit",
"selectLanguage": "Sprache auswählen",
"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."
}
},
"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",
"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",
"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",
"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": {
"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.",
"noEmailEnforced": "Keine E-Mail erforderlich.",
"noInvitations": "Keine Einladungen.",
"noUsernameEnforced": "Kein Nutzername erforderlich.",
"title": "Einfache Authentifizierung",
"userInvitation": "Benutzereinladung"
},
"srEditLabel": "Bearbeiten",
"usernameHeader": "Nutzername"
}
},
"welcome": "Deutsche, willkommen!"
}

View File

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

View File

@ -1 +0,0 @@
{}

View File

@ -1,527 +0,0 @@
{
"account": {
"devices": {
"capabilities": "Aye, yer Capabilities",
"lastConnected": "Last Linked",
"noDevices": "No contraptions tied to yer coffers, eh?",
"platform": "Ship",
"revoke": "Scuttle 'em",
"subheader": "Manage the contraptions allowed access to yer Drop booty.",
"title": "Contraptions"
},
"notifications": {
"all": "Gaze upon all {arrow}",
"desc": "View and manage yer messages from the crows' nest.",
"markAllAsRead": "Mark all as read, aye!",
"markAsRead": "Mark as read, matey!",
"none": "No messages, savvy?",
"notifications": "Crows' Nest",
"title": "Messages from the Crows' Nest",
"unread": "Unread Messages"
},
"settings": "Settings",
"title": "Yer Own Coffer"
},
"actions": "Deeds",
"add": "Add",
"adminTitle": "Cap'n's Quarters - Drop",
"adminTitleTemplate": "{0} - Cap'n - Drop",
"auth": {
"callback": {
"authClient": "Grant passage to this scallywag?",
"authorize": "Grant Passage",
"authorizedClient": "Drop has granted passage to the scallywag. Ye may now shut this porthole.",
"issues": "Troubles be brewin', matey?",
"learn": "Learn more {arrow}",
"paste": "Scribble this code into the scallywag to carry on:",
"permWarning": "Grantin' this request allows \"{name}\" on \"{platform}\" to:",
"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",
"password": "Secret Word",
"register": {
"confirmPasswordFormat": "Must be the same as above, savvy?",
"emailFormat": "Must be in the fashion of a true scallywag {'@'} example.com",
"passwordFormat": "Must be 14 or more marks, ye landlubber!",
"subheader": "Fill in yer details below to make yer mark.",
"title": "Forge yer Drop Mark",
"usernameFormat": "Must be 5 or more marks, and all lowercase, argh!"
},
"signin": {
"externalProvider": "Sign in with another ship's captain {arrow}",
"forgot": "Forgot yer secret word, eh?",
"noAccount": "No mark in the logbook? Beg a cap'n to make ye one, argh!",
"or": "OR",
"pageTitle": "Sign in to Drop, ye dog!",
"rememberMe": "Remember me, savvy?",
"signin": "Sign in, ye scurvy dog!",
"title": "Sign in to yer mark"
},
"signout": "Cast off!",
"username": "Scallywag Name"
},
"cancel": "Belay that!",
"chars": {
"arrow": "→",
"arrowBack": "←",
"quoted": "\"\"",
"srComma": ", {0}"
},
"common": {
"add": "Append",
"cannotUndo": "This deed cannot be undone, ye hear!",
"close": "Shut yer trap!",
"create": "Forge!",
"date": "Date",
"deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?",
"divider": "{'|'}",
"edit": "Amend",
"friends": "Shipmates",
"groups": "Crews",
"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…",
"tags": "Marks",
"today": "Today"
},
"delete": "Scuttle!",
"drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!",
"drop": "Drop"
},
"editor": {
"bold": "Bold, like a cannonball!",
"boldPlaceholder": "bold text, matey",
"code": "Code, ye scallywag!",
"codePlaceholder": "code, argh!",
"heading": "Heading, to the horizon!",
"headingPlaceholder": "heading, savvy?",
"italic": "Italic, like a wobbly deck!",
"italicPlaceholder": "italic text, arrr!",
"link": "Link, a chain to adventure!",
"linkPlaceholder": "link text, ye dog!",
"listItem": "List Item, for yer plunder!",
"listItemPlaceholder": "list item, eh?"
},
"errors": {
"admin": {
"user": {
"delete": {
"desc": "Couldn't make {0} walk the plank!",
"title": "Couldn't make 'em walk the plank"
}
}
},
"auth": {
"disabled": "Ya' angered the Cap'n, give him a holler!",
"invalidInvite": "Boarding pass no longer valid, ya scallywag!",
"invalidPassState": "Cap'n's rolled snake eyes, see how he's doin'.",
"invalidUserOrPass": "Are ya lying to me about your username and password, ya dog?",
"inviteIdRequired": "Ya need to include the ID for your boarding pass, matey!",
"method": {
"signinDisabled": "No entrance through these parts, arr!"
},
"usernameTaken": "We already have a scallywag with that 'ere name of yours."
},
"backHome": "{arrow} Back to yer safe harbor",
"game": {
"banner": {
"description": "Drop failed to hoist the banner image: {0}",
"title": "Failed to hoist the banner image"
},
"carousel": {
"description": "Drop failed to update the image carousel: {0}",
"title": "Failed to update image carousel"
},
"cover": {
"description": "Drop failed to hoist the cover image: {0}",
"title": "Failed to hoist the cover image"
},
"deleteImage": {
"description": "Drop failed to scuttle the image: {0}",
"title": "Failed to scuttle the image"
},
"description": {
"description": "Drop failed to update the game description: {0}",
"title": "Failed to update game description"
},
"metadata": {
"description": "Drop failed to update the game's charts: {0}",
"title": "Failed to update yer charts"
}
},
"invalidBody": "Invalid request, ye barnacle-encrusted body: {0}",
"inviteRequired": "Invitation demanded to sign up, ye landlubber.",
"library": {
"add": {
"desc": "Drop couldn't add this game to yer treasure hoard: {0}",
"title": "Failed to add game to yer treasure hoard"
},
"collection": {
"create": {
"desc": "Drop couldn't forge yer collection, argh: {0}",
"title": "Failed to forge collection"
}
},
"source": {
"delete": {
"desc": "Drop couldn't scuttle this source: {0}",
"title": "Failed to scuttle treasure hoard source"
}
}
},
"news": {
"article": {
"delete": {
"desc": "Drop couldn't scuttle this article: {0}",
"title": "Failed to scuttle article"
}
}
},
"occurred": "An error occurred whilst answerin' yer plea. If ye believe this be a bug, report it, ye dog! Try signin' in and see if it clears the decks.",
"ohNo": "Blimey!",
"pageTitle": "{0} | Drop",
"revokeClient": "Failed to scuttle scallywag",
"revokeClientFull": "Failed to scuttle scallywag {0}",
"signIn": "Sign in {arrow}, ye scurvy dog!",
"support": "Support Discord, arrr!",
"unknown": "An unknown blunder occurred, savvy?",
"upload": {
"description": "Drop couldn't hoist the file: {0}",
"title": "Failed to hoist the file"
},
"version": {
"delete": {
"desc": "Drop met a squall whilst scuttlin' the version: {error}",
"title": "There was a squall whilst scuttlin' the version"
},
"order": {
"desc": "Drop met a squall whilst updatin' the version: {error}",
"title": "There was a squall whilst updatin' the version order"
}
}
},
"footer": {
"about": "About, savvy?",
"aboutDrop": "About Drop, argh!",
"comparison": "Comparison, matey!",
"docs": {
"client": "Scallywag's Docs",
"server": "Cap'n's Docs"
},
"documentation": "Charts and Scrolls",
"findGame": "Find a Game, ye dog!",
"footer": "Keel",
"games": "Games",
"social": {
"discord": "Discord, argh!",
"github": "GitHub, savvy?"
},
"topSellers": "Top Plunderers",
"version": "Drop {version} {gitRef}"
},
"header": {
"admin": {
"admin": "Cap'n",
"metadata": "Meta argh",
"settings": "Shape",
"tasks": "Duties",
"users": "Crew"
},
"back": "Aft!",
"openSidebar": "Open the side-hatch!"
},
"helpUsTranslate": "Help us translate Drop {arrow}, argh!",
"highest": "highest",
"home": "Home Port",
"library": {
"addGames": "All Plunder",
"addToLib": "Add to Yer Treasure Hoard",
"admin": {
"detectedGame": "Drop has found new plunder to import, argh!",
"detectedVersion": "Drop has found new versions of this plunder to import, savvy!",
"game": {
"addCarouselNoImages": "No images to add, ye dog.",
"addDescriptionNoImages": "No images to add, argh.",
"addImageCarousel": "Add from image treasure hoard",
"currentBanner": "banner",
"currentCover": "cover",
"deleteImage": "Scuttle image",
"editGameDescription": "Plunder Description",
"editGameName": "Plunder Name",
"imageCarousel": "Image Carousel",
"imageCarouselDescription": "Customize what images and what order be shown on the store page, savvy.",
"imageCarouselEmpty": "No images added to the carousel yet, argh.",
"imageLibrary": "Image treasure hoard",
"imageLibraryDescription": "Please note all images hoisted be accessible to all crew through browser dev-tools, savvy.",
"removeImageCarousel": "Remove image",
"setBanner": "Set as banner",
"setCover": "Set as cover"
},
"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…",
"search": "Search",
"searchPlaceholder": "Fallout 4, savvy?",
"selectDir": "Pick a directory, ye landlubber…",
"selectGame": "Pick plunder to import",
"selectGamePlaceholder": "Pick a game, ye dog…",
"selectGameSearch": "Pick game",
"selectPlatform": "Pick a ship, ye scallywag…",
"version": {
"advancedOptions": "Advanced options, savvy?",
"import": "Import version",
"installDir": "(install_dir)/",
"launchCmd": "Launch executable/command, argh!",
"launchDesc": "Executable to launch the game, matey!",
"launchPlaceholder": "game.exe, aye!",
"loadingVersion": "Loading version charts…",
"noAdv": "No advanced options for this rig, argh.",
"noVersions": "No versions to import, savvy!",
"platform": "Ship type",
"setupCmd": "Setup executable/command",
"setupDesc": "Ran once when the game is installed, ye hear!",
"setupMode": "Setup mode, savvy?",
"setupModeDesc": "When enabled, this version has no launch command, and merely runs the executable on the crew's computer. Useful for games that only give installers and not portable files, argh!",
"setupPlaceholder": "setup.exe, aye!",
"umuLauncherId": "UMU Launcher ID",
"umuOverride": "Override UMU Launcher Game ID",
"umuOverrideDesc": "By default, Drop uses a non-ID when launchin' with UMU Launcher. To get the right patches for some games, ye might have to set this field by hand, savvy.",
"updateMode": "Update mode, argh!",
"updateModeDesc": "When enabled, these files will be installed atop (overwritin') the previous version's. If many 'update modes' be chained together, they be applied in order, ye hear!",
"version": "Pick version to import"
},
"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}",
"openStore": "Open in Store, argh!",
"shortDesc": "Short Description",
"sources": {
"create": "Forge source",
"createDesc": "Drop will use this source to get to yer game treasure hoard, and make 'em available, argh.",
"desc": "Rig yer treasure hoard sources, where Drop will look for new plunder and versions to import, savvy.",
"fsDesc": "Imports games from a path on disk. Needs version-based folder structure, and backs archived games, ye hear!",
"fsPath": "Path",
"fsPathDesc": "An absolute path to yer game treasure hoard.",
"fsPathPlaceholder": "/mnt/games, aye!",
"link": "Sources {arrow}",
"nameDesc": "The name of yer source, for yer own reckonin', argh.",
"namePlaceholder": "My New Source, savvy?",
"sources": "Treasure Hoard Sources",
"typeDesc": "The type of yer source. Changes the demanded options, ye dog!",
"working": "Workin', eh?"
},
"subheader": "As ye add folders to yer treasure hoard sources, Drop will find 'em and ask ye to import 'em. Each game needs to be imported before ye can import a version, savvy.",
"title": "Treasure Hoards",
"version": {
"delta": "Upgrade mode",
"noVersions": "Ye have no versions of this plunder available, ye dog!",
"noVersionsAdded": "no versions added, argh!"
},
"versionPriority": "Version priority"
},
"back": "Aft to Treasure Hoard",
"collection": {
"addToNew": "Add to new collection",
"collections": "Collections",
"create": "Forge Collection",
"createDesc": "Collections can be used to sort yer plunder and find 'em easier, especially if ye have a grand treasure hoard, argh!",
"delete": "Scuttle Collection",
"namePlaceholder": "Collection name, matey!",
"noCollections": "No collections, savvy!",
"notFound": "Collection not found, argh!",
"subheader": "Add a new collection to sort yer plunder",
"title": "Collection"
},
"gameCount": "{0} plunder | {0} plunder | {0} plunder",
"inLib": "In Treasure Hoard",
"launcherOpen": "Open in Launcher, argh!",
"noGames": "No plunder in treasure hoard, savvy!",
"notFound": "Plunder not found, matey!",
"search": "Search treasure hoard, ye dog…",
"subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!"
},
"lowest": "lowest",
"news": {
"article": {
"add": "Add, ye dog!",
"content": "Content (Markdown), savvy!",
"create": "Forge New Article",
"editor": "Editor",
"editorGuide": "Use the quick ways above or scribble Markdown directly. Backs **bold**, *italic*, [links](url), and more, argh!",
"new": "New article, savvy!",
"preview": "Preview, matey!",
"shortDesc": "Short description",
"submit": "Submit, ye scurvy dog!",
"tagPlaceholder": "Add a mark, ye dog…",
"titles": "Title, argh!",
"uploadCover": "Hoist cover image"
},
"back": "Aft to News",
"checkLater": "Check back later for new charts, matey!",
"delete": "Scuttle Article",
"filter": {
"all": "All time, savvy!",
"month": "This moon",
"week": "This week",
"year": "This year, argh!"
},
"none": "No articles, savvy!",
"notFound": "Article not found, matey!",
"search": "Search articles, ye dog!",
"searchPlaceholder": "Search articles, argh…",
"subheader": "Stay up to date with the latest charts and announcements, savvy!",
"title": "Latest News from the High Seas"
},
"options": "Options, matey!",
"security": "Safety",
"selectLanguage": "Pick yer tongue",
"settings": "Settings",
"store": {
"commingSoon": "comin' soon, argh!",
"exploreMore": "Explore more {arrow}, ye dog!",
"images": "Plunder Images",
"lookAt": "Look at it, ye scurvy dog!",
"noGame": "no plunder",
"noImages": "No images, savvy!",
"openAdminDashboard": "Open in Cap'n's Quarters",
"platform": "Ship | Ship | Ships",
"rating": "Rating, argh!",
"readLess": "Click to read less, matey!",
"readMore": "Click to read more, ye dog!",
"recentlyAdded": "Recently Added Plunder",
"recentlyReleased": "Recently set sail",
"recentlyUpdated": "Recently Updated",
"released": "Released, argh!",
"reviews": "({0} Sea Tales)",
"title": "Store",
"view": "View in Store"
},
"tasks": {
"admin": {
"back": "{arrow} Aft to Duties",
"completedTasksTitle": "Duties completed",
"dailyScheduledTitle": "Daily scheduled duties",
"noTasksRunning": "No duties currently underway",
"runningTasksTitle": "Duties underway",
"scheduled": {
"checkUpdateDescription": "Check if Drop has new charts.",
"checkUpdateName": "Check for new charts.",
"cleanupInvitationsDescription": "Cleans up expired invitations from the logbook to save space, savvy.",
"cleanupInvitationsName": "Clean up invitations",
"cleanupObjectsDescription": "Finds and scuttles unreferenced and unused objects to save space, argh.",
"cleanupObjectsName": "Clean up objects",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and keep ye safe, ye dog!",
"cleanupSessionsName": "Clean up sessions."
},
"viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly chores"
}
},
"title": "Drop",
"titleTemplate": "{0} | Drop",
"todo": "Todo, argh!",
"type": "Type",
"upload": "Hoist!",
"uploadFile": "Hoist file",
"userHeader": {
"closeSidebar": "Close side-hatch!",
"links": {
"community": "Shipmates",
"library": "Treasure Hoard",
"news": "News from the High Seas"
},
"profile": {
"admin": "Cap'n's Quarters",
"settings": "Account settings, savvy!"
}
},
"users": {
"admin": {
"adminHeader": "Cap'n, eh?",
"adminUserLabel": "Cap'n of the crew",
"authLink": "Passage {arrow}",
"authentication": {
"configure": "Rig",
"description": "Drop backs many 'passage ways'. As ye enable or disable 'em, they show on the sign-in screen for the crew to pick. Click the dot menu to rig the passage way.",
"disabled": "Disabled",
"enabled": "Enabled",
"enabledKey": "Enabled, argh?",
"oidc": "OpenID Connect, savvy?",
"simple": "Simple (crew name/secret word)",
"srOpenOptions": "Open options",
"title": "Passage"
},
"authoptionsHeader": "Passage Options",
"delete": "Scuttle!",
"deleteUser": "Make {0} walk the plank!",
"description": "Manage the crew on yer Drop vessel, and set yer passage methods, savvy?",
"displayNameHeader": "Scallywag Name",
"emailHeader": "Salty Mail",
"normalUserLabel": "Common crewman",
"simple": {
"adminInvitation": "Cap'n's Invitation",
"createInvitation": "Forge Invitation",
"description": "Simple passage uses a system of 'invitations' to create crew. Ye can forge an invitation, and optionally name a crew name or salty mail for the crew, then it'll make a magic scroll that can be used to make a mark.",
"expires": "Expires: {expiry}",
"invitationTitle": "Invitations",
"invite3Days": "3 suns",
"invite6Months": "6 moons",
"inviteAdminSwitchDescription": "Make this crewman a cap'n, argh!",
"inviteAdminSwitchLabel": "Cap'n's invitation",
"inviteButton": "Invite, ye dog!",
"inviteDescription": "Drop will make a scroll ye can send to the scallywag ye want to invite. Ye can optionally name a crew name or salty mail for them to use.",
"inviteEmailDescription": "Must be in the fashion of a scallywag {'@'} example.com",
"inviteEmailLabel": "Salty mail address (optional)",
"inviteEmailPlaceholder": "me{'@'}example.com",
"inviteExpiryLabel": "Expires",
"inviteMonth": "1 moon",
"inviteNever": "Never",
"inviteTitle": "Invite crew to Drop",
"inviteUsernameFormat": "Must be 5 or more marks",
"inviteUsernameLabel": "Crew Name (optional)",
"inviteUsernamePlaceholder": "myScallywagName",
"inviteWeek": "1 week",
"inviteYear": "1 year",
"neverExpires": "Never expires, savvy.",
"noEmailEnforced": "No salty mail forced, matey.",
"noInvitations": "No invitations, argh.",
"noUsernameEnforced": "No crew name forced, argh.",
"title": "Simple passage",
"userInvitation": "Crewman's Invitation"
},
"srEditLabel": "Amend",
"usernameHeader": "Crew Name"
}
},
"welcome": "Ahoy, Welcome!"
}

View File

@ -1,676 +0,0 @@
{
"account": {
"devices": {
"capabilities": "Capabilities",
"lastConnected": "Last Connected",
"noDevices": "No devices connected to your account.",
"platform": "Platform",
"revoke": "Revoke",
"subheader": "Manage the devices authorized to access your Drop account.",
"title": "Devices"
},
"notifications": {
"all": "View all {arrow}",
"desc": "View and manage your notifications.",
"markAllAsRead": "Mark all as read",
"markAsRead": "Mark as read",
"none": "No notifications",
"notifications": "Notifications",
"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"
},
"actions": "Actions",
"add": "Add",
"adminTitle": "Admin Dashboard - Drop",
"adminTitleTemplate": "{0} - Admin - Drop",
"auth": {
"callback": {
"authClient": "Authorize client?",
"authorize": "Authorize",
"authorizedClient": "Drop has successfully authorized the client. You may now close this window.",
"issues": "Having issues?",
"learn": "Learn more {arrow}",
"paste": "Paste this code into the client to continue:",
"permWarning": "Accepting this request will allow \"{name}\" on \"{platform}\" to:",
"requestedAccess": "\"{name}\" has requested access to your Drop account.",
"success": "Successful!"
},
"code": {
"description": "Use a code to connect your Drop client if you are unable to open a web browser on your device.",
"title": "Connect your Drop client"
},
"confirmPassword": "Confirm @:auth.password",
"displayName": "Display Name",
"email": "Email",
"password": "Password",
"register": {
"confirmPasswordFormat": "Must be the same as above",
"emailFormat": "Must be in the format user{'@'}example.com",
"passwordFormat": "Must be 14 or more characters",
"subheader": "Fill in your details below to create your account.",
"title": "Create your Drop account",
"usernameFormat": "Must be 5 or more characters, and lowercase"
},
"signin": {
"externalProvider": "Sign in with external provider {arrow}",
"forgot": "Forgot password?",
"noAccount": "Don't have an account? Ask an admin to create one for you.",
"or": "OR",
"pageTitle": "Sign in to Drop",
"rememberMe": "Remember me",
"signin": "Sign in",
"title": "Sign in to your account"
},
"signout": "Signout",
"username": "Username"
},
"cancel": "Cancel",
"chars": {
"arrow": "→",
"arrowBack": "←",
"quoted": "\"\"",
"srComma": ", {0}"
},
"common": {
"add": "Add",
"cannotUndo": "This action cannot be undone.",
"close": "Close",
"create": "Create",
"date": "Date",
"deleteConfirm": "Are you sure you want to delete \"{0}\"?",
"divider": "{'|'}",
"edit": "Edit",
"friends": "Friends",
"groups": "Groups",
"insert": "Insert",
"name": "Name",
"noResults": "No results",
"noSelected": "No items selected.",
"remove": "Remove",
"save": "Save",
"saved": "Saved",
"servers": "Servers",
"srLoading": "Loading…",
"tags": "Tags",
"today": "Today"
},
"delete": "Delete",
"drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty.",
"drop": "Drop"
},
"editor": {
"bold": "Bold",
"boldPlaceholder": "bold text",
"code": "Code",
"codePlaceholder": "code",
"heading": "Heading",
"headingPlaceholder": "heading",
"italic": "Italic",
"italicPlaceholder": "italic text",
"link": "Link",
"linkPlaceholder": "link text",
"listItem": "List Item",
"listItemPlaceholder": "list item"
},
"errors": {
"admin": {
"user": {
"delete": {
"desc": "Drop couldn't delete this user: {0}",
"title": "Failed to delete user"
}
}
},
"auth": {
"disabled": "Invalid or disabled account. Please contact the server administrator.",
"invalidInvite": "Invalid or expired invitation",
"invalidPassState": "Invalid password state. Please contact the server administrator.",
"invalidUserOrPass": "Invalid username or password.",
"inviteIdRequired": "id required in fetching invitation",
"method": {
"signinDisabled": "Sign in method not enabled"
},
"usernameTaken": "Username already taken."
},
"backHome": "{arrow} Back to home",
"externalUrl": {
"subtitle": "This message is only visible to admins.",
"title": "Accessing over different EXTERNAL_URL. Please check the docs."
},
"game": {
"banner": {
"description": "Drop failed to update the banner image: {0}",
"title": "Failed to update the banner image"
},
"carousel": {
"description": "Drop failed to update the image carousel: {0}",
"title": "Failed to update image carousel"
},
"cover": {
"description": "Drop failed to update the cover image: {0}",
"title": "Failed to update the cover image"
},
"deleteImage": {
"description": "Drop failed to delete the image: {0}",
"title": "Failed to delete the image"
},
"description": {
"description": "Drop failed to update the game description: {0}",
"title": "Failed to update game description"
},
"metadata": {
"description": "Drop failed to update the game's metadata: {0}",
"title": "Failed to update metadata"
}
},
"invalidBody": "Invalid request body: {0}",
"inviteRequired": "Invitation required to sign up.",
"library": {
"add": {
"desc": "Drop couldn't add this game to your library: {0}",
"title": "Failed to add game to library"
},
"collection": {
"create": {
"desc": "Drop couldn't create your collection: {0}",
"title": "Failed to create collection"
}
},
"source": {
"delete": {
"desc": "Drop couldn't delete this source: {0}",
"title": "Failed to delete library source"
}
}
},
"news": {
"article": {
"delete": {
"desc": "Drop couldn't delete this article: {0}",
"title": "Failed to delete article"
}
}
},
"occurred": "An error occurred while responding to your request. If you believe this to be a bug, please report it. Try signing in and see if it resolves the issue.",
"ohNo": "Oh no!",
"pageTitle": "{0} | Drop",
"revokeClient": "Failed to revoke client",
"revokeClientFull": "Failed to revoke client {0}",
"signIn": "Sign in {arrow}",
"support": "Support Discord",
"unknown": "An unknown error occurred",
"upload": {
"description": "Drop couldn't upload the file: {0}",
"title": "Failed to upload file"
},
"version": {
"delete": {
"desc": "Drop encountered an error while deleting the version: {error}",
"title": "There an error while deleting the version"
},
"order": {
"desc": "Drop encountered an error while updating the version: {error}",
"title": "There an error while updating the version order"
}
}
},
"footer": {
"about": "About",
"aboutDrop": "About Drop",
"comparison": "Comparison",
"docs": {
"client": "Client Docs",
"server": "Server Docs"
},
"documentation": "Documentation",
"findGame": "Find a Game",
"footer": "Footer",
"games": "Games",
"social": {
"discord": "Discord",
"github": "GitHub"
},
"topSellers": "Top Sellers",
"version": "Drop {version} {gitRef}"
},
"header": {
"admin": {
"admin": "Admin",
"metadata": "Meta",
"settings": {
"title": "Settings",
"store": "Store",
"tokens": "API tokens"
},
"tasks": "Tasks",
"users": "Users"
},
"back": "Back",
"openSidebar": "Open sidebar"
},
"helpUsTranslate": "Help us translate Drop {arrow}",
"highest": "highest",
"home": "Home",
"library": {
"addGames": "All Games",
"addToLib": "Add to Library",
"admin": {
"detectedGame": "Drop has detected you have new games to import.",
"detectedVersion": "Drop has detected you have new versions of this game to import.",
"game": {
"addCarouselNoImages": "No images to add.",
"addDescriptionNoImages": "No images to add.",
"addImageCarousel": "Add from image library",
"currentBanner": "banner",
"currentCover": "cover",
"deleteImage": "Delete image",
"editGameDescription": "Game Description",
"editGameName": "Game Name",
"editReleaseDate": "Release Date",
"imageCarousel": "Image Carousel",
"imageCarouselDescription": "Customise what images and what order are shown on the store page.",
"imageCarouselEmpty": "No images added to the carousel yet.",
"imageLibrary": "Image library",
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
"removeImageCarousel": "Remove image",
"setBanner": "Set as banner",
"setCover": "Set as cover"
},
"gameLibrary": "Game Library",
"import": {
"bulkImportDescription": "When on this page, you won't be redirect to the import task, so you can import multiple games in succession.",
"bulkImportTitle": "Bulk import mode",
"import": "Import",
"link": "Import {arrow}",
"loading": "Loading game results…",
"search": "Search",
"searchPlaceholder": "Fallout 4",
"selectDir": "Please select a directory…",
"selectGame": "Select game to import",
"selectGamePlaceholder": "Please select a game…",
"selectGameSearch": "Select game",
"selectPlatform": "Please select a platform…",
"version": {
"advancedOptions": "Advanced options",
"import": "Import version",
"installDir": "(install_dir)/",
"launchCmd": "Launch executable/command",
"launchDesc": "Executable to launch the game",
"launchPlaceholder": "game.exe",
"loadingVersion": "Loading version metadata…",
"noAdv": "No advanced options for this configuration.",
"noVersions": "No versions to import",
"platform": "Version platform",
"setupCmd": "Setup executable/command",
"setupDesc": "Ran once when the game is installed",
"setupMode": "Setup mode",
"setupModeDesc": "When enabled, this version does not have a launch command, and simply runs the executable on the user's computer. Useful for games that only distribute installer and not portable files.",
"setupPlaceholder": "setup.exe",
"umuLauncherId": "UMU Launcher ID",
"umuOverride": "Override UMU Launcher Game ID",
"umuOverrideDesc": "By default, Drop uses a non-ID when launching with UMU Launcher. In order to get the right patches for some games, you may have to manually set this field.",
"updateMode": "Update mode",
"updateModeDesc": "When enabled, these files will be installed on top of (overwriting) the previous version's. If multiple \"update modes\" are chained together, they are applied in order.",
"version": "Select version to import"
},
"withoutMetadata": "Import without metadata"
},
"metadata": {
"companies": {
"action": "Manage {arrow}",
"addGame": {
"description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.",
"developer": "Developer?",
"noGames": "No games to add",
"publisher": "Publisher?",
"title": "Connect game to this company"
},
"description": "Companies organize games by who they were developed or published by.",
"editor": {
"action": "Add Game {plus}",
"descriptionPlaceholder": "{'<'}description{'>'}",
"developed": "Developed",
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
"libraryTitle": "Game Library",
"noDescription": "(no description)",
"published": "Published",
"uploadBanner": "Upload banner",
"uploadIcon": "Upload icon",
"websitePlaceholder": "{'<'}website{'>'}"
},
"modals": {
"createDescription": "Create a company to further organize your games.",
"createFieldDescription": "Company Description",
"createFieldDescriptionPlaceholder": "A small indie studio that...",
"createFieldName": "Company Name",
"createFieldNamePlaceholder": "My New Company...",
"createFieldWebsite": "Company Website",
"createFieldWebsitePlaceholder": "https://example.com/",
"createTitle": "Create a company",
"nameDescription": "Edit the company's name. Used to match to new game imports.",
"nameTitle": "Edit company name",
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
"shortDeckTitle": "Edit company description",
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection.",
"websiteTitle": "Edit company website"
},
"noCompanies": "No companies",
"noGames": "No games",
"search": "Search companies…",
"searchGames": "Search company games…",
"title": "Companies"
},
"tags": {
"action": "Manage {arrow}",
"create": "Create",
"description": "Tags are automatically created from imported genres. You can add custom tags to add categorisation to your game library.",
"modal": {
"description": "Create a tag to organize your library.",
"title": "Create Tag"
},
"title": "Tags"
}
},
"metadataProvider": "Metadata provider",
"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}",
"openStore": "Open in Store",
"shortDesc": "Short Description",
"sources": {
"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",
"sources": "Library Sources",
"typeDesc": "The type of your source. Changes the required options.",
"working": "Working?"
},
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
"title": "Libraries",
"version": {
"delta": "Upgrade mode",
"noVersions": "You have no versions of this game available.",
"noVersionsAdded": "no versions added"
},
"versionPriority": "Version priority"
},
"back": "Back to Library",
"collection": {
"addToNew": "Add to new collection",
"collections": "Collections",
"create": "Create Collection",
"createDesc": "Collections can used to organise your games and find them more easily, especially if you have a large library.",
"delete": "Delete Collection",
"namePlaceholder": "Collection name",
"noCollections": "No collections",
"notFound": "Collection not found",
"subheader": "Add a new collection to organize your games",
"title": "Collection"
},
"gameCount": "{0} games | {0} game | {0} games",
"inLib": "In Library",
"launcherOpen": "Open in Launcher",
"noGames": "No games in library",
"notFound": "Game not found",
"search": "Search library…",
"subheader": "Organize your games into collections for easy access, and access all your games."
},
"lowest": "lowest",
"news": {
"article": {
"add": "Add",
"content": "Content (Markdown)",
"create": "Create New Article",
"editor": "Editor",
"editorGuide": "Use the shortcuts above or write Markdown directly. Supports **bold**, *italic*, [links](url), and more.",
"new": "New article",
"preview": "Preview",
"shortDesc": "Short description",
"submit": "Submit",
"tagPlaceholder": "Add a tag…",
"titles": "Title",
"uploadCover": "Upload cover image"
},
"back": "Back to News",
"checkLater": "Check back later for updates.",
"delete": "Delete Article",
"filter": {
"all": "All time",
"month": "This month",
"week": "This week",
"year": "This year"
},
"none": "No articles",
"notFound": "Article not found",
"search": "Search articles",
"searchPlaceholder": "Search articles…",
"subheader": "Stay up to date with the latest updates and announcements.",
"title": "Latest News"
},
"options": "Options",
"security": "Security",
"selectLanguage": "Select language",
"settings": {
"admin": {
"description": "Configure Drop settings",
"store": {
"dropGameAltPlaceholder": "Example Game icon",
"dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.",
"dropGameNamePlaceholder": "Example Game",
"showGamePanelTextDecoration": "Show title and description on game tiles (default: on)",
"title": "Store"
},
"title": "Settings"
}
},
"setup": {
"auth": {
"description": "Authentication in Drop happens through multiple configured 'providers'. Each one can allow users to sign-in through their method. To get started, have at least one authentication provider enabled, and create an account through it.",
"docs": "Documentation {arrow}",
"enabled": "Enabled?",
"openid": {
"description": "OpenID Connect (OIDC) is an OAuth2 extension commonly supported. Drop requires OIDC configuration to be done via environment variables.",
"skip": "I have a user with OIDC",
"title": "OpenID Connect"
},
"simple": {
"description": "Simple authentication uses username/password to authentication users. It is enabled by default if no other authentication provider is enabled.",
"register": "Register as admin {arrow}",
"title": "Simple authentication"
},
"title": "Authentication"
},
"finish": "Let's go {arrow}",
"noPage": "no page",
"stages": {
"account": {
"description": "You need at least one account to start using Drop.",
"name": "Setup your admin account."
},
"library": {
"description": "Add at least one library source to use Drop.",
"name": "Create a library."
}
},
"welcome": "Hey there.",
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works."
},
"store": {
"about": "About",
"commingSoon": "coming soon",
"developers": "Developers | Developer | Developers",
"exploreMore": "Explore more {arrow}",
"featured": "Featured",
"images": "Game Images",
"lookAt": "Check it out",
"noDevelopers": "No developers",
"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",
"readLess": "Click to read less",
"readMore": "Click to read more",
"recentlyAdded": "Recently Added",
"recentlyReleased": "Recently released",
"recentlyUpdated": "Recently Updated",
"released": "Released",
"reviews": "({0} Reviews)",
"tags": "Tags",
"title": "Store",
"view": {
"sort": "Sort",
"srFilters": "Filters",
"srGames": "Games",
"srViewGrid": "View grid"
},
"viewInStore": "View in Store",
"website": "Website"
},
"tasks": {
"admin": {
"back": "{arrow} Back to Tasks",
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"noTasksRunning": "No tasks currently running",
"runningTasksTitle": "Running tasks",
"scheduled": {
"checkUpdateDescription": "Check if Drop has an update.",
"checkUpdateName": "Check update.",
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
"cleanupInvitationsName": "Clean up invitations",
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
"cleanupObjectsName": "Clean up objects",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
"cleanupSessionsName": "Clean up sessions."
},
"viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly scheduled tasks",
"progress": "{0}%",
"execute": "{arrow} Execute"
}
},
"title": "Drop",
"titleTemplate": "{0} - Drop",
"todo": "Todo",
"type": "Type",
"upload": "Upload",
"uploadFile": "Upload file",
"userHeader": {
"closeSidebar": "Close sidebar",
"links": {
"community": "Community",
"library": "Library",
"news": "News"
},
"profile": {
"admin": "Admin Dashboard",
"settings": "Account settings"
}
},
"users": {
"admin": {
"adminHeader": "Admin?",
"adminUserLabel": "Admin user",
"authentication": {
"configure": "Configure",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
"disabled": "Disabled",
"enabled": "Enabled",
"enabledKey": "Enabled?",
"oidc": "OpenID Connect",
"simple": "Simple (username/password)",
"srOpenOptions": "Open options",
"title": "Authentication"
},
"authLink": "Authentication {arrow}",
"authoptionsHeader": "Auth Options",
"delete": "Delete",
"deleteUser": "Delete user {0}",
"description": "Manage the users on your Drop instance, and configure your authentication methods.",
"displayNameHeader": "Display Name",
"emailHeader": "Email",
"normalUserLabel": "Normal user",
"simple": {
"adminInvitation": "Admin invitation",
"createInvitation": "Create invitation",
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
"expires": "Expires: {expiry}",
"invitationTitle": "Invitations",
"invite3Days": "3 days",
"invite6Months": "6 months",
"inviteAdminSwitchDescription": "Create this user as an administrator",
"inviteAdminSwitchLabel": "Admin invitation",
"inviteButton": "Invite",
"inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.",
"inviteEmailDescription": "Must be in the format user{'@'}example.com",
"inviteEmailLabel": "Email address (optional)",
"inviteEmailPlaceholder": "me{'@'}example.com",
"inviteExpiryLabel": "Expires",
"inviteMonth": "1 month",
"inviteNever": "Never",
"inviteTitle": "Invite user to Drop",
"inviteUsernameFormat": "Must be 5 or more characters",
"inviteUsernameLabel": "Username (optional)",
"inviteUsernamePlaceholder": "myUsername",
"inviteWeek": "1 week",
"inviteYear": "1 year",
"neverExpires": "Never expires.",
"noEmailEnforced": "No email enforced.",
"noInvitations": "No invitations.",
"noUsernameEnforced": "No username enforced.",
"title": "Simple authentication",
"userInvitation": "User invitation"
},
"srEditLabel": "Edit",
"usernameHeader": "Username"
}
},
"welcome": "American, Welcome!"
}

View File

@ -1 +0,0 @@
{}

View File

@ -1,673 +0,0 @@
{
"account": {
"devices": {
"capabilities": "Capacités",
"lastConnected": "Dernière Connexion",
"noDevices": "Aucun appareil 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": "Voir tout {arrow}",
"desc": "Voir et gérer vos notifications.",
"markAllAsRead": "Marquer tout 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",
"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",
"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"
},
"confirmPassword": "Confirmez @:auth.password",
"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",
"invalidPassState": "Le mot de passe enregistré est invalide. Merci de contacter l'administrateur du serveur.",
"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",
"externalUrl": {
"subtitle": "Ce message n'est visible qu'aux administrateurs.",
"title": "Accès via une EXTERNAL_URL différente. Veuillez consulter la documentation."
},
"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}",
"support": "Assistance Discord",
"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": {
"store": "Store",
"title": "Paramètres",
"tokens": "API tokens"
},
"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"
},
"libraryHint": "Pas de bibliothèque configurée.",
"libraryHintDocsLink": "Qu'est-ce que cela veut dire ? {arrow}",
"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}",
"descriptionPlaceholder": "{'<'}description{'>'}",
"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",
"websitePlaceholder": "{'<'}site web{'>'}"
},
"modals": {
"createDescription": "Créez une société pour mieux organizer vos jeux.",
"createFieldDescription": "Description de la Société",
"createFieldDescriptionPlaceholder": "Un petit studio indépendant qui...",
"createFieldName": "Nom de la société",
"createFieldNamePlaceholder": "Ma nouvelle société...",
"createFieldWebsite": "Site web de la société",
"createFieldWebsitePlaceholder": "https://exemple com/",
"createTitle": "Créer une société",
"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.",
"documentationLink": "Documentation {arrow}",
"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.",
"fsFlatTitle": "Compatibilité",
"fsPath": "Chemin daccès",
"fsPathDesc": "Un chemin daccès absolu à votre bibliothèque de jeux.",
"fsPathPlaceholder": "/mnt/jeux",
"fsTitle": "Drop-style",
"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": {
"all": "Tous les temps",
"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",
"developers": "Développeurs | Développeur | Développeurs",
"exploreMore": "Explorer plus {arrow}",
"featured": "Mis en avant",
"images": "Images de Jeux",
"lookAt": "Découvrez le maintenant",
"noDevelopers": "Pas de développeur",
"noFeatured": "PAS DE JEU MIS EN AVANT",
"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",
"openFeatured": "Mettez des étoiles aux jeux dans l'administration de la bibliothèque {arrow}",
"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",
"execute": "{arrow} Exécuter",
"noTasksRunning": "Pas de tâche en cours",
"progress": "{0]%",
"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é",
"enabledKey": "Activée ?",
"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 !"
}

View File

@ -1 +0,0 @@
{}

View File

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

@ -1 +0,0 @@
{}

View File

@ -1 +0,0 @@
{}

View File

@ -42,9 +42,7 @@
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">{{
$t("userHeader.closeSidebar")
}}</span>
<span class="sr-only">Close sidebar</span>
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
</button>
</div>
@ -98,7 +96,7 @@
<DropLogo class="h-8 w-auto" />
<span
class="mt-1 bg-blue-400 px-1 py-0.5 rounded-md text-xs font-bold font-display"
>{{ $t("header.admin.admin") }}</span
>Admin</span
>
</div>
<nav class="mt-8">
@ -133,7 +131,7 @@
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
<span class="sr-only">Open sidebar</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
@ -162,52 +160,43 @@ import {
ServerStackIcon,
HomeIcon,
Cog6ToothIcon,
UserGroupIcon,
RectangleStackIcon,
DocumentIcon,
UserGroupIcon,
} from "@heroicons/vue/24/outline";
import type { NavigationItem } from "~/composables/types";
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
import { XMarkIcon } from "@heroicons/vue/24/solid";
const i18nHead = useLocaleHead();
const navigation: Array<NavigationItem & { icon: Component }> = [
{ label: $t("home"), route: "/admin", prefix: "/admin", icon: HomeIcon },
{ label: "Home", route: "/admin", prefix: "/admin", icon: HomeIcon },
{
label: $t("userHeader.links.library"),
label: "Library",
route: "/admin/library",
prefix: "/admin/library",
icon: ServerStackIcon,
},
{
label: $t("header.admin.metadata"),
label: "Meta",
route: "/admin/metadata",
prefix: "/admin/metadata",
icon: DocumentIcon,
},
{
label: $t("header.admin.users"),
label: "Users",
route: "/admin/users",
prefix: "/admin/users",
icon: UserGroupIcon,
},
{
label: $t("header.admin.tasks"),
route: "/admin/task",
prefix: "/admin/task",
icon: RectangleStackIcon,
},
{
label: $t("header.admin.settings.title"),
label: "Settings",
route: "/admin/settings",
prefix: "/admin/settings",
icon: Cog6ToothIcon,
},
{
label: $t("header.back"),
route: "/store",
label: "Back",
route: "/",
prefix: ".",
icon: ArrowLeftIcon,
},
@ -228,12 +217,17 @@ router.afterEach(() => {
useHead({
htmlAttrs: {
lang: i18nHead.value.htmlAttrs.lang,
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
dir: i18nHead.value.htmlAttrs.dir,
lang: "en",
},
link: [
{
rel: "icon",
type: "image/png",
href: "/favicon.png",
},
],
titleTemplate(title) {
return title ? $t("adminTitleTemplate", [title]) : $t("adminTitle");
return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`;
},
});
</script>

View File

@ -13,22 +13,22 @@
<script setup lang="ts">
const route = useRoute();
const i18nHead = useLocaleHead();
const noWrapper = !!route.query.noWrapper;
const { t } = useI18n();
useHead({
htmlAttrs: {
lang: i18nHead.value.htmlAttrs.lang,
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
dir: i18nHead.value.htmlAttrs.dir,
lang: "en",
},
// // seo headers
// link: [...i18nHead.value.link],
// meta: [...i18nHead.value.meta],
link: [
{
rel: "icon",
type: "image/png",
href: "/favicon.png",
},
],
titleTemplate(title) {
return title ? t("titleTemplate", [title]) : t("title");
if (title) return `${title} | Drop`;
return `Drop`;
},
});
</script>

View File

@ -1,46 +1,16 @@
import tailwindcss from "@tailwindcss/vite";
import { execSync } from "node:child_process";
import { cpSync, readFileSync, existsSync } from "node:fs";
import path from "node:path";
import module from "module";
import { viteStaticCopy } from "vite-plugin-static-copy";
import { type } from "arktype";
const packageJsonSchema = type({
name: "string",
version: "string",
});
const twemojiJson = module.findPackageJSON(
"@discordapp/twemoji",
import.meta.url,
);
if (!twemojiJson) {
throw new Error("Could not find @discordapp/twemoji package.");
}
// get drop version
const dropVersion = getDropVersion();
// get git ref or supply during build
const commitHash =
process.env.BUILD_GIT_REF ??
execSync("git rev-parse --short HEAD").toString().trim();
console.log(`Drop ${dropVersion} #${commitHash}`);
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
extends: ["./drop-base"],
// Module config from here down
// Module config from here down
modules: [
"vue3-carousel-nuxt",
"nuxt-security", // "@nuxt/image",
"nuxt-security",
// "@nuxt/image",
"@nuxt/fonts",
"@nuxt/eslint",
"@nuxtjs/i18n",
"@vueuse/nuxt",
],
// Nuxt-only config
@ -57,15 +27,9 @@ export default defineNuxtConfig({
},
css: ["~/assets/tailwindcss.css", "~/assets/core.scss"],
sourcemap: {
server: true,
client: true,
},
experimental: {
buildCache: true,
viewTransition: true,
componentIslands: true,
},
// future: {
@ -73,38 +37,7 @@ export default defineNuxtConfig({
// },
vite: {
plugins: [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tailwindcss() as any,
// only used in dev server, not build because nitro sucks
// see build hook below
viteStaticCopy({
targets: [
{
src: "node_modules/@discordapp/twemoji/dist/svg/*",
dest: "twemoji",
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
],
},
hooks: {
"nitro:build:public-assets": (nitro) => {
// this is only run during build, not dev server
// https://github.com/nuxt/nuxt/issues/18918#issuecomment-1925774964
// copy emojis to .output/public/twemoji
const targetDir = path.join(nitro.options.output.publicDir, "twemoji");
cpSync(path.join(path.dirname(twemojiJson), "dist", "svg"), targetDir, {
recursive: true,
});
},
},
runtimeConfig: {
gitRef: commitHash,
dropVersion: dropVersion,
plugins: [tailwindcss()],
},
app: {
@ -119,30 +52,18 @@ export default defineNuxtConfig({
nitro: {
minify: true,
compressPublicAssets: true,
experimental: {
websocket: true,
tasks: true,
openAPI: true,
},
openAPI: {
// tracking for dynamic openapi schema https://github.com/nitrojs/nitro/issues/2974
// create body from types: https://github.com/nitrojs/nitro/issues/3275
meta: {
title: "Drop",
description:
"Drop is an open-source, self-hosted game distribution platform, creating a Steam-like experience for DRM-free games.",
version: dropVersion,
},
},
scheduledTasks: {
"0 * * * *": ["dailyTasks"],
"*/30 * * * *": ["downloadCleanup"],
"0 * * * *": ["cleanup:invitations", "cleanup:sessions"],
},
compressPublicAssets: true,
storage: {
appCache: {
driver: "lru-cache",
@ -174,77 +95,6 @@ export default defineNuxtConfig({
prefix: "Vue",
},
i18n: {
defaultLocale: "en-us",
strategy: "no_prefix",
experimental: {
localeDetector: "localeDetector.ts",
autoImportTranslationFunctions: true,
},
detectBrowserLanguage: {
useCookie: true,
cookieKey: "drop_i18n_redirected",
fallbackLocale: "en-us",
},
locales: [
{ code: "en-us", language: "en-us", name: "English", file: "en_us.json" },
{
code: "en-gb",
language: "en-gb",
name: "English (UK)",
file: "en_gb.json",
},
{
code: "en-au",
language: "en-au",
name: "English (Australia)",
file: "en_au.json",
},
{
code: "en-pirate",
language: "en-pirate",
name: "English (Pirate)",
file: "en_pirate.json",
},
{
code: "fr",
language: "fr",
name: "French",
file: "fr.json",
},
{
code: "de",
language: "de",
name: "German",
file: "de.json",
},
{
code: "it",
language: "it",
name: "Italian",
file: "it.json",
},
{
code: "es",
language: "es",
name: "Spanish",
file: "es.json",
},
{
code: "zh",
language: "zh",
name: "Chinese",
file: "zh.json",
},
{
code: "zh-tw",
language: "zh-tw",
name: "Chinese (Taiwan)",
file: "zh_tw.json",
},
],
},
security: {
headers: {
contentSecurityPolicy: {
@ -256,51 +106,11 @@ export default defineNuxtConfig({
"https://www.giantbomb.com",
"https://images.pcgamingwiki.com",
"https://images.igdb.com",
"https://*.steamstatic.com",
],
},
strictTransportSecurity: false,
},
rateLimiter: false,
xssValidator: false,
requestSizeLimiter: false,
},
});
/**
* Gets the drop version from the environment variable or package.json
* @returns {string} The drop version
*/
function getDropVersion(): string {
// get drop version from environment variable
if (process.env.BUILD_DROP_VERSION) {
return process.env.BUILD_DROP_VERSION;
}
// example nightly: "v0.3.0-nightly.2025.05.28"
const defaultVersion = "v0.0.0-alpha.0";
// get path
const packageJsonPath = path.join(
path.dirname(import.meta.url.replace("file://", "")),
"package.json",
);
if (!existsSync(packageJsonPath)) {
console.error("Could not find package.json, using default version.");
return defaultVersion;
}
// parse package.json
const raw = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const packageJson = packageJsonSchema(raw);
if (packageJson instanceof type.errors) {
console.error("Failed to parse package.json", packageJson.summary);
return defaultVersion;
}
// ensure version starts with 'v'
if (packageJson.version.startsWith("v")) {
return packageJson.version;
}
return `v${packageJson.version}`;
}

View File

@ -1,12 +1,8 @@
{
"name": "drop",
"version": "0.3.3",
"name": "nuxt-app",
"version": "0.1.0.beta",
"private": true,
"type": "module",
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=22.16.0"
},
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
@ -14,81 +10,71 @@
"preview": "nuxt preview",
"postinstall": "nuxt prepare && prisma generate",
"typecheck": "nuxt typecheck",
"lint": "pnpm run lint:eslint && pnpm run lint:prettier",
"lint": "yarn lint:eslint && yarn 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": "3.2.0",
"@drop-oss/droplet": "^0.7.2",
"@drop-oss/headscalez": "0.0.4",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3",
"@nuxt/fonts": "^0.11.0",
"@nuxt/image": "^1.10.0",
"@nuxtjs/i18n": "^9.5.5",
"@prisma/client": "^6.11.1",
"@prisma/client": "^6.7.0",
"@tailwindcss/vite": "^4.0.6",
"@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.0",
"argon2": "^0.41.1",
"arktype": "^2.1.10",
"axios": "^1.12.0",
"axios": "^1.7.7",
"bcryptjs": "^3.0.2",
"cheerio": "^1.0.0",
"cookie-es": "^2.0.0",
"fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0",
"luxon": "^3.6.1",
"micromark": "^4.0.1",
"normalize-url": "^8.0.2",
"nuxt": "^3.17.4",
"nuxt": "^3.16.2",
"nuxt-security": "2.2.0",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"prisma": "^6.11.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.7.1",
"prisma": "^6.7.0",
"sharp": "^0.33.5",
"stream-mime-type": "^2.0.0",
"turndown": "^7.2.0",
"unstorage": "^1.15.0",
"vite-plugin-static-copy": "^3.1.2",
"vue": "latest",
"vue-router": "latest",
"vue3-carousel": "^0.16.0",
"vue3-carousel": "^0.15.0",
"vue3-carousel-nuxt": "^1.1.5",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^4.0.1",
"@nuxt/eslint": "^1.3.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/bcryptjs": "^3.0.0",
"@types/luxon": "^3.6.2",
"@types/node": "^22.13.16",
"@types/semver": "^7.7.0",
"@types/turndown": "^5.0.5",
"autoprefixer": "^10.4.20",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.1",
"h3": "^1.15.3",
"nitropack": "^2.11.12",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.0",
"h3": "^1.15.1",
"ofetch": "^1.4.1",
"postcss": "^8.4.47",
"prettier": "^3.5.3",
"sass": "^1.79.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3",
"vue-tsc": "^3.0.1"
"vue-tsc": "^2.2.8"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"overrides": {
"vue3-carousel-nuxt": {
"vue3-carousel": "^0.16.0"
"vue3-carousel": "^0.15.0"
}
},
"prisma": {
"schema": "./prisma"
},
"packageManager": "pnpm@10.15.0+sha512.486ebc259d3e999a4e8691ce03b5cac4a71cbeca39372a9b762cb500cfdf0873e2cb16abe3d951b1ee2cf012503f027b98b6584e4df22524e0c7450d9ec7aa7b"
}
}

View File

@ -42,9 +42,7 @@
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">{{
$t("userHeader.closeSidebar")
}}</span>
<span class="sr-only">Close sidebar</span>
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
</button>
</div>
@ -75,13 +73,13 @@
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
<span class="sr-only">Open sidebar</span>
<Bars3Icon class="size-6" aria-hidden="true" />
</button>
<div
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
>
{{ $t("account.title") }}
Account
</div>
</div>
@ -102,7 +100,6 @@ import {
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
const router = useRouter();
const { t } = useI18n();
const sidebarOpen = ref(false);
router.afterEach(() => {
@ -110,6 +107,6 @@ router.afterEach(() => {
});
useHead({
title: t("account.title"),
title: "Account",
});
</script>

View File

@ -1,108 +1,90 @@
<template>
<div>
<div class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.devices.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.devices.subheader") }}
</p>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">Devices</h1>
<p class="mt-2 text-sm text-zinc-400">
All the devices authorized to access your Drop account.
</p>
</div>
</div>
<div
class="mt-8 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.devices.platform") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.devices.capabilities") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.devices.lastConnected") }}
</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="client in clients"
:key="client.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"
>
{{ client.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<span
class="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20"
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Platform
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Can Access
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Last Connected
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="client in clients"
:key="client.id"
class="even:bg-zinc-800"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ client.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ client.platform }}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="capability in client.capabilities"
:key="capability"
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"
>
<CheckIcon class="size-3" />
{{ capability }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime :date="client.lastConnected" />
</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="() => revokeClientWrapper(client.id)"
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<ul class="flex flex-col gap-y-2">
<li
v-for="capability in client.capabilities"
:key="capability"
class="inline-flex items-center gap-x-0.5"
>
<CheckIcon class="size-4" /> {{ capability }}
</li>
</ul>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ DateTime.fromISO(client.lastConnected).toRelative() }}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-3"
>
{{ $t("account.devices.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [client.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="clients.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.devices.noDevices") }}
</td>
</tr>
</tbody>
</table>
<button
class="text-red-600 hover:text-red-900"
@click="() => revokeClientWrapper(client.id)"
>
Revoke<span class="sr-only">, {{ client.name }}</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
@ -110,25 +92,16 @@
<script setup lang="ts">
import { CheckIcon } from "@heroicons/vue/24/outline";
import { DateTime } from "luxon";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore pending https://github.com/nitrojs/nitro/issues/2758
const clients = ref(await $dropFetch("/api/v1/user/client"));
const { t } = useI18n();
async function revokeClient(id: string) {
await $dropFetch(`/api/v1/user/client/${id}`, { method: "DELETE" });
}
// clients.value.push({
// id: "example-client",
// userId: "example-user",
// name: "Example Client",
// platform: "Windows",
// capabilities: ["TrackPlaytime"],
// lastConnected: new Date().toISOString(),
// });
function revokeClientWrapper(id: string) {
revokeClient(id)
.then(() => {
@ -139,8 +112,8 @@ function revokeClientWrapper(id: string) {
createModal(
ModalType.Notification,
{
title: t("errors.revokeClient"),
description: t("errors.revokeClientFull", String(e)),
title: "Failed to revoke client",
description: `Failed to revoke client: ${e}`,
},
(_, c) => c(),
);

View File

@ -1,94 +1 @@
<template>
<!-- go away eslint -->
<div />
<!-- I don't want to localize this -->
<!--
<div>
<div v-if="user" class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
Hello, {{ user.displayName }}!
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
Welcome to your Drop account. Here you can view and manage your account
information.
</p>
</div>
<div v-if="user" class="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
<div
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
>
<div class="p-6">
<h3 class="text-base font-semibold text-zinc-100">
Account Information
</h3>
<dl class="mt-4 space-y-4">
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Username</dt>
<dd class="text-sm text-zinc-100">{{ user.username }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Email</dt>
<dd class="text-sm text-zinc-100">{{ user.email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Account Type</dt>
<dd>
<span
:class="[
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
user.admin
? 'bg-blue-400/10 text-blue-400 ring-blue-400/20'
: 'bg-zinc-400/10 text-zinc-400 ring-zinc-400/20',
]"
>
{{ user.admin ? "Administrator" : "Standard User" }}
</span>
</dd>
</div>
</dl>
</div>
</div>
<div
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
>
<div class="p-6">
<h3 class="text-base font-semibold text-zinc-100">Account Actions</h3>
<div class="mt-4 space-y-3">
<button
type="button"
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
Change Password
</button>
<button
type="button"
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
Update Email
</button>
</div>
</div>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-[200px]">
<div class="text-zinc-400">Loading account information...</div>
</div>
</div>
-->
</template>
<script setup lang="ts">
definePageMeta({
layout: "default",
});
useHead({
title: "Account",
});
</script>
<template><div></div></template>

View File

@ -1,150 +1,3 @@
<template>
<div>
<div class="border-b border-zinc-800 pb-4 w-full">
<div class="flex items-center justify-between w-full">
<h2
class="text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.notifications.notifications") }}
</h2>
<button
:disabled="notifications.length === 0"
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800 disabled:hover:scale-100 disabled:hover:shadow-none"
@click="markAllAsRead"
>
<CheckIcon class="size-4" />
{{ $t("account.notifications.markAllAsRead") }}
</button>
</div>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.notifications.desc") }}
</p>
</div>
<div class="mt-4 space-y-4">
<div
v-for="notification in notifications"
:key="notification.id"
class="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
:class="{ 'opacity-75': notification.read }"
>
<div class="p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-base font-semibold text-zinc-100">
{{ notification.title }}
</h3>
<p class="mt-1 text-sm text-zinc-400">
{{ notification.description }}
</p>
<div class="mt-4 flex flex-wrap gap-2">
<NuxtLink
v-for="[name, href] in notification.actions.map((v) =>
v.split('|'),
)"
:key="href"
:href="href"
class="inline-flex items-center rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20 transition-all duration-200 hover:bg-blue-400/20 hover:scale-105 active:scale-95"
>
{{ name }}
</NuxtLink>
</div>
</div>
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2">
<span class="text-xs text-zinc-500">
<RelativeTime :date="notification.created" />
</span>
<button
v-if="!notification.read"
type="button"
class="inline-flex gap-x-1 items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20 transition-all duration-200 hover:bg-zinc-400/20 hover:scale-105 active:scale-95"
@click="markAsRead(notification.id)"
>
<CheckIcon class="size-3" />
{{ $t("account.notifications.markAsRead") }}
</button>
<button
type="button"
class="inline-flex gap-x-1 items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="deleteNotification(notification.id)"
>
<TrashIcon class="size-3" />
{{ $t("delete") }}
</button>
</div>
</div>
</div>
</div>
<div
v-if="notifications.length === 0"
class="rounded-xl border border-zinc-800 bg-zinc-900 p-8 text-center"
>
<p class="text-sm text-zinc-400">
{{ $t("account.notifications.none") }}
</p>
</div>
</div>
</div>
<div></div>
</template>
<script setup lang="ts">
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
import type { NotificationModel } from "~/prisma/client/models";
import type { SerializeObject } from "nitropack";
definePageMeta({
layout: "default",
});
const { t } = useI18n();
useHead({
title: t("account.notifications.title"),
});
// Fetch notifications
const notifications = ref<SerializeObject<NotificationModel>[]>([]);
async function fetchNotifications() {
const { data } = await useFetch("/api/v1/notifications");
notifications.value = data.value || [];
}
// Initial fetch
await fetchNotifications();
// Mark a notification as read
async function markAsRead(id: string) {
await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" });
const notification = notifications.value.find(
(n: SerializeObject<NotificationModel>) => n.id === id,
);
if (notification) {
notification.read = true;
}
}
// Mark all notifications as read
async function markAllAsRead() {
await $dropFetch("/api/v1/notifications/readall", { method: "POST" });
notifications.value.forEach(
(notification: SerializeObject<NotificationModel>) => {
notification.read = true;
},
);
}
// Delete a notification
async function deleteNotification(id: string) {
await $dropFetch(`/api/v1/notifications/${id}`, { method: "DELETE" });
const index = notifications.value.findIndex(
(n: SerializeObject<NotificationModel>) => n.id === id,
);
if (index !== -1) {
notifications.value.splice(index, 1);
}
}
</script>

View File

@ -1,229 +0,0 @@
<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

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

View File

@ -5,9 +5,9 @@
:model-value="currentlySelectedVersion"
@update:model-value="(value) => updateCurrentlySelectedVersion(value)"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("library.admin.import.version.version")
}}</ListboxLabel>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select version to import</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
@ -15,9 +15,9 @@
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
versions[currentlySelectedVersion]
}}</span>
<span v-else class="block truncate text-zinc-600">{{
$t("library.admin.import.selectDir")
}}</span>
<span v-else class="block truncate text-zinc-600"
>Please select a directory...</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
@ -79,20 +79,17 @@
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.setupCmd") }}</label
>Setup executable/command</label
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.setupDesc") }}
</p>
<p class="text-zinc-400 text-xs">Ran once when the game is installed</p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>(install_dir)/</span
>
{{ $t("library.admin.import.version.installDir") }}
</span>
<Combobox
as="div"
:value="versionSettings.setup"
@ -102,9 +99,7 @@
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="
$t('library.admin.import.version.setupPlaceholder')
"
:placeholder="'setup.exe'"
@change="setupProcessQuery = $event.target.value"
@blur="setupProcessQuery = ''"
/>
@ -176,7 +171,7 @@
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ $t("chars.quoted", { text: setupProcessQuery }) }}
"{{ setupProcessQuery }}"
</span>
<span
@ -211,11 +206,14 @@
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
>Setup mode</SwitchLabel
>
<SwitchDescription as="span" class="text-sm text-zinc-400"
>When enabled, this version does not have a launch command, and
simply runs the executable on the user's computer. Useful for games
that only distribute installer and not portable
files.</SwitchDescription
>
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
$t("library.admin.import.version.setupModeDesc")
}}</SwitchDescription>
</span>
<Switch
v-model="versionSettings.onlySetup"
@ -237,18 +235,16 @@
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.launchCmd") }}</label
>Launch executable/command</label
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.launchDesc") }}
</p>
<p class="text-zinc-400 text-xs">Executable to launch the game</p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>{{ $t("library.admin.import.version.installDir") }}</span
>(install_dir)/</span
>
<Combobox
as="div"
@ -259,9 +255,7 @@
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="
$t('library.admin.import.version.launchPlaceholder')
"
:placeholder="'game.exe'"
@change="launchProcessQuery = $event.target.value"
@blur="launchProcessQuery = ''"
/>
@ -333,7 +327,7 @@
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ $t("chars.quoted", { text: launchProcessQuery }) }}
"{{ launchProcessQuery }}"
</span>
<span
@ -367,7 +361,7 @@
</div>
<PlatformSelector v-model="versionSettings.platform">
{{ $t("library.admin.import.version.platform") }}
Version platform
</PlatformSelector>
<SwitchGroup as="div" class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
@ -375,12 +369,13 @@
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>Update mode</SwitchLabel
>
<SwitchDescription as="span" class="text-sm text-zinc-400"
>When enabled, these files will be installed on top of (overwriting)
the previous version's. If multiple "update modes" are chained
together, they are applied in order.</SwitchDescription
>
{{ $t("library.admin.import.version.updateMode") }}
</SwitchLabel>
<SwitchDescription as="span" class="text-sm text-zinc-400">
{{ $t("library.admin.import.version.updateModeDesc") }}
</SwitchDescription>
</span>
<Switch
v-model="versionSettings.delta"
@ -403,9 +398,7 @@
<DisclosureButton
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
>
<span class="text-base/7 font-semibold">
{{ $t("library.admin.import.version.advancedOptions") }}
</span>
<span class="text-base/7 font-semibold">Advanced options</span>
<span class="ml-6 flex h-7 items-center">
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
@ -427,12 +420,13 @@
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>Override UMU Launcher Game ID</SwitchLabel
>
<SwitchDescription as="span" class="text-sm text-zinc-400"
>By default, Drop uses a non-ID when launching with UMU
Launcher. In order to get the right patches for some games,
you may have to manually set this field.</SwitchDescription
>
{{ $t("library.admin.import.version.umuOverride") }}
</SwitchLabel>
<SwitchDescription as="span" class="text-sm text-zinc-400">
{{ $t("library.admin.import.version.umuOverrideDesc") }}
</SwitchDescription>
</span>
<Switch
v-model="umuIdEnabled"
@ -454,9 +448,8 @@
<label
for="umu-id"
class="block text-sm font-medium leading-6 text-zinc-100"
>UMU Launcher ID</label
>
{{ $t("library.admin.import.version.umuLauncherId") }}
</label>
<div class="mt-2">
<input
id="umu-id"
@ -474,7 +467,7 @@
</div>
<div v-else class="text-zinc-400">
{{ $t("library.admin.import.version.noAdv") }}
No advanced options for this configuration.
</div>
</DisclosurePanel>
</Disclosure>
@ -484,7 +477,7 @@
:loading="importLoading"
@click="startImport_wrapper"
>
{{ $t("library.admin.import.import") }}
Import
</LoadingButton>
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
<div class="flex">
@ -504,7 +497,7 @@
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
{{ $t("library.admin.import.version.loadingVersion") }}
Loading version metadata...
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
@ -521,6 +514,7 @@
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
</div>
</template>
@ -554,7 +548,7 @@ definePageMeta({
});
const router = useRouter();
const { t } = useI18n();
const route = useRoute();
const gameId = route.params.id.toString();
const versions = await $dropFetch(
@ -667,7 +661,7 @@ function startImport_wrapper() {
importLoading.value = true;
startImport()
.catch((error) => {
importError.value = error.statusMessage ?? t("errors.unknown");
importError.value = error.statusMessage ?? "An unknown error occurred.";
})
.finally(() => {
importLoading.value = false;

View File

@ -1,170 +1,148 @@
<template>
<div
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
>
<div
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
<div>
<!-- import games button -->
<NuxtLink
v-if="unimportedVersions !== undefined"
:href="
unimportedVersions.length > 0 ? `/admin/library/${game.id}/import` : ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
<!--start-->
<div>
<Listbox v-if="false" v-model="currentMode" as="div">
<div class="relative mt-2">
<ListboxButton
class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
>
<span class="col-start-1 row-start-1 truncate">{{
currentMode
}}</span>
{{
unimportedVersions.length > 0
? "Import version"
: "No versions to import"
}}
</NuxtLink>
<PencilIcon class="ml-auto size-5" />
<ChevronUpDownIcon
class="text-gray-500 size-5"
aria-hidden="true"
/>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ListboxOption
v-for="[value] in Object.entries(components)"
v-slot="{ active, selected }"
:key="value"
as="template"
:value="value"
>
<li
:class="[
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ value }}</span
>
<span
v-if="selected"
class="text-white absolute inset-y-0 right-0 flex items-center pr-4"
>
<PencilIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="pt-4 inline-flex gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div class="flex flex-wrap items-center justify-between sm:flex-nowrap">
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
<button
:class="[
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
value == currentMode
? 'bg-zinc-900 text-zinc-100'
: 'bg-transparent text-zinc-500',
]"
@click="() => (currentMode = value as GameEditorMode)"
>
<component :is="icon" class="size-4" />
{{ value }}
</button>
</div>
Version priority
</h3>
</div>
</div>
<div>
<!-- open in store button -->
<NuxtLink
:href="`/store/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{{ $t("library.admin.openStore") }}
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
<div class="mt-4 text-center w-full text-sm text-zinc-600">lowest</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
>
<template #item="{ element: item }: { element: GameVersion }">
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? "Upgrade mode" : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon class="cursor-move w-6 h-6 text-zinc-400 handle" />
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
no versions added
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">highest</div>
</div>
<component
:is="components[currentMode].editor"
v-model="game"
:unimported-versions="unimportedVersions"
/>
</div>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { GameEditorMetadata, GameEditorVersion } from "#components";
import {
ArrowTopRightOnSquareIcon,
DocumentIcon,
PencilIcon,
ServerStackIcon,
} from "@heroicons/vue/24/outline";
import type { Component } from "vue";
const route = useRoute();
const gameId = route.params.id.toString();
const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game/:id`,
{ params: { id: gameId } },
);
const game = ref(rawGame);
import { Bars3Icon, TrashIcon } from "@heroicons/vue/16/solid";
import type { GameVersion } from "~/prisma/client";
definePageMeta({
layout: "admin",
});
enum GameEditorMode {
Metadata = "Metadata",
Versions = "Versions",
// TODO implement UI for this
const route = useRoute();
const gameId = route.params.id.toString();
const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`,
);
const game = ref(rawGame);
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: gameId,
versions: game.value.versions.map((e) => e.versionName),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "There an error while updating the version order",
description: `Drop encountered an error while updating the version: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
}
}
const components: {
[key in GameEditorMode]: { editor: Component; icon: Component };
} = {
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
[GameEditorMode.Versions]: {
editor: GameEditorVersion,
icon: ServerStackIcon,
},
};
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
useHead({
// To do a title with the game name in it, we need some sort of watch
title: `${currentMode.value} - ${game.value.mName}`,
});
watch(currentMode, (v) => {
useHead({
title: `${v} - ${game.value.mName}`,
});
});
async function deleteVersion(versionName: string) {
try {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: gameId,
versionName: versionName,
},
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName),
1,
);
} catch (e) {
createModal(
ModalType.Notification,
{
title: "There an error while deleting the version",
description: `Drop encountered an error while deleting the version: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
}
}
</script>

View File

@ -5,25 +5,19 @@
:model="currentlySelectedGame"
@update:model-value="(value) => updateSelectedGame_wrapper(value)"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.import.selectGame") }}
</ListboxLabel>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
>Select game to import</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span v-if="currentlySelectedGame != -1" class="block truncate"
>{{ games.unimportedGames[currentlySelectedGame].game }}
<span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{
games.unimportedGames[currentlySelectedGame].library.name
}}</span
></span
>
<span v-else class="block truncate text-zinc-400">{{
$t("library.admin.import.selectDir")
<span v-if="currentlySelectedGame != -1" class="block truncate">{{
games.unimportedGames[currentlySelectedGame]
}}</span>
<span v-else class="block truncate text-zinc-400"
>Please select a directory...</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
@ -43,9 +37,9 @@
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="({ game, library }, gameIdx) in games.unimportedGames"
v-for="(game, gameIdx) in games.unimportedGames"
:key="game"
v-slot="{ active }"
v-slot="{ active, selected }"
as="template"
:value="gameIdx"
>
@ -57,20 +51,14 @@
>
<span
:class="[
gameIdx === currentlySelectedGame
? 'font-semibold'
: 'font-normal',
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ game }}
<span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{ library.name }}</span
></span
>{{ game }}</span
>
<span
v-if="gameIdx === currentlySelectedGame"
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
@ -84,34 +72,6 @@
</transition>
</div>
</Listbox>
<div class="flex items-center justify-between gap-x-8">
<span class="flex grow flex-col">
<label
id="bulkImport-label"
class="text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.import.bulkImportTitle") }}</label
>
<span id="bulkImport-description" class="text-sm text-zinc-400">{{
$t("library.admin.import.bulkImportDescription")
}}</span>
</span>
<div
class="group relative inline-flex w-11 shrink-0 rounded-full bg-zinc-800 p-0.5 inset-ring inset-ring-zinc-100/5 outline-offset-2 outline-blue-600 transition-colors duration-200 ease-in-out has-checked:bg-blue-600 has-focus-visible:outline-2"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-zinc-100/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5"
/>
<input
id="bulkImport"
v-model="bulkImportMode"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
name="bulkImport"
aria-labelledby="bulkImport-label"
aria-describedby="bulkImport-description"
/>
</div>
</div>
<div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4">
<!-- without metadata option -->
@ -120,8 +80,7 @@
class="w-fit"
:loading="importLoading"
@click="() => importGame_wrapper(false)"
>
{{ $t("library.admin.import.withoutMetadata") }}
>Import without metadata
</LoadingButton>
</div>
@ -130,7 +89,7 @@
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold"
>
<div class="h-[1px] grow bg-zinc-800" />
{{ $t("auth.signin.or") }}
OR
<div class="h-[1px] grow bg-zinc-800" />
</div>
@ -141,7 +100,7 @@
<label
for="searchTerm"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.import.search") }}</label
>Search</label
>
<div class="mt-2 flex">
<div class="-mr-px grid grow grid-cols-1 focus-within:relative">
@ -151,7 +110,7 @@
type="text"
name="searchTerm"
class="col-start-1 row-start-1 block w-full rounded-l-md bg-zinc-950 py-1.5 px-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:placeholder="$t('library.admin.import.searchPlaceholder')"
placeholder="John Smith"
/>
</div>
<LoadingButton
@ -164,7 +123,7 @@
class="-ml-0.5 size-4 text-gray-400"
aria-hidden="true"
/>
{{ $t("library.admin.import.search") }}
Search
</LoadingButton>
</div>
</form>
@ -176,7 +135,7 @@
>
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
>Select game</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
@ -186,9 +145,9 @@
v-if="currentlySelectedMetadata != -1"
:game="metadataResults[currentlySelectedMetadata]"
/>
<span v-else class="block truncate text-zinc-600">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</span>
<span v-else class="block truncate text-zinc-600"
>Please select a game...</span
>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
@ -232,7 +191,7 @@
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
{{ $t("library.admin.import.loading") }}
Loading game results...
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
@ -249,6 +208,7 @@
fill="currentFill"
/>
</svg>
<span class="sr-only">Loading...</span>
</div>
<div
@ -273,8 +233,7 @@
:loading="importLoading"
:disabled="currentlySelectedMetadata === -1"
@click="() => importGame_wrapper()"
>
{{ $t("library.admin.import.import") }}
>Import
</LoadingButton>
<div
@ -315,52 +274,43 @@ definePageMeta({
layout: "admin",
});
const { t } = useI18n();
const games = await $dropFetch("/api/v1/admin/import/game");
const rawGames = await $dropFetch("/api/v1/admin/import/game");
const games = ref(rawGames);
const currentlySelectedGame = ref(-1);
const gameSearchResultsLoading = ref(false);
const gameSearchResultsError = ref<string | undefined>();
const gameSearchTerm = ref("");
const gameSearchLoading = ref(false);
const bulkImportMode = ref(false);
async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value) return;
currentlySelectedGame.value = value;
if (currentlySelectedGame.value == -1) return;
const option = games.value.unimportedGames[currentlySelectedGame.value];
if (!option) return;
const game = games.unimportedGames[currentlySelectedGame.value];
if (!game) return;
metadataResults.value = undefined;
currentlySelectedMetadata.value = -1;
gameSearchTerm.value = option.game;
gameSearchTerm.value = game;
await searchGame();
}
async function searchGame() {
gameSearchResultsError.value = undefined;
gameSearchLoading.value = true;
try {
const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
);
metadataResults.value = results;
gameSearchLoading.value = false;
} catch (e) {
gameSearchLoading.value = false;
throw e;
}
const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
);
metadataResults.value = results;
gameSearchLoading.value = false;
}
function updateSelectedGame_wrapper(value: number) {
gameSearchResultsLoading.value = true;
updateSelectedGame(value)
.catch((error) => {
gameSearchResultsError.value = error.statusMessage || t("errors.unknown");
gameSearchResultsError.value =
error.statusMessage || "An unknown error occurred";
})
.finally(() => {
gameSearchResultsLoading.value = false;
@ -374,38 +324,28 @@ const router = useRouter();
const importLoading = ref(false);
const importError = ref<string | undefined>();
async function importGame(useMetadata: boolean) {
if (!metadataResults.value && useMetadata) return;
async function importGame(metadata: boolean) {
if (!metadataResults.value && metadata) return;
const metadata =
useMetadata && metadataResults.value
? metadataResults.value[currentlySelectedMetadata.value]
: undefined;
const option = games.value.unimportedGames[currentlySelectedGame.value];
const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
const game = await $dropFetch("/api/v1/admin/import/game", {
method: "POST",
body: {
path: option.game,
library: option.library.id,
metadata,
path: games.unimportedGames[currentlySelectedGame.value],
metadata:
metadata && metadataResults.value
? metadataResults.value[currentlySelectedMetadata.value]
: undefined,
},
});
if (!bulkImportMode.value) {
router.push(`/admin/task/${taskId}`);
} else {
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
currentlySelectedGame.value = -1;
gameSearchResultsError.value = undefined;
}
router.push(`/admin/library/${game.id}`);
}
function importGame_wrapper(metadata = true) {
importLoading.value = true;
importError.value = undefined;
importGame(metadata)
.catch((error) => {
importError.value = error?.statusMessage || t("errors.unknown");
importError.value = error?.statusMessage || "An unknown error occurred.";
})
.finally(() => {
importLoading.value = false;

View File

@ -1,32 +1,23 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.gameLibrary") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.subheader") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/library/sources"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<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>
</div>
<div class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
Library
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-gray-500 sm:text-md/8"
>
As you add folders to your library, Drop will detect it and prompt you
to import it. Each game needs to be imported before you can import a
version.
</p>
</div>
<div v-if="toImport" class="rounded-md bg-blue-600/10 p-4">
<div
v-if="libraryState.unimportedGames.length > 0"
class="rounded-md bg-blue-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
@ -36,22 +27,15 @@
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
{{ $t("library.admin.detectedGame") }}
Drop has detected you have new games to import.
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
href="/admin/library/import"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
Import
<span aria-hidden="true"> &rarr;</span>
</NuxtLink>
</p>
</div>
@ -64,7 +48,7 @@
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.search')"
placeholder="Search library..."
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
@ -78,86 +62,47 @@
<li
v-for="game in filteredLibraryGames"
:key="game.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-700 rounded-lg bg-zinc-950/20 text-left shadow"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
class="h-16 w-16 flex-shrink-0 rounded-md"
:src="useObject(game.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
<h3 class="text-sm font-medium text-zinc-100 font-display">
{{ game.mName }}
<button
type="button"
:class="[
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
game.featured
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
]"
@click="() => featureGame(game.id)"
>
<svg
v-if="gameFeatureLoading[game.id]"
aria-hidden="true"
:class="[
game.featured ? ' fill-zinc-900' : 'fill-zinc-100',
'size-3 text-transparent animate-spin',
]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<StarIcon v-else class="size-3" aria-hidden="true" />
</button>
<span
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ game.library!.name }}</span
class="ml-2 inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ game.metadataSource }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dt class="sr-only">Short Description</dt>
<dd class="text-sm text-zinc-400">
{{ game.mShortDescription }}
</dd>
<dt class="sr-only">
{{ $t("library.admin.metadataProvider") }}
</dt>
<dt class="sr-only">Metadata provider</dt>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/${game.id}`"
class="w-fit rounded-md bg-blue-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open with Library &rarr;
</NuxtLink>
<NuxtLink
:href="`/admin/metadata/games/${game.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
Open with Metadata &rarr;
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteGame(game.id)"
>
{{ $t("delete") }}
Delete
</button>
</div>
</div>
@ -176,22 +121,15 @@
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
{{ $t("library.admin.detectedVersion") }}
Drop has detected you have new verions of this game to import.
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
:href="`/admin/library/${game.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
Import
<span aria-hidden="true"> &rarr;</span>
</NuxtLink>
</p>
</div>
@ -210,25 +148,7 @@
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-600">
{{ $t("library.admin.version.noVersions") }}
</h3>
</div>
</div>
</div>
<div
v-if="game.notifications.offline"
class="rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<ExclamationCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ $t("library.admin.offline") }}
You have no versions of this game available.
</h3>
</div>
</div>
@ -239,103 +159,35 @@
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
No results
</p>
<p
v-if="
filteredLibraryGames.length == 0 &&
libraryGames.length == 0 &&
libraryState.hasLibraries
"
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
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>
No games imported
</p>
</ul>
</div>
</template>
<script setup lang="ts">
import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import {
ArrowTopRightOnSquareIcon,
InformationCircleIcon,
StarIcon,
} from "@heroicons/vue/20/solid";
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid";
import { InformationCircleIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("library.admin.title"),
title: "Libraries",
});
const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library");
type LibraryStateGame = (typeof libraryState.games)[number]["game"];
const toImport = ref(
Object.values(libraryState.unimportedGames).flat().length > 0,
);
const libraryGames = ref<
Array<
LibraryStateGame & {
status: "online" | "offline";
hasNotifications?: boolean;
notifications: {
noVersions?: boolean;
toImport?: boolean;
offline?: boolean;
};
}
>
>(
const libraryGames = ref(
libraryState.games.map((e) => {
if (e.status == "offline") {
return {
...e.game,
status: "offline" as const,
hasNotifications: true,
notifications: {
offline: true,
},
};
}
const noVersions = e.status.noVersions;
const toImport = e.status.unimportedVersions.length > 0;
@ -346,7 +198,6 @@ const libraryGames = ref<
toImport,
},
hasNotifications: noVersions || toImport,
status: "online" as const,
};
}),
);
@ -365,31 +216,8 @@ const filteredLibraryGames = computed(() =>
);
async function deleteGame(id: string) {
await $dropFetch(`/api/v1/admin/game/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
await $dropFetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" });
const index = libraryGames.value.findIndex((e) => e.id === id);
libraryGames.value.splice(index, 1);
toImport.value = true;
}
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
async function featureGame(id: string) {
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);
const game = libraryGames.value[gameIndex];
gameFeatureLoading.value[game.id] = true;
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.id,
},
body: { featured: !game.featured },
failTitle: "Failed to feature/unfeature game",
});
libraryGames.value[gameIndex].featured = !game.featured;
gameFeatureLoading.value[game.id] = false;
}
</script>

View File

@ -1,462 +0,0 @@
<template>
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.sources.sources") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.sources.desc") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (actionSourceOpen = true)"
>
{{ $t("common.create") }}
</button>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-zinc-700">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("type") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.working") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("options") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">{{ $t("common.edit") }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(source, sourceIdx) in sources"
:key="source.id"
class="even:bg-zinc-800"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ source.name }}
</td>
<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
v-if="source.working"
class="size-5 text-green-500"
/>
<XMarkIcon v-else class="size-5 text-red-500" />
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.options }}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-3 text-right text-sm font-medium space-x-2"
>
<button
class="text-blue-500 hover:text-blue-400"
@click="() => edit(sourceIdx)"
>
{{ $t("common.edit") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
<button
class="text-red-500 hover:text-red-400"
@click="() => deleteSource(sourceIdx)"
>
{{ $t("delete") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<ModalTemplate v-model="actionSourceOpen">
<template #default>
<div>
<DialogTitle
v-if="createMode"
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.create") }}
</DialogTitle>
<DialogTitle
v-else
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.edit") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.sources.createDesc") }}
</p>
</div>
<form
class="mt-2 space-y-4"
@submit.prevent="() => performActionSource_wrapper()"
>
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("common.name") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.nameDesc") }}
</p>
<div class="mt-2">
<input
id="name"
v-model="sourceName"
name="name"
type="text"
autocomplete="name"
:placeholder="$t('library.admin.sources.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 v-if="createMode">
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("type")
}}</label>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.typeDesc") }}
</p>
<RadioGroup v-model="currentSourceOption" class="mt-2">
<RadioGroupLabel class="sr-only">{{
$t("type")
}}</RadioGroupLabel>
<div class="space-y-4">
<RadioGroupOption
v-for="[source, metadata] in optionsMetadataIter"
:key="source"
v-slot="{ checked }"
as="template"
:value="source"
>
<div
:class="[
'relative block cursor-pointer bg-zinc-800 rounded-lg border border-zinc-900 px-2 py-2 shadow-sm focus:outline-none sm:flex sm:justify-between',
]"
>
<span class="flex items-center gap-x-4">
<div>
<component
:is="metadata.icon"
class="size-12 bg-zinc-900 rounded-xl p-3 text-zinc-400"
/>
</div>
<span class="flex flex-col text-sm">
<RadioGroupLabel
as="span"
class="font-semibold text-zinc-100"
>{{ 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"
>
{{ 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
:class="[
checked ? 'ring-2 ring-blue-600' : '',
'pointer-events-none absolute -inset-px rounded-lg',
]"
aria-hidden="true"
/>
</div>
</RadioGroupOption>
</div>
</RadioGroup>
</div>
<div class="h-[1px] w-full bg-zinc-700 rounded-full" />
<component
:is="optionUIs[currentSourceOption]"
v-model="sourceConfig"
/>
<input type="submit" class="hidden" />
</form>
<div v-if="modalError" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ modalError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="modalLoading"
:disabled="modalLoading"
class="w-full sm:w-fit"
@click="() => performActionSource_wrapper()"
>
{{ createMode ? $t("common.create") : $t("common.save") }}
</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="
() => {
editIndex = undefined;
close();
}
"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
/**
* I did something a little cursed for this
* To avoid making a separate modal for saving, we
* instead set the index of the source we want to edit
* and there's a bunch of checks everywhere to switch
* between 'create' and 'edit'
*/
import {
DropLogo,
SourceOptionsFilesystem,
SourceOptionsFlatFilesystem,
} from "#components";
import {
DialogTitle,
RadioGroup,
RadioGroupDescription,
RadioGroupLabel,
RadioGroupOption,
} from "@headlessui/vue";
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";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
definePageMeta({
layout: "admin",
});
useHead({
title: "Library Sources",
});
const { t } = useI18n();
// Optional token for setup wizard
const { token = undefined } = defineProps<{ token?: string }>();
const headers = token ? { Authorization: token } : undefined;
const sources = ref(
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources", {
headers,
}),
);
const editIndex = ref<undefined | number>(undefined);
const createMode = computed(() => editIndex.value === undefined);
const actionSourceOpen = ref(false);
const currentSourceOption = ref<LibraryBackend>("Filesystem");
const sourceName = ref("");
const sourceConfig = ref<object>({});
const modalError = ref<undefined | string>();
const modalLoading = ref(false);
const optionUIs: { [key in LibraryBackend]: Component } = {
Filesystem: SourceOptionsFilesystem,
FlatFilesystem: SourceOptionsFlatFilesystem,
};
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"),
docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
},
};
const optionsMetadataIter = Object.entries(optionsMetadata);
async function performActionSource() {
const createMode = editIndex.value === undefined;
const source = await $dropFetch<WorkingLibrarySource>(
"/api/v1/admin/library/sources",
{
body: {
id: createMode ? undefined : sources.value[editIndex.value!].id,
name: sourceName.value,
backend: createMode ? currentSourceOption.value : undefined,
options: sourceConfig.value,
},
method: createMode ? "POST" : "PATCH",
headers,
},
);
if (createMode) {
sources.value.push(source);
} else {
sources.value[editIndex.value!] = source;
}
}
function performActionSource_wrapper() {
modalError.value = undefined;
modalLoading.value = true;
performActionSource()
.then(() => {
actionSourceOpen.value = false;
sourceConfig.value = {};
sourceName.value = "";
})
.catch((e) => {
if (e instanceof FetchError) {
modalError.value = e.statusMessage ?? e.message;
} else {
modalError.value = e as string;
}
})
.finally(() => {
modalLoading.value = false;
});
}
function edit(index: number) {
const source = sources.value[index];
if (!source) return;
sourceName.value = source.name;
sourceConfig.value = source.options! as object;
editIndex.value = index;
actionSourceOpen.value = true;
}
async function deleteSource(index: number) {
const source = sources.value[index];
if (!source) return;
try {
await $dropFetch("/api/v1/admin/library/sources", {
method: "DELETE",
body: { id: source.id },
headers,
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.library.source.delete.title"),
description: t("errors.library.source.delete.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
}
sources.value.splice(index, 1);
}
</script>

View File

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

View File

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

View File

@ -1,14 +1,16 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game!">
<div class="grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col">
<div>
<div
v-if="game && unimportedVersions !== undefined"
class="grow flex flex-col gap-y-8"
>
<div class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col">
<div
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
>
<div class="inline-flex items-center gap-4">
<!-- icon image -->
<img :src="coreMetadataIconUrl" class="size-20" />
<img :src="useObject(game.mIconObjectId)" class="size-20" />
<div>
<h1 class="text-5xl font-bold font-display text-zinc-100">
{{ game.mName }}
@ -23,31 +25,10 @@
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showEditCoreMetadata = true)"
>
{{ $t("common.edit") }} <PencilIcon class="size-4" />
Edit <PencilIcon class="size-4" />
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" />
<div class="flex flex-col">
<label
for="releaseDate"
class="text-sm/6 font-medium text-zinc-100"
>
{{ $t("library.admin.game.editReleaseDate") }}
</label>
<div class="mt-2">
<input
id="releaseDate"
v-model="releaseDate"
type="date"
name="releaseDate"
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>
<!-- image carousel pick -->
<div class="border-b border-zinc-700">
<div class="border-b border-zinc-700 py-4">
@ -56,10 +37,11 @@
>
<div class="ml-4 mt-4">
<h3 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.game.imageCarousel") }}
Image Carousel
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
{{ $t("library.admin.game.imageCarouselDescription") }}
Customise what images and what order are shown on the store
page.
</p>
</div>
<div class="ml-4 mt-4 shrink-0">
@ -68,7 +50,7 @@
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showAddCarouselModal = true)"
>
{{ $t("library.admin.game.addImageCarousel") }}
Add from image library
</button>
</div>
</div>
@ -77,7 +59,7 @@
v-if="game.mImageCarouselObjectIds.length == 0"
class="text-zinc-400 text-center py-8"
>
{{ $t("library.admin.game.imageCarouselEmpty") }}
No images added to the carousel yet.
</div>
<draggable
@ -94,10 +76,10 @@
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => removeImageFromCarousel(element)"
>
{{ $t("library.admin.game.removeImageCarousel") }}
Remove image
</button>
</div>
</div>
@ -115,18 +97,13 @@
>
<div>
<CheckIcon
v-if="descriptionSaving == DescriptionSavingState.NotLoading"
v-if="descriptionSaving == 0"
class="size-5 text-zinc-100"
/>
<div
v-else-if="descriptionSaving == DescriptionSavingState.Waiting"
>
<div v-else-if="descriptionSaving == 1">
<PencilIcon class="animate-pulse size-5 text-zinc-100" />
</div>
<div
v-else-if="descriptionSaving == DescriptionSavingState.Loading"
role="status"
>
<div v-else-if="descriptionSaving == 2" role="status">
<svg
aria-hidden="true"
class="w-5 h-5 text-transparent animate-spin fill-white"
@ -143,7 +120,7 @@
fill="currentFill"
/>
</svg>
<span class="sr-only">{{ $t("common.srLoading") }}</span>
<span class="sr-only">Loading...</span>
</div>
</div>
@ -196,8 +173,36 @@
</div>
</div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:fixed lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col lg:right-0 gap-y-8 px-6 py-4"
>
<!-- toolbar -->
<div class="inline-flex justify-end items-stretch gap-x-4">
<!-- open in library button -->
<NuxtLink
:href="`/admin/library/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Library &rarr;
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
<!-- open in store button -->
<NuxtLink
:href="`/store/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Store
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
</div>
<!-- image library -->
<div>
<div class="border-b border-zinc-800 pb-3">
@ -208,10 +213,11 @@
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
{{ $t("library.admin.game.imageLibrary") }}
Image library
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
{{ $t("library.admin.game.imageLibraryDescription") }}
Please note all images uploaded are accessible to all users
through browser dev-tools.
</p>
</div>
<div class="flex-shrink-0">
@ -220,7 +226,7 @@
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showUploadModal = true)"
>
{{ $t("upload") }}
Upload
</button>
</div>
</div>
@ -238,25 +244,25 @@
<button
v-if="image !== game.mBannerObjectId"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => updateBannerImage(image)"
>
{{ $t("library.admin.game.setBanner") }}
Set as banner
</button>
<button
v-if="image !== game.mCoverObjectId"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => updateCoverImage(image)"
>
{{ $t("library.admin.game.setCover") }}
Set as cover
</button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteImage(image)"
>
{{ $t("library.admin.game.deleteImage") }}
Delete image
</button>
</div>
<div
@ -264,44 +270,34 @@
image === game.mBannerObjectId ||
image === game.mCoverObjectId
"
class="absolute bottom-0 left-0 flex flex-row gap-x-1 p-1"
class="absolute bottom-0 left-0 bg-zinc-950/75 text-zinc-100 text-sm font-semibold px-2 py-1 rounded-tr"
>
<span
v-for="[key] of (
[
[
$t('library.admin.game.currentBanner'),
image === game.mBannerObjectId,
],
[
$t('library.admin.game.currentCover'),
image === game.mCoverObjectId,
],
] as const
).filter((e) => e[1])"
:key="key"
class="inline-flex items-center rounded-full bg-blue-900 px-2 py-1 text-xs font-medium text-blue-100"
>{{ key }}</span
>
current
{{
[
image === game.mBannerObjectId ? "banner" : undefined,
image === game.mCoverObjectId ? "cover" : undefined,
]
.filter((e) => e)
.join(" & ")
}}
</div>
</div>
</div>
</div>
</div>
</div>
<ModalUploadFile
<UploadFileDialog
v-model="showUploadModal"
:options="{ id: game.id }"
accept="image/*"
endpoint="/api/v1/admin/game/image"
:multiple="true"
@upload="(result: GameModel) => uploadAfterImageUpload(result)"
@upload="(result: Game) => uploadAfterImageUpload(result)"
/>
<ModalTemplate v-model="showAddCarouselModal">
<template #default>
<div
class="grid grid-cols-2 grid-flow-dense gap-4 max-h-[70vh] overflow-y-auto p-4"
>
<div class="grid grid-cols-2 grid-flow-dense gap-4">
<div
v-for="(image, imageIdx) in validAddCarouselImages"
:key="imageIdx"
@ -313,10 +309,10 @@
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => addImageToCarousel(image)"
>
{{ $t("add") }}
Add
</button>
</div>
</div>
@ -324,7 +320,7 @@
v-if="validAddCarouselImages.length == 0"
class="text-zinc-400 col-span-2"
>
{{ $t("library.admin.game.addCarouselNoImages") }}
No images to add.
</div>
</div>
</template>
@ -332,10 +328,10 @@
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false"
>
{{ $t("common.close") }}
Cancel
</button>
</template>
</ModalTemplate>
@ -353,10 +349,10 @@
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => insertImageAtCursor(image)"
>
{{ $t("common.insert") }}
Insert
</button>
</div>
</div>
@ -364,7 +360,7 @@
v-if="game.mImageLibraryObjectIds.length == 0"
class="text-zinc-400 col-span-2"
>
{{ $t("library.admin.game.addDescriptionNoImages") }}
No images to add.
</div>
</div>
</template>
@ -372,10 +368,10 @@
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="showAddImageDescriptionModal = false"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false"
>
{{ $t("cancel") }}
Cancel
</button>
</template>
</ModalTemplate>
@ -390,14 +386,14 @@
type="button"
class="cursor-pointer relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{{ $t("upload") }}
Upload
</span>
<input
id="file-upload"
accept="image/*"
class="hidden"
type="file"
@change="(e: Event) => coreMetadataUploadFiles(e as any)"
@change="(e) => coreMetadataUploadFiles(e as any)"
/>
</label>
</div>
@ -407,7 +403,7 @@
<label
for="name"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.game.editGameName") }}</label
>Game Name</label
>
<div class="mt-2">
<input
@ -423,7 +419,7 @@
<label
for="description"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.game.editGameDescription") }}</label
>Game Description</label
>
<div class="mt-2">
<input
@ -445,15 +441,15 @@
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => coreMetadataUpdate_wrapper()"
>
{{ $t("common.save") }}
Save
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
@click="showEditCoreMetadata = false"
>
{{ $t("cancel") }}
Cancel
</button>
</template>
</ModalTemplate>
@ -461,16 +457,19 @@
</template>
<script setup lang="ts">
import type { GameModel, GameTagModel } from "~/prisma/client/models";
import type { Game } from "~/prisma/client";
import { micromark } from "micromark";
import {
ArrowTopRightOnSquareIcon,
CheckIcon,
DocumentIcon,
PencilIcon,
PhotoIcon,
} from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
definePageMeta({
layout: "admin",
});
const showUploadModal = ref(false);
const showAddCarouselModal = ref(false);
@ -478,74 +477,22 @@ const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true);
type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
const game = defineModel<ModelType>() as Ref<ModelType>;
if (!game.value)
throw createError({
statusCode: 500,
statusMessage: "Game not provided to editor component",
});
const currentTags = ref<{ [key: string]: boolean }>(
Object.fromEntries(game.value.tags.map((e) => [e.id, true])),
);
const tags = (await $dropFetch("/api/v1/admin/tags")).map(
(e) => ({ name: e.name, param: e.id }) satisfies StoreSortOption,
const route = useRoute();
const gameId = route.params.id.toString();
const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`,
);
const game = ref(rawGame);
watch(
currentTags,
async (v) => {
await $dropFetch(`/api/v1/admin/game/:id/tags`, {
method: "PATCH",
params: {
id: game.value.id,
},
body: { tags: Object.keys(v) },
failTitle: "Failed to update game tags",
});
},
{ deep: true },
);
const releaseDate = ref(
game.value.mReleased
? new Date(game.value.mReleased).toISOString().substring(0, 10)
: "",
);
watch(releaseDate, async (newDate) => {
const body: PatchGameBody = {};
if (newDate) {
const parsed = new Date(newDate);
if (!isNaN(parsed.getTime())) {
body.mReleased = parsed;
}
}
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.value.id,
},
body,
failTitle: "Failed to update release date",
});
});
const { t } = useI18n();
// I don't know why I split these fields off.
const coreMetadataName = ref(game.value.mName);
const coreMetadataDescription = ref(game.value.mShortDescription);
const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId));
const coreMetadataIconFileUpload = ref<FileList | undefined>();
const coreMetadataLoading = ref(false);
function coreMetadataUploadFiles(e: InputEvent) {
if (coreMetadataIconUrl.value.startsWith("blob")) {
console.log("freed object URL");
URL.revokeObjectURL(coreMetadataIconUrl.value);
}
@ -556,9 +503,9 @@ function coreMetadataUploadFiles(e: InputEvent) {
createModal(
ModalType.Notification,
{
title: t("errors.upload.title"),
description: t("errors.upload.description", [t("errors.unknown")]),
buttonText: t("common.close"),
title: "Failed to upload file",
description: "Drop couldn't upload this file.",
buttonText: "Close",
},
(e, c) => c(),
);
@ -575,16 +522,14 @@ async function coreMetadataUpdate() {
formData.append("icon", newIcon);
}
formData.append("id", game.value.id);
formData.append("name", coreMetadataName.value);
formData.append("description", coreMetadataDescription.value);
const result = await $dropFetch(
`/api/v1/admin/game/${game.value.id}/metadata`,
{
method: "POST",
body: formData,
},
);
const result = await $dropFetch(`/api/v1/admin/game/metadata`, {
method: "POST",
body: formData,
});
return result;
}
@ -595,20 +540,18 @@ function coreMetadataUpdate_wrapper() {
createModal(
ModalType.Notification,
{
title: t("errors.game.metadata.title"),
description: t("errors.game.metadata.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
title: "Failed to update metadata",
description: `Drop failed to update the game's metadata: ${
e?.statusMessage || "An unknown error occurred. "
}`,
buttonText: "Close",
},
(e, c) => c(),
);
})
.then((newGame) => {
console.log(newGame);
if (!newGame) return;
Object.assign(game.value, newGame);
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);
})
.finally(() => {
coreMetadataLoading.value = false;
@ -623,44 +566,35 @@ const descriptionEditor = ref<HTMLTextAreaElement | undefined>();
// 0 is not loading
// 1 is waiting for stop
// 2 is loading
enum DescriptionSavingState {
NotLoading,
Waiting,
Loading,
}
const descriptionSaving = ref<DescriptionSavingState>(
DescriptionSavingState.NotLoading,
);
const descriptionSaving = ref<number>(0);
let savingTimeout: undefined | NodeJS.Timeout;
type PatchGameBody = Partial<GameModel>;
watch(descriptionHTML, (_v) => {
descriptionSaving.value = DescriptionSavingState.Waiting;
console.log(game.value.mDescription);
descriptionSaving.value = 1;
if (savingTimeout) clearTimeout(savingTimeout);
savingTimeout = setTimeout(async () => {
try {
descriptionSaving.value = DescriptionSavingState.Loading;
await $dropFetch(`/api/v1/admin/game/:id`, {
descriptionSaving.value = 2;
await $dropFetch("/api/v1/admin/game", {
method: "PATCH",
params: {
id: game.value.id,
},
body: {
id: gameId,
mDescription: game.value.mDescription,
} satisfies PatchGameBody,
},
});
descriptionSaving.value = DescriptionSavingState.NotLoading;
descriptionSaving.value = 0;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.description.title"),
description: t("errors.game.description.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
title: "Failed to update game description",
description: `Drop failed to update the game description: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred."
}`,
buttonText: "Close",
},
(e, c) => c(),
);
@ -688,25 +622,24 @@ function insertImageAtCursor(id: string) {
async function updateBannerImage(id: string) {
try {
if (game.value.mBannerObjectId == id) return;
const { mBannerObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
const { mBannerObjectId } = await $dropFetch("/api/v1/admin/game", {
method: "PATCH",
params: {
id: game.value.id,
},
body: {
mBannerObjectId: id,
} satisfies PatchGameBody,
id: gameId,
mBannerId: id,
},
});
game.value.mBannerObjectId = mBannerObjectId;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.banner.title"),
description: t("errors.game.banner.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
title: "There an error while updating the banner image",
description: `Drop encountered an error while updating the banner image: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
@ -716,25 +649,24 @@ async function updateBannerImage(id: string) {
async function updateCoverImage(id: string) {
try {
if (game.value.mCoverObjectId == id) return;
const { mCoverObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
const { mCoverObjectId } = await $dropFetch("/api/v1/admin/game", {
method: "PATCH",
params: {
id: game.value.id,
},
body: {
mCoverObjectId: id,
} satisfies PatchGameBody,
id: gameId,
mCoverId: id,
},
});
game.value.mCoverObjectId = mCoverObjectId;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.cover.title"),
description: t("errors.game.cover.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
title: "There an error while updating the cover image",
description: `Drop encountered an error while updating the cover image: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
@ -759,23 +691,27 @@ async function deleteImage(id: string) {
createModal(
ModalType.Notification,
{
title: t("errors.game.deleteImage.title"),
description: t("errors.game.deleteImage.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
title: "There an error while deleting the image",
description: `Drop encountered an error while deleting the image: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);
}
}
async function uploadAfterImageUpload(result: GameModel) {
async function uploadAfterImageUpload(result: Game) {
if (!game.value) return;
game.value.mImageLibraryObjectIds = result.mImageLibraryObjectIds;
}
function addImageToCarousel(id: string) {
showAddCarouselModal.value = false;
game.value.mImageCarouselObjectIds.push(id);
updateImageCarousel();
}
@ -790,24 +726,23 @@ function removeImageFromCarousel(id: string) {
async function updateImageCarousel() {
try {
await $dropFetch(`/api/v1/admin/game/:id`, {
await $dropFetch("/api/v1/admin/game", {
method: "PATCH",
params: {
id: game.value.id,
},
body: {
mImageCarouselObjectIds: game.value.mImageCarouselObjectIds,
} satisfies PatchGameBody,
id: gameId,
mImageCarousel: game.value.mImageCarouselObjectIds,
},
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.carousel.title"),
description: t("errors.game.carousel.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
title: "There an error while updating the image carousel",
description: `Drop encountered an error while updating image carousel: ${
// @ts-expect-error attempt to get statusMessage on error
e?.statusMessage ?? "An unknown error occurred"
}`,
buttonText: "Close",
},
(e, c) => c(),
);

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