mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
Compare commits
83 Commits
image-impo
...
ef244edd1a
| Author | SHA1 | Date | |
|---|---|---|---|
| ef244edd1a | |||
| b083c1e00a | |||
| 5f2cb75666 | |||
| 2a23f4d14c | |||
| b20d355527 | |||
| fa9620eac1 | |||
| a201b62c04 | |||
| 9bf164ab77 | |||
| 97c6f3490c | |||
| f5cb856d3d | |||
| f331661c14 | |||
| 2087531ace | |||
| 4c9a2c681a | |||
| 55878bdf5f | |||
| 67de1f6c02 | |||
| 2db8e753b7 | |||
| b4f9b77809 | |||
| 0b9a715bf2 | |||
| 1002265000 | |||
| 37a2dff0dd | |||
| 799cd6c394 | |||
| 2a005a2222 | |||
| 3942d5c442 | |||
| 5c1b0e6c1e | |||
| d84c70a05f | |||
| bfd5c8e761 | |||
| 3311aa7274 | |||
| fcfc30e5df | |||
| 7266d0485b | |||
| a520d52ad3 | |||
| aa1de921ee | |||
| bfeacbbdfe | |||
| cf3a458bdf | |||
| ca7a89bbcf | |||
| d323816b9e | |||
| 367d349a68 | |||
| 8efddc07bc | |||
| afce9f159a | |||
| 3af00e085e | |||
| b7d685814b | |||
| fd828d5b50 | |||
| b33e27e446 | |||
| c97a56eb42 | |||
| 5e5519ece7 | |||
| f1957a418c | |||
| 322af0b4ca | |||
| 6853383e86 | |||
| 6d89b7e510 | |||
| 6baddc10e9 | |||
| a2ea0060cb | |||
| 6aaab30439 | |||
| ea5d108a10 | |||
| f0b127789f | |||
| 4c8be2bfd1 | |||
| 7e371adeb0 | |||
| 6d7b491adb | |||
| abec952e39 | |||
| 9ff541059d | |||
| b84d1f20b5 | |||
| ecc806dc07 | |||
| 45c94cfcbf | |||
| f6f972c2d6 | |||
| e1dc26f676 | |||
| 2fec40c5a6 | |||
| 8f572e1259 | |||
| 43aa15d45c | |||
| 59a5540248 | |||
| 5bfb3e0f68 | |||
| c04f6cbf80 | |||
| d2863fa95b | |||
| 821fd2cf2d | |||
| 6f84ad42fc | |||
| 1d1157a902 | |||
| 6ca9e34c7e | |||
| bc29c468d8 | |||
| 925ea1a414 | |||
| c9addd407e | |||
| 242ae09857 | |||
| ba28c52912 | |||
| a98c95e695 | |||
| 26615ccad0 | |||
| 0b0972b48d | |||
| a435ead916 |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@ -21,17 +21,20 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable --network-timeout 1000000
|
||||
run: pnpm install
|
||||
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
@ -42,14 +45,17 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "yarn"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable --network-timeout 1000000
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
run: pnpm run lint
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@ -8,9 +8,6 @@ on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # run at 2 AM UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
web:
|
||||
name: Push website Docker image to registry
|
||||
|
||||
@ -1 +1,3 @@
|
||||
drop-base/
|
||||
drop-base/
|
||||
# file is fully managed by pnpm, no reason to break it
|
||||
pnpm-lock.yaml
|
||||
|
||||
243
CONTRIBUTING.md
243
CONTRIBUTING.md
@ -1,242 +1,3 @@
|
||||
# CONTRIBUTING GUIDELINES
|
||||
# Contributing
|
||||
|
||||
Drop is a community-driven project. Contribution is welcome, encouraged, and appreciated.
|
||||
It is also essential for the development of the project.
|
||||
|
||||
First, please take a moment to review our [code of conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
These guidelines are an attempt at better addressing pending
|
||||
issues and pull requests. Please read them closely.
|
||||
|
||||
Foremost, be so kind as to [search](#use-the-search-luke). This ensures any contribution
|
||||
you would make is not already covered.
|
||||
|
||||
<!-- TOC updateonsave:true depthfrom:2 -->
|
||||
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [You have a problem](#you-have-a-problem)
|
||||
- [You have a suggestion](#you-have-a-suggestion)
|
||||
- [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).
|
||||
|
||||
---
|
||||
|
||||
## Translation
|
||||
|
||||
If you want to help translate Drop, we would love to have your help! You can do so on our weblate instance. Please make sure to read the [message format syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) page before starting. Failure to do so may result in your translations causing errors in Drop.
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
Drop uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
specification. The automatic changelog tool uses these to automatically generate
|
||||
a changelog based on the commit messages. Here's a guide to writing a commit message
|
||||
to allow this:
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
type(scope)!: subject
|
||||
```
|
||||
|
||||
- `type`: the type of the commit is one of the following:
|
||||
- `feat`: new features.
|
||||
- `fix`: bug fixes.
|
||||
- `docs`: documentation changes.
|
||||
- `refactor`: refactor of a particular code section without introducing
|
||||
new features or bug fixes.
|
||||
- `style`: code style improvements.
|
||||
- `perf`: performance improvements.
|
||||
- `test`: changes to the test suite.
|
||||
- `ci`: changes to the CI system.
|
||||
- `build`: changes to the build system.
|
||||
- `chore`: for other changes that don't match previous types. This doesn't appear
|
||||
in the changelog.
|
||||
|
||||
- `scope`: section of the codebase that the commit makes changes to. If it makes changes to
|
||||
many sections, or if no section in particular is modified, leave blank without the parentheses.
|
||||
Examples:
|
||||
- Commit that changes the `git` plugin:
|
||||
|
||||
```
|
||||
feat(git): add alias for `git commit`
|
||||
```
|
||||
|
||||
- Commit that changes many plugins:
|
||||
|
||||
```
|
||||
style: fix inline declaration of arrays
|
||||
```
|
||||
|
||||
For changes to plugins or themes, the scope should be the plugin or theme name:
|
||||
- ✅ `fix(agnoster): commit subject`
|
||||
- ❌ `fix(theme/agnoster): commit subject`
|
||||
|
||||
- `!`: this goes after the `scope` (or the `type` if scope is empty), to indicate that the commit
|
||||
introduces breaking changes.
|
||||
|
||||
Optionally, you can specify a message that the changelog tool will display to the user to indicate
|
||||
what's changed and what they can do to deal with it. You can use multiple lines to type this message;
|
||||
the changelog parser will keep reading until the end of the commit message or until it finds an empty
|
||||
line.
|
||||
|
||||
Example (made up):
|
||||
|
||||
```
|
||||
style(agnoster)!: change dirty git repo glyph
|
||||
|
||||
BREAKING CHANGE: the glyph to indicate when a git repository is dirty has
|
||||
changed from a Powerline character to a standard UTF-8 emoji. You can
|
||||
change it back by setting `ZSH_THEME_DIRTY_GLYPH`.
|
||||
|
||||
Fixes #420
|
||||
|
||||
Co-authored-by: Username <email>
|
||||
```
|
||||
|
||||
- `subject`: a brief description of the changes. This will be displayed in the changelog. If you need
|
||||
to specify other details, you can use the commit body, but it won't be visible.
|
||||
|
||||
Formatting tricks: the commit subject may contain:
|
||||
- Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool:
|
||||
|
||||
```
|
||||
feat(archlinux): add support for aura AUR helper (#9467)
|
||||
```
|
||||
|
||||
- Formatted inline code by using backticks: the text between backticks will also be highlighted by
|
||||
the changelog tool:
|
||||
```
|
||||
feat(shell-proxy): enable unexported `DEFAULT_PROXY` setting (#9774)
|
||||
```
|
||||
|
||||
### Style
|
||||
|
||||
Try to keep the first commit line short. It's harder to do using this commit style but try to be
|
||||
concise, and if you need more space, you can use the commit body. Try to make sure that the commit
|
||||
subject is clear and precise enough that users will know what changed by just looking at the changelog.
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
## Volunteer
|
||||
|
||||
Very nice!! :)
|
||||
|
||||
Please have a look at the [Volunteer](https://github.com/ohmyzsh/ohmyzsh/wiki/Volunteers)
|
||||
page for instructions on where to start and more.
|
||||
-->
|
||||
|
||||
## Reference
|
||||
|
||||
This contributing guide is adapted from the
|
||||
[oh-my-zsh contribution guide](https://github.com/ohmyzsh/ohmyzsh/blob/master/CONTRIBUTING.md).
|
||||
If there are any issues with this, please email admin@deepcore.dev.
|
||||
Check out our contributing guidelines on our developer docs: [https://developer.droposs.org/contributing](https://developer.droposs.org/contributing).
|
||||
|
||||
41
Dockerfile
41
Dockerfile
@ -1,40 +1,45 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:lts-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
# so corepack knows pnpm's version
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# prevent prompt to download
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
# setup for offline
|
||||
RUN corepack pack
|
||||
# don't call out to network anymore
|
||||
ENV COREPACK_ENABLE_NETWORK=0
|
||||
|
||||
### Unified deps builder
|
||||
# FROM node:lts-alpine AS deps
|
||||
# WORKDIR /app
|
||||
# COPY package.json yarn.lock ./
|
||||
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --network-timeout 1000000 --ignore-scripts
|
||||
FROM base AS deps
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
### Build for app
|
||||
FROM node:lts-alpine AS build-system
|
||||
# setup workdir - has to be the same filepath as app because fuckin' Prisma
|
||||
WORKDIR /app
|
||||
FROM base AS build-system
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_TELEMETRY_DISABLED=1
|
||||
# ENV YARN_CACHE_FOLDER=/root/.yarn
|
||||
|
||||
# add git so drop can determine its git ref at build
|
||||
# pnpm for build
|
||||
RUN apk add --no-cache git pnpm
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# copy deps and rest of project files
|
||||
# COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG BUILD_DROP_VERSION
|
||||
ARG BUILD_GIT_REF
|
||||
|
||||
# build
|
||||
RUN pnpm import
|
||||
RUN pnpm install --shamefully-hoist
|
||||
RUN pnpm run build
|
||||
# RUN --mount=type=cache,target=/root/.yarn yarn postinstall && yarn build
|
||||
RUN pnpm run postinstall && pnpm run build
|
||||
|
||||
### create run environment for Drop
|
||||
FROM node:lts-alpine AS run-system
|
||||
WORKDIR /app
|
||||
FROM base AS run-system
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_TELEMETRY_DISABLED=1
|
||||
@ -42,6 +47,8 @@ ENV NUXT_TELEMETRY_DISABLED=1
|
||||
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
|
||||
RUN apk add --no-cache pnpm
|
||||
RUN pnpm install prisma@6.11.1
|
||||
# init prisma to download all required files
|
||||
RUN pnpm prisma init
|
||||
|
||||
COPY --from=build-system /app/package.json ./
|
||||
COPY --from=build-system /app/.output ./app
|
||||
|
||||
71
README.md
71
README.md
@ -6,73 +6,32 @@
|
||||
# Drop
|
||||
|
||||
[](https://droposs.org)
|
||||
[](https://docs.droposs.org/)
|
||||
[](https://forum.droposs.org)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/ACq4qZp4a9)
|
||||
[](https://opencollective.com/drop-oss)
|
||||
[
|
||||
](https://translate.droposs.org/engage/drop/)
|
||||
|
||||
Drop is an open-source game distribution platform, like GameVault or Steam. It's designed to distribute and shared DRM-free game quickly, all while being incredibly flexible, beautiful and fast.
|
||||
Drop is an open-source game distribution platform, similar to GameVault or Steam. It's designed to distribute and share DRM-free games quickly, all while being incredibly flexible, beautiful, and fast.
|
||||
|
||||
<div align="center">
|
||||
<img src="https://droposs.org/_ipx/f_webp&q_80/images/carousel/store.png" alt="Drop Screenshot" width="900rem"/>
|
||||
</div>
|
||||
|
||||
## Philosophy
|
||||
|
||||
1. Drop is flexible. While abstractions and interfaces can make the codebase more complicated, the flexibility is worth it.
|
||||
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from a username/password to SSO.
|
||||
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with complexity available to the users who want it.
|
||||
1. Drop is flexible. While abstractions and interfaces can complicate the codebase, the flexibility is worth it.
|
||||
2. Drop is secure. The nature of Drop means an instance can never be accessible without authentication. In line with #1, Drop also supports a huge variety of authentication mechanisms, from username/password to SSO.
|
||||
3. Drop is user-friendly. The interface is designed to be clean and simple to use, with advanced features available to users who want them.
|
||||
|
||||
## Deployment
|
||||
|
||||
To just deploy Drop, we've set up a simple docker compose file in deploy-template.
|
||||
|
||||
1. Generate a [GiantBomb API Key](https://www.giantbomb.com/api/)
|
||||
2. Navigate to the deploy-template directory in your terminal (`cd deploy-template`)
|
||||
3. Edit the compose.yml file (`nano compose.yml`) and copy your GiamtBomb API Key into the GIANT_BOMB_API_KEY environment variable
|
||||
4. Run `docker compose up -d`
|
||||
|
||||
Your drop server should now be running. To register the admin user, navigate to http://your.drop.server.ip:3000/register?id=admin
|
||||
and fill in the required forms
|
||||
|
||||
### Adding a game
|
||||
|
||||
To add a game to the drop library, do as follows:
|
||||
|
||||
1. Ensure that the current user owns the library folder with `sudo chown -R $(id -u $(whoami)) library`
|
||||
2. `cd library`
|
||||
3. `mkdir <GAME_NAME>` with the name of the game which you would like to register
|
||||
4. `cd <GAME_NAME>`
|
||||
5. `mkdir <VERSION_NAME>` Upload files for the specific game version to this folder
|
||||
6. Navigate to http://your.drop.server.ip:3000/
|
||||
7. Import game metadata (uses GiantBomb API Key) by selecting the game and specifying which entry to import
|
||||
8. Navigate to http://your.drop.server.ip:3000/admin/library
|
||||
9. You should see the game which you have just imported listed in this menu. There should be a notification that "Drop has detected you have new verions of this game to import". Select import here.
|
||||
10. Select the game version to import and thus fill in fields as required.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
This repo uses the Nuxt 3 + TailwindCSS stack, with the `yarn` package manager.
|
||||
|
||||
For the database, Drop uses Prisma connected to PostgreSQL.
|
||||
|
||||
## Development
|
||||
|
||||
To get started with development, you need `yarn --optional` and `docker compose` installed (or know how to set up a PostgreSQL database).
|
||||
|
||||
### Note: `--optional` flag is **REQUIRED**
|
||||
|
||||
Drop uses a utility package called droplet that's written in Rust. It has builts for Linux (GNU) and Windows, and they are set up as optional packages. `npm` installs these by default, but `yarn` needs the `--optional` flag.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Run `git submodule update --init --recursive` to setup submodules
|
||||
1. Copy the `.env.example` to `.env` and add your GiantBomb metadata key (more metadata providers coming)
|
||||
1. Create the `.data` directory with `mkdir .data`
|
||||
1. Ensure that your user owns the `.data` directory with `sudo chown -R $(id -u $(whoami))`
|
||||
1. Open up a terminal and navigate to `dev-tools`, and run `docker compose up`
|
||||
1. Open up another terminal in the root directory of the project and run `yarn` and then `yarn dev` to start the dev server
|
||||
|
||||
As part of the first-time bootstrap, Drop creates an invitation with the fixed id of 'admin'. So, to create an admin account, go to:
|
||||
|
||||
http://localhost:3000/auth/register?id=admin
|
||||
See our documentation on how to [deploy Drop](https://docs.droposs.org/docs/guides/quickstart) for more information.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please see the [in-depth contributing guide](CONTRIBUTING.md)
|
||||
Please see the [in-depth contributing guide](CONTRIBUTING.md). The guide includes information on how to set up the project, how to contribute code, how to report issues, and even how to effectively translate Drop.
|
||||
|
||||
[](https://translate.droposs.org/engage/drop/)
|
||||
|
||||
23
app.vue
23
app.vue
@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<NuxtLoadingIndicator color="#2563eb" />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<ModalStack />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
await updateUser();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* You can customise the default animation here. */
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
|
||||
}
|
||||
</style>
|
||||
65
app/app.vue
Normal file
65
app/app.vue
Normal file
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<NuxtLoadingIndicator color="#2563eb" />
|
||||
<NuxtLayout>
|
||||
<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>
|
||||
/* You can customise the default animation here. */
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
|
||||
}
|
||||
</style>
|
||||
@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
@config "../tailwind.config.js";
|
||||
@config "../../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
@ -45,6 +45,7 @@ import {
|
||||
LockClosedIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
CodeBracketIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { UserIcon } from "@heroicons/vue/24/solid";
|
||||
import type { Component } from "vue";
|
||||
@ -73,6 +74,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||
icon: BellIcon,
|
||||
count: notifications.value.length,
|
||||
},
|
||||
{
|
||||
label: t("account.token.title"),
|
||||
route: "/account/tokens",
|
||||
prefix: "/account/tokens",
|
||||
icon: CodeBracketIcon,
|
||||
},
|
||||
{
|
||||
label: t("account.settings"),
|
||||
route: "/account/settings",
|
||||
@ -86,7 +86,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
@ -106,7 +106,7 @@ function signin_wrapper() {
|
||||
router.push(route.query.redirect?.toString() ?? "/");
|
||||
})
|
||||
.catch((response) => {
|
||||
const message = response.statusMessage || t("errors.unknown");
|
||||
const message = response.message || t("errors.unknown");
|
||||
error.value = message;
|
||||
})
|
||||
.finally(() => {
|
||||
@ -4,9 +4,10 @@
|
||||
v-for="(_, i) in amount"
|
||||
:key="i"
|
||||
:class="[
|
||||
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
|
||||
carousel.currentSlide === i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
|
||||
'transition-all cursor-pointer h-2 rounded-full',
|
||||
]"
|
||||
@click="slideTo(i)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -18,8 +19,8 @@ const carousel = inject(injectCarousel)!;
|
||||
|
||||
const amount = carousel.maxSlide - carousel.minSlide + 1;
|
||||
|
||||
// function slideTo(index: number) {
|
||||
// const offsetIndex = index + carousel.minSlide;
|
||||
// carousel.nav.slideTo(offsetIndex);
|
||||
// }
|
||||
function slideTo(index: number) {
|
||||
const offsetIndex = index + carousel.minSlide;
|
||||
carousel.nav.slideTo(offsetIndex);
|
||||
}
|
||||
</script>
|
||||
@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<svg
|
||||
aria-label="Drop Logo"
|
||||
class="text-blue-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@ -9,6 +10,16 @@
|
||||
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="100"
|
||||
:stroke-dashoffset="dashArray"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ progress?: number }>();
|
||||
|
||||
const dashArray = computed(() =>
|
||||
props.progress === undefined ? 0 : ((100 - props.progress) / 100) * 50 + 50,
|
||||
);
|
||||
</script>
|
||||
@ -10,7 +10,7 @@
|
||||
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z"
|
||||
/>
|
||||
</svg>
|
||||
<DropLogo class="h-6" />
|
||||
<DropLogo aria-hidden="true" class="h-6" />
|
||||
<span class="text-blue-400 font-display font-bold text-xl uppercase">
|
||||
{{ $t("drop.drop") }}
|
||||
</span>
|
||||
@ -35,7 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import type { GameModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -444,7 +444,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel, GameTagModel } from "~/prisma/client/models";
|
||||
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
|
||||
import { micromark } from "micromark";
|
||||
import {
|
||||
CheckIcon,
|
||||
@ -466,7 +466,7 @@ const game = defineModel<ModelType>() as Ref<ModelType>;
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Game not provided to editor component",
|
||||
message: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
const currentTags = ref<{ [key: string]: boolean }>(
|
||||
@ -553,7 +553,7 @@ function coreMetadataUpdate_wrapper() {
|
||||
{
|
||||
title: t("errors.game.metadata.title"),
|
||||
description: t("errors.game.metadata.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -614,7 +614,7 @@ watch(descriptionHTML, (_v) => {
|
||||
{
|
||||
title: t("errors.game.description.title"),
|
||||
description: t("errors.game.description.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -660,7 +660,7 @@ async function updateBannerImage(id: string) {
|
||||
{
|
||||
title: t("errors.game.banner.title"),
|
||||
description: t("errors.game.banner.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -688,7 +688,7 @@ async function updateCoverImage(id: string) {
|
||||
{
|
||||
title: t("errors.game.cover.title"),
|
||||
description: t("errors.game.cover.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -717,7 +717,7 @@ async function deleteImage(id: string) {
|
||||
{
|
||||
title: t("errors.game.deleteImage.title"),
|
||||
description: t("errors.game.deleteImage.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
@ -761,7 +761,7 @@ async function updateImageCarousel() {
|
||||
{
|
||||
title: t("errors.game.carousel.title"),
|
||||
description: t("errors.game.carousel.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
285
app/components/GameEditor/Version.vue
Normal file
285
app/components/GameEditor/Version.vue
Normal file
@ -0,0 +1,285 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div v-if="game && unimportedVersions" class="p-8">
|
||||
<div>
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">Versions</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400 max-w-lg">
|
||||
Versions are a collection of files that are downloaded to clients.
|
||||
Each version can have multiple configurations, for different
|
||||
platforms.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<NuxtLink
|
||||
:href="canImport ? `/admin/library/g/${game.id}/import` : ''"
|
||||
type="button"
|
||||
:class="[
|
||||
canImport ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-800/50',
|
||||
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
canImport
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm">
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
Version Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
Imported
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
Platforms
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">{{ $t("actions") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tr
|
||||
v-for="version in game.versions"
|
||||
:key="version.versionId"
|
||||
class="transition-colors duration-150 hover:bg-zinc-800/50"
|
||||
>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ version.versionName }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<RelativeTime :date="version.created" />
|
||||
</td>
|
||||
<td class="px-3 py-4">
|
||||
<ul class="space-y-4">
|
||||
<li
|
||||
v-for="gameVersion in version.gameVersions"
|
||||
:key="gameVersion.versionId"
|
||||
class="px-3 py-2 border border-zinc-800 rounded-lg shadow"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm flex items-center gap-x-2 text-zinc-200 font-semibold"
|
||||
>
|
||||
<IconsPlatform
|
||||
:platform="
|
||||
platforms[gameVersion.platformId].platformIcon.key
|
||||
"
|
||||
:fallback="
|
||||
platforms[gameVersion.platformId].platformIcon
|
||||
.fallback
|
||||
"
|
||||
class="size-5 text-blue-500"
|
||||
/>
|
||||
<span class="block truncate">{{
|
||||
platforms[gameVersion.platformId].name
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- launch commands -->
|
||||
<div class="space-y-1 mt-4">
|
||||
<div
|
||||
v-if="gameVersion.install"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
|
||||
>Install</span
|
||||
>
|
||||
|
||||
<div
|
||||
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
|
||||
>
|
||||
<span class="text-zinc-700">(install dir)/</span
|
||||
>{{ gameVersion.install.command }}
|
||||
{{ gameVersion.install.args }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-semibold text-sm text-zinc-100"
|
||||
>Launch options</span
|
||||
>
|
||||
<ul class="divide-y divide-zinc-700">
|
||||
<li
|
||||
v-for="launch in gameVersion.launches"
|
||||
:key="launch.command"
|
||||
class="ml-2 py-2 flex justify-between items-center"
|
||||
>
|
||||
<h1
|
||||
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
|
||||
>
|
||||
{{ launch.name }}
|
||||
</h1>
|
||||
<div
|
||||
class="mt-1 whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
|
||||
>
|
||||
<span class="text-zinc-700"
|
||||
>(install dir)/</span
|
||||
>{{ launch.command }} {{ launch.args }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="gameVersion.uninstall"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span
|
||||
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
|
||||
>Uninstall</span
|
||||
>
|
||||
|
||||
<div
|
||||
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
|
||||
>
|
||||
<span class="text-zinc-700">(install dir)/</span
|
||||
>{{ gameVersion.uninstall.command }}
|
||||
{{ gameVersion.uninstall.args }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
@click="() => deleteVersion(version.versionId)"
|
||||
>
|
||||
Delete
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [version.versionName]) }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="game.versions.length === 0">
|
||||
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
|
||||
No versions
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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 { SerializeObject, TypedInternalResponse } 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 GameFetchType = TypedInternalResponse<
|
||||
"/api/v1/admin/game/:id",
|
||||
unknown,
|
||||
"get"
|
||||
>["game"];
|
||||
const game = defineModel<SerializeObject<GameFetchType>>({ required: true });
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
const rawPlatforms = await useAdminPlatforms();
|
||||
const platforms = Object.fromEntries(
|
||||
renderPlatforms(rawPlatforms).map((v) => [v.param, v]),
|
||||
);
|
||||
|
||||
async function updateVersionOrder() {
|
||||
try {
|
||||
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: game.value.id,
|
||||
versions: game.value.versions.map((e) => e.versionId),
|
||||
},
|
||||
});
|
||||
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)?.message ?? t("errors.unknown"),
|
||||
}),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVersion(versionId: string) {
|
||||
await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "DELETE",
|
||||
body: {
|
||||
id: versionId,
|
||||
},
|
||||
failTitle: "Failed to delete version.",
|
||||
});
|
||||
game.value.versions.splice(
|
||||
game.value.versions.findIndex((e) => e.versionId === versionId),
|
||||
1,
|
||||
);
|
||||
hasDeleted.value = true;
|
||||
}
|
||||
</script>
|
||||
@ -77,7 +77,7 @@ const {
|
||||
}>
|
||||
| undefined
|
||||
| null;
|
||||
href?: string;
|
||||
href?: string | undefined;
|
||||
showTitleDescription?: boolean;
|
||||
animate?: boolean;
|
||||
defaultPlaceholder?: boolean;
|
||||
@ -16,7 +16,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
|
||||
const { game } = defineProps<{
|
||||
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
|
||||
26
app/components/Icons/Platform.vue
Normal file
26
app/components/Icons/Platform.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<component
|
||||
:is="platformIcons[props.platform as HardwarePlatform]"
|
||||
v-if="platformIcons[props.platform as HardwarePlatform]"
|
||||
/>
|
||||
<div v-else-if="props.fallback" v-html="props.fallback" />
|
||||
<DropLogo v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HardwarePlatform } from "~~/prisma/client/enums";
|
||||
import type { Component } from "vue";
|
||||
import LinuxLogo from "./LinuxLogo.vue";
|
||||
import WindowsLogo from "./WindowsLogo.vue";
|
||||
import MacLogo from "./MacLogo.vue";
|
||||
import DropLogo from "../DropLogo.vue";
|
||||
|
||||
const props = defineProps<{ platform: string; fallback?: string | undefined }>();
|
||||
|
||||
const platformIcons: { [key in HardwarePlatform]: Component } = {
|
||||
[HardwarePlatform.Linux]: LinuxLogo,
|
||||
[HardwarePlatform.Windows]: WindowsLogo,
|
||||
[HardwarePlatform.macOS]: MacLogo,
|
||||
};
|
||||
</script>
|
||||
238
app/components/Import/Game.vue
Normal file
238
app/components/Import/Game.vue
Normal file
@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<!-- without metadata option -->
|
||||
<div>
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="props.loading"
|
||||
@click="() => importGame(false)"
|
||||
>
|
||||
{{ $t("library.admin.import.withoutMetadata") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
<!-- divider -->
|
||||
<div
|
||||
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold"
|
||||
>
|
||||
<div class="h-[1px] grow bg-zinc-800" />
|
||||
{{ $t("auth.signin.or") }}
|
||||
<div class="h-[1px] grow bg-zinc-800" />
|
||||
</div>
|
||||
|
||||
<!-- with metadata option -->
|
||||
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<form @submit.prevent="() => searchGame()">
|
||||
<label
|
||||
for="searchTerm"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>{{ $t("library.admin.import.search") }}</label
|
||||
>
|
||||
<div class="mt-2 flex">
|
||||
<div class="-mr-px grid grow grid-cols-1 focus-within:relative">
|
||||
<input
|
||||
id="searchTerm"
|
||||
v-model="gameSearchTerm"
|
||||
type="text"
|
||||
name="searchTerm"
|
||||
class="col-start-1 row-start-1 block w-full rounded-l-md bg-zinc-950 py-1.5 px-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
:placeholder="$t('library.admin.import.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<LoadingButton
|
||||
:loading="gameSearchLoading"
|
||||
:style="'none'"
|
||||
type="submit"
|
||||
class="w-24 flex shrink-0 items-center justify-center gap-x-1.5 rounded-r-md bg-zinc-950 px-3 py-2 text-sm font-semibold text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 hover:bg-zinc-900 focus:relative focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
|
||||
>
|
||||
<MagnifyingGlassIcon
|
||||
class="-ml-0.5 size-4 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ $t("library.admin.import.search") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Listbox
|
||||
v-if="metadataResults && metadataResults.length > 0"
|
||||
v-model="model"
|
||||
as="div"
|
||||
>
|
||||
<ListboxLabel
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<GameSearchResultWidget v-if="model !== undefined" :game="model" />
|
||||
<span v-else class="block truncate text-zinc-600">
|
||||
{{ $t("library.admin.import.selectGamePlaceholder") }}
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="result in metadataResults"
|
||||
:key="result.id"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="result"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<GameSearchResultWidget :game="result" />
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
<div
|
||||
v-else-if="gameSearchResultsLoading"
|
||||
role="status"
|
||||
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||
>
|
||||
{{ $t("library.admin.import.loading") }}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-6 h-6 text-transparent animate-spin fill-white"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="gameSearchResultsError"
|
||||
class="w-fit rounded-md bg-red-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ gameSearchResultsError }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="props.loading"
|
||||
:disabled="model === undefined"
|
||||
@click="() => importGame()"
|
||||
>
|
||||
{{ $t("library.admin.import.import") }}
|
||||
</LoadingButton>
|
||||
|
||||
<div
|
||||
v-if="props.error"
|
||||
class="mt-4 w-fit rounded-md bg-red-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ props.error }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
|
||||
const model = ref<GameMetadataSearchResult | undefined>(undefined);
|
||||
|
||||
const props = defineProps<{
|
||||
gameName: string;
|
||||
loading: boolean;
|
||||
error?: string | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [metadata: GameMetadataSearchResult | undefined];
|
||||
}>();
|
||||
|
||||
function importGame(metadata = true) {
|
||||
emit("import", metadata ? model.value : undefined);
|
||||
}
|
||||
|
||||
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
|
||||
|
||||
onMounted(() => {
|
||||
if (!metadataResults.value) searchGame();
|
||||
});
|
||||
|
||||
const gameSearchLoading = ref(false);
|
||||
const gameSearchResultsLoading = ref(false);
|
||||
const gameSearchResultsError = ref<string | undefined>();
|
||||
const gameSearchTerm = ref(props.gameName);
|
||||
|
||||
async function searchGame() {
|
||||
gameSearchResultsError.value = undefined;
|
||||
gameSearchLoading.value = true;
|
||||
try {
|
||||
const results = await $dropFetch(
|
||||
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
|
||||
);
|
||||
metadataResults.value = results;
|
||||
gameSearchLoading.value = false;
|
||||
} catch (e) {
|
||||
gameSearchLoading.value = false;
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
336
app/components/Import/Redist.vue
Normal file
336
app/components/Import/Redist.vue
Normal file
@ -0,0 +1,336 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="flex flex-row gap-x-4">
|
||||
<label
|
||||
for="icon-upload"
|
||||
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
|
||||
>
|
||||
<input
|
||||
id="icon-upload"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/*"
|
||||
@change="addFile"
|
||||
/>
|
||||
<img
|
||||
v-if="currentFileObjectUrl"
|
||||
:src="currentFileObjectUrl"
|
||||
class="absolute inset-0 object-cover w-full h-full"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50"
|
||||
>
|
||||
<ArrowUpTrayIcon class="size-6" />
|
||||
<span class="text-xs font-bold font-display uppercase">Upload</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="grow flex flex-col gap-y-4">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-zinc-100"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-zinc-100"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
id="description"
|
||||
v-model="description"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SwitchGroup
|
||||
as="div"
|
||||
class="max-w-lg flex items-center justify-between gap-x-4"
|
||||
>
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>Create as platform</SwitchLabel
|
||||
>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
||||
>Versions for this redistributable will be able to take a series of
|
||||
launch commands. Intended to be used with emulators and similar
|
||||
programs.</SwitchDescription
|
||||
>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="isPlatform"
|
||||
:class="[
|
||||
isPlatform ? 'bg-blue-600' : 'bg-zinc-800',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
isPlatform ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<div class="relative">
|
||||
<div class="flex flex-row gap-x-4">
|
||||
<label
|
||||
for="platform-icon-upload"
|
||||
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
|
||||
>
|
||||
<input
|
||||
id="platform-icon-upload"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
accept="image/svg+xml"
|
||||
:disabled="!isPlatform"
|
||||
@change="addSvg"
|
||||
/>
|
||||
<div
|
||||
v-if="platform.icon"
|
||||
class="absolute inset-0 object-cover w-full h-full text-blue-600"
|
||||
v-html="platform.icon"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50 focus:text-zinc-100"
|
||||
>
|
||||
<ArrowUpTrayIcon class="size-6" />
|
||||
<span class="text-xs font-bold font-display uppercase"
|
||||
>Upload SVG</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
<div class="grow flex flex-col gap-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="platform-name"
|
||||
class="block text-sm font-medium text-zinc-100"
|
||||
>Platform Name</label
|
||||
>
|
||||
<input
|
||||
id="platform-name"
|
||||
v-model="platform.name"
|
||||
type="text"
|
||||
:disabled="!isPlatform"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 w-full">
|
||||
<label for="platform-name" class="block text-sm font-medium text-zinc-100"
|
||||
>File Extensions {{ currentExtDotted }}
|
||||
</label
|
||||
>
|
||||
<Combobox
|
||||
as="div"
|
||||
:model-value="currentExtDotted"
|
||||
nullable
|
||||
class="mt-1 w-full"
|
||||
:disabled="!isPlatform"
|
||||
@update:model-value="(v) => addExt(v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="w-full block flex-1 rounded-lg border-1 border-zinc-800 py-2 px-2 bg-zinc-950 text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder=".exe"
|
||||
@change="currentExt = $event.target.value"
|
||||
@blur="currentExt = ''"
|
||||
/>
|
||||
<ComboboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-if="currentExt"
|
||||
v-slot="{ active }"
|
||||
:value="currentExtDotted"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span class="block">
|
||||
<span class="text-blue-300">filename</span
|
||||
><span class="font-semibold">{{ currentExtDotted }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
<div class="mt-2 flex gap-1 flex-wrap">
|
||||
<div
|
||||
v-for="ext in platform.fileExts"
|
||||
:key="ext"
|
||||
class="bg-blue-600/10 border-1 border-blue-700 rounded-full px-2 py-1 text-xs text-blue-400"
|
||||
>
|
||||
{{ ext }}
|
||||
</div>
|
||||
<span
|
||||
v-if="platform.fileExts.length == 0"
|
||||
class="uppercase font-display text-zinc-700 font-bold text-xs"
|
||||
>No suggested file extensions.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isPlatform" class="absolute inset-0 bg-zinc-950/20" />
|
||||
</div>
|
||||
<div>
|
||||
<LoadingButton
|
||||
class="w-fit"
|
||||
:loading="props.loading"
|
||||
:disabled="buttonDisabled"
|
||||
@click="() => importRedist()"
|
||||
>
|
||||
{{ $t("library.admin.import.import") }}
|
||||
</LoadingButton>
|
||||
|
||||
<div v-if="props.error" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ props.error }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
Switch,
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
} from "@headlessui/vue";
|
||||
import { ArrowUpTrayIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const currentFile = ref<File | undefined>(undefined);
|
||||
const currentFileObjectUrl = ref<string | undefined>(undefined);
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [
|
||||
metadata: { name: string; description: string; icon: File } | undefined,
|
||||
platform: typeof platform.value | undefined,
|
||||
];
|
||||
}>();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const isPlatform = ref(false);
|
||||
const currentExt = ref("");
|
||||
const currentExtDotted = computed(() => {
|
||||
if(!currentExt.value) return "";
|
||||
const cleaned = currentExt.value.replace(/\W/g, "").toLowerCase();
|
||||
return `.${cleaned}`;
|
||||
});
|
||||
const platform = ref<{ name: string; icon: string; fileExts: string[] }>({
|
||||
name: "",
|
||||
icon: "",
|
||||
fileExts: [],
|
||||
});
|
||||
|
||||
const buttonDisabled = computed<boolean>(
|
||||
() =>
|
||||
!(
|
||||
name.value &&
|
||||
description.value &&
|
||||
currentFileObjectUrl.value &&
|
||||
(!isPlatform.value || (platform.value.name && platform.value.icon))
|
||||
),
|
||||
);
|
||||
|
||||
function addFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (currentFileObjectUrl.value) {
|
||||
URL.revokeObjectURL(currentFileObjectUrl.value);
|
||||
}
|
||||
|
||||
currentFile.value = file;
|
||||
currentFileObjectUrl.value = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
async function addSvg(event: Event) {
|
||||
const file = (event.target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const svgContent = await file.text();
|
||||
const parser = new DOMParser();
|
||||
try {
|
||||
const document = parser.parseFromString(svgContent, "image/svg+xml");
|
||||
const svg = document.getElementsByTagName("svg").item(0);
|
||||
if (!svg) throw "No SVG in uploaded image.";
|
||||
svg.removeAttribute("width");
|
||||
svg.removeAttribute("height");
|
||||
platform.value.icon = svg.outerHTML;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to upload SVG",
|
||||
description: (e as string)?.toString() ?? e,
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function addExt(ext: string | null) {
|
||||
if (!ext) return;
|
||||
if (platform.value.fileExts.includes(ext)) return;
|
||||
platform.value.fileExts.push(ext);
|
||||
currentExt.value = "";
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
gameName: string;
|
||||
loading: boolean;
|
||||
error?: string | undefined;
|
||||
}>();
|
||||
|
||||
function importRedist() {
|
||||
if (!currentFile.value) return;
|
||||
emit(
|
||||
"import",
|
||||
{
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
icon: currentFile.value,
|
||||
},
|
||||
isPlatform.value
|
||||
? {
|
||||
...platform.value,
|
||||
fileExts: platform.value.fileExts.map((e) => e.slice(1)),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
@ -3,7 +3,7 @@
|
||||
<LanguageSelectorListbox />
|
||||
<NuxtLink
|
||||
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
|
||||
to="https://translate.droposs.org/projects/drop/"
|
||||
to="https://translate.droposs.org/engage/drop/"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
@ -18,8 +18,12 @@
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
|
||||
<DevOnly
|
||||
><h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
|
||||
<DevOnly>
|
||||
<h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
|
||||
</DevOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
</script>
|
||||
@ -92,7 +92,7 @@ import type { Locale } from "vue-i18n";
|
||||
|
||||
const { showText = true } = defineProps<{ showText?: boolean }>();
|
||||
|
||||
const { locales, locale: currLocale, setLocale } = useI18n();
|
||||
const { locale: currLocale, setLocale, locales } = useI18n();
|
||||
|
||||
function changeLocale(locale: Locale) {
|
||||
setLocale(locale);
|
||||
27
app/components/LogLine.vue
Normal file
27
app/components/LogLine.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<span class="text-xs font-mono text-zinc-400 inline-flex items-top gap-x-2"
|
||||
><span v-if="!short" class="text-zinc-500">{{ log.timestamp }}</span>
|
||||
<span
|
||||
:class="[
|
||||
colours[log.level] || 'text-green-400',
|
||||
'uppercase font-display font-semibold',
|
||||
]"
|
||||
>{{ log.level }}</span
|
||||
>
|
||||
<pre :class="[short ? 'line-clamp-1' : '', 'mt-[1px]']">{{
|
||||
log.message
|
||||
}}</pre>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskLog } from "~~/server/internal/tasks";
|
||||
|
||||
defineProps<{ log: typeof TaskLog.infer; short?: boolean }>();
|
||||
|
||||
const colours: { [key: string]: string } = {
|
||||
info: "text-blue-400",
|
||||
warn: "text-yellow-400",
|
||||
error: "text-red-400",
|
||||
};
|
||||
</script>
|
||||
@ -162,7 +162,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import type { GameModel } from "~~/prisma/client/models";
|
||||
import {
|
||||
DialogTitle,
|
||||
Listbox,
|
||||
@ -171,7 +171,7 @@ import {
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
@ -208,7 +208,7 @@ const { t } = useI18n();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const currentGame = ref<(typeof metadataGames.value)[number]>();
|
||||
const currentGame = ref<NonNullable<(typeof metadataGames.value)[number]> | null>(null);
|
||||
const developed = ref(false);
|
||||
const published = ref(false);
|
||||
const addGameLoading = ref(false);
|
||||
@ -231,12 +231,12 @@ async function addGame() {
|
||||
emit("created", game, published.value, developed.value);
|
||||
} catch (e) {
|
||||
if (e instanceof FetchError) {
|
||||
addError.value = e.statusMessage ?? e.message ?? t("errors.unknown");
|
||||
addError.value = e.message ?? t("errors.unknown");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
currentGame.value = undefined;
|
||||
currentGame.value = null;
|
||||
developed.value = false;
|
||||
published.value = false;
|
||||
addGameLoading.value = false;
|
||||
@ -46,7 +46,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { CollectionEntryModel, GameModel } from "~/prisma/client/models";
|
||||
import type { CollectionEntryModel, GameModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -97,13 +97,13 @@ async function createCollection() {
|
||||
} catch (error) {
|
||||
console.error("Failed to create collection:", error);
|
||||
|
||||
const err = error as { statusMessage?: string };
|
||||
const err = error as { message?: string };
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.library.collection.create.title"),
|
||||
description: t("errors.library.collection.create.desc", [
|
||||
err?.statusMessage ?? t("errors.unknown"),
|
||||
err?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
148
app/components/Modal/CreateCompany.vue
Normal file
148
app/components/Modal/CreateCompany.vue
Normal file
@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<ModalTemplate v-model="open">
|
||||
<template #default>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium leading-6 text-white">
|
||||
{{ $t("library.admin.metadata.companies.modals.createTitle") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
{{ $t("library.admin.metadata.companies.modals.createDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form class="space-y-4" @submit.prevent="() => createCompany()">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.modals.createFieldName")
|
||||
}}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="name"
|
||||
v-model="companyName"
|
||||
type="text"
|
||||
name="name"
|
||||
:placeholder="
|
||||
$t(
|
||||
'library.admin.metadata.companies.modals.createFieldNamePlaceholder',
|
||||
)
|
||||
"
|
||||
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="description"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>{{
|
||||
$t(
|
||||
"library.admin.metadata.companies.modals.createFieldDescription",
|
||||
)
|
||||
}}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="description"
|
||||
v-model="companyDescription"
|
||||
type="text"
|
||||
name="description"
|
||||
:placeholder="
|
||||
$t(
|
||||
'library.admin.metadata.companies.modals.createFieldDescriptionPlaceholder',
|
||||
)
|
||||
"
|
||||
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="website"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.modals.createFieldWebsite")
|
||||
}}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="website"
|
||||
v-model="companyWebsite"
|
||||
type="text"
|
||||
name="website"
|
||||
:placeholder="
|
||||
$t(
|
||||
'library.admin.metadata.companies.modals.createFieldWebsitePlaceholder',
|
||||
)
|
||||
"
|
||||
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="hidden" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons="{ close }">
|
||||
<LoadingButton
|
||||
:loading="loading"
|
||||
:disabled="!companyValid"
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => createCompany()"
|
||||
>
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="() => close()"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CompanyModel } from "~~/prisma/client/models";
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [company: CompanyModel];
|
||||
}>();
|
||||
|
||||
const companyName = ref("");
|
||||
const companyDescription = ref("");
|
||||
const companyWebsite = ref("");
|
||||
|
||||
const loading = ref(false);
|
||||
const companyValid = computed(
|
||||
() => companyName.value && companyDescription.value,
|
||||
);
|
||||
async function createCompany() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const newCompany = await $dropFetch("/api/v1/admin/company", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: companyName.value,
|
||||
description: companyDescription.value,
|
||||
website: companyWebsite.value,
|
||||
},
|
||||
failTitle: "Failed to create new company",
|
||||
});
|
||||
open.value = false;
|
||||
emit("created", newCompany);
|
||||
} finally {
|
||||
/* empty */
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
</script>
|
||||
@ -45,7 +45,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { GameTagModel } from "~/prisma/client/models";
|
||||
import type { GameTagModel } from "~~/prisma/client/models";
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [tag: GameTagModel];
|
||||
267
app/components/Modal/CreateToken.vue
Normal file
267
app/components/Modal/CreateToken.vue
Normal file
@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<ModalTemplate v-model="model" size-class="max-w-3xl">
|
||||
<template #default>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("account.token.name") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("account.token.nameDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
:placeholder="
|
||||
props.suggestedName ?? $t('account.token.namePlaceholder')
|
||||
"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Listbox v-model="expiryKey" as="div">
|
||||
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
|
||||
$t("users.admin.simple.inviteExpiryLabel")
|
||||
}}</ListboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="block truncate">{{ expiryKey }}</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[label] in Object.entries(expiry)"
|
||||
:key="label"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="label"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected
|
||||
? 'font-semibold text-zinc-100'
|
||||
: 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ label }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("account.token.acls") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("account.token.aclsDesc") }}
|
||||
</p>
|
||||
<fieldset class="divide-y divide-zinc-700">
|
||||
<div
|
||||
v-for="[sectionName, sectionAcls] in Object.entries(
|
||||
aclsBySection,
|
||||
)"
|
||||
:key="sectionName"
|
||||
class="grid lg:grid-cols-3 gap-1 py-3"
|
||||
>
|
||||
<div
|
||||
v-for="[acl, description] in Object.entries(sectionAcls)"
|
||||
:key="acl"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div class="flex h-6 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
<input
|
||||
id="acl"
|
||||
v-model="currentACLs[acl]"
|
||||
aria-describedby="acl-description"
|
||||
name="acl"
|
||||
type="checkbox"
|
||||
class="col-start-1 row-start-1 appearance-none rounded-sm border checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 border-white/10 bg-white/5 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:indeterminate:border-blue-500 dark:indeterminate:bg-blue-500 dark:focus-visible:outline-blue-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto"
|
||||
/>
|
||||
<svg
|
||||
class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-white/25"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
class="opacity-0 group-has-checked:opacity-100"
|
||||
d="M3 8L6 11L11 3.5"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
class="opacity-0 group-has-indeterminate:opacity-100"
|
||||
d="M3 7H11"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm/6">
|
||||
<label
|
||||
for="acl"
|
||||
class="font-display font-medium text-white"
|
||||
>{{ acl }}</label
|
||||
>
|
||||
{{ " " }}
|
||||
<span id="acl-description" class="text-xs text-zinc-400"
|
||||
><span class="sr-only">{{ acl }} </span
|
||||
>{{ description }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #buttons>
|
||||
<LoadingButton :loading="props.loading" @click="() => createToken()">
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||
@click="() => cancel()"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
|
||||
import type { DurationLike } from "luxon";
|
||||
|
||||
// Reuse for both admin and user tokens
|
||||
|
||||
const model = defineModel<boolean>({ required: true });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
acls: { [key: string]: string };
|
||||
loading?: boolean;
|
||||
suggestedAcls?: string[];
|
||||
suggestedName?: string;
|
||||
}>();
|
||||
|
||||
// Label to parameters to moment.js .add()
|
||||
const expiry: Record<string, DurationLike | undefined> = {
|
||||
[t("account.token.expiryMonth")]: {
|
||||
month: 1,
|
||||
},
|
||||
[t("account.token.expiry3Month")]: {
|
||||
month: 3,
|
||||
},
|
||||
[t("account.token.expiry6Month")]: {
|
||||
month: 6,
|
||||
},
|
||||
[t("account.token.expiryYear")]: {
|
||||
year: 1,
|
||||
},
|
||||
[t("account.token.expiry5Year")]: {
|
||||
year: 5,
|
||||
},
|
||||
[t("account.token.noExpiry")]: undefined,
|
||||
};
|
||||
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0]); // Cast to any because we just know it's okay
|
||||
const name = ref(props.suggestedName ?? "");
|
||||
const currentACLs = ref<{ [key: string]: boolean }>(
|
||||
Object.fromEntries((props.suggestedAcls ?? []).map((v) => [v, true])),
|
||||
);
|
||||
|
||||
const aclsBySection = computed(() => {
|
||||
const sections: { [key: string]: { [key: string]: string } } = {};
|
||||
for (const [acl, description] of Object.entries(props.acls)) {
|
||||
const section = acl.split(":")[0];
|
||||
sections[section] ??= {};
|
||||
sections[section][acl] = description;
|
||||
}
|
||||
return sections;
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [name: string, acls: string[], expiry: DurationLike | undefined];
|
||||
}>();
|
||||
|
||||
function createToken() {
|
||||
emit(
|
||||
"create",
|
||||
name.value,
|
||||
Object.entries(currentACLs.value)
|
||||
.filter(([_acl, enabled]) => enabled)
|
||||
.map(([acl, _enabled]) => acl),
|
||||
expiry[expiryKey.value],
|
||||
);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
model.value = false;
|
||||
}
|
||||
|
||||
watch(model, (c) => {
|
||||
if (!c) {
|
||||
name.value = "";
|
||||
currentACLs.value = {};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -35,7 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CollectionModel } from "~/prisma/client/models";
|
||||
import type { CollectionModel } from "~~/prisma/client/models";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
|
||||
const collection = defineModel<CollectionModel | undefined>();
|
||||
@ -67,8 +67,8 @@ async function deleteCollection() {
|
||||
{
|
||||
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"),
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -71,8 +71,8 @@ async function deleteArticle() {
|
||||
{
|
||||
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"),
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -36,7 +36,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
const user = defineModel<UserModel | undefined>();
|
||||
const deleteLoading = ref(false);
|
||||
@ -62,8 +62,8 @@ async function deleteUser() {
|
||||
{
|
||||
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"),
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@ -177,7 +177,7 @@ function uploadFile_wrapper() {
|
||||
uploadLoading.value = true;
|
||||
uploadFile()
|
||||
.catch((error) => {
|
||||
uploadError.value = error.statusMessage ?? t("errors.unknown");
|
||||
uploadError.value = error.message ?? t("errors.unknown");
|
||||
})
|
||||
.finally(() => {
|
||||
uploadLoading.value = false;
|
||||
@ -414,8 +414,8 @@ async function createArticle() {
|
||||
|
||||
modalOpen.value = false;
|
||||
} catch (e) {
|
||||
// @ts-expect-error attempt to get statusMessage on error
|
||||
error.value = e?.statusMessage ?? t("errors.unknown");
|
||||
// @ts-expect-error attempt to get message on error
|
||||
error.value = e?.message ?? t("errors.unknown");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -44,7 +44,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const props = defineProps<{ notification: NotificationModel }>();
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<Listbox v-model="typedModel" as="div">
|
||||
<Listbox v-model="model" as="div">
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
><slot
|
||||
/></ListboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<div class="relative">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span v-if="model" class="flex items-center">
|
||||
<component
|
||||
:is="PLATFORM_ICONS[model]"
|
||||
alt=""
|
||||
<span v-if="currentEntry" class="flex items-center">
|
||||
<IconsPlatform
|
||||
:platform="currentEntry.platformIcon.key"
|
||||
:fallback="currentEntry.platformIcon.fallback"
|
||||
class="h-5 w-5 flex-shrink-0 text-blue-600"
|
||||
/>
|
||||
<span class="ml-3 block truncate">{{ model }}</span>
|
||||
<span class="ml-3 block truncate">{{ currentEntry.name }}</span>
|
||||
</span>
|
||||
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
|
||||
<span
|
||||
@ -32,11 +32,11 @@
|
||||
class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-950 ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[name, value] in Object.entries(values)"
|
||||
:key="value"
|
||||
v-for="entry in values"
|
||||
:key="entry.param"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="value"
|
||||
:value="entry.param"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
@ -45,15 +45,13 @@
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<component
|
||||
:is="PLATFORM_ICONS[value]"
|
||||
alt=""
|
||||
:class="[
|
||||
active ? 'text-zinc-100' : 'text-blue-600',
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
]"
|
||||
<IconsPlatform
|
||||
v-if="entry.platformIcon"
|
||||
:platform="entry.platformIcon.key"
|
||||
:fallback="entry.platformIcon.fallback"
|
||||
class="size-5 text-blue-500"
|
||||
/>
|
||||
<span class="ml-3 block truncate">{{ name }}</span>
|
||||
<span class="ml-3 block truncate">{{ entry.name }}</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
@ -83,17 +81,14 @@ import {
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
|
||||
const model = defineModel<PlatformClient | undefined>();
|
||||
const model = defineModel<string | undefined>();
|
||||
|
||||
const typedModel = computed<PlatformClient | null>({
|
||||
get() {
|
||||
return model.value || null;
|
||||
},
|
||||
set(v) {
|
||||
if (v === null) return (model.value = undefined);
|
||||
model.value = v;
|
||||
},
|
||||
});
|
||||
const props = defineProps<{ platforms: PlatformRenderable[] }>();
|
||||
const currentEntry = computed(() =>
|
||||
model.value
|
||||
? props.platforms.find((v) => v.param === model.value)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const values = Object.fromEntries(Object.entries(PlatformClient));
|
||||
const values = props.platforms;
|
||||
</script>
|
||||
120
app/components/PreloadSelector.vue
Normal file
120
app/components/PreloadSelector.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<Combobox
|
||||
as="div"
|
||||
:value="props.value"
|
||||
nullable
|
||||
@update:model-value="(v) => emit('update', v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="file.exe"
|
||||
@change="query = $event.target.value"
|
||||
@blur="query = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
v-if="filtered?.length ?? 0 > 0"
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
|
||||
>
|
||||
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="guess in filtered"
|
||||
:key="guess.filename"
|
||||
v-slot="{ active, selected }"
|
||||
:value="guess.filename"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-2 block truncate',
|
||||
selected && 'font-semibold',
|
||||
]"
|
||||
>
|
||||
{{ guess.filename }}
|
||||
<IconsPlatform
|
||||
:platform="guess.platform.platformIcon.key"
|
||||
:fallback="guess.platform.platformIcon.fallback"
|
||||
class="size-5 flex-shrink-0 text-blue-600"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<ComboboxOption
|
||||
v-if="query"
|
||||
v-slot="{ active, selected }"
|
||||
:value="query"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span :class="['block truncate', selected && 'font-semibold']">
|
||||
{{ $t("chars.quoted", { text: query }) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [v: string];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
value?: string | undefined;
|
||||
guesses?: Array<{ platform: PlatformRenderable; filename: string }>;
|
||||
}>();
|
||||
|
||||
const query = ref("");
|
||||
|
||||
const filtered = computed(() =>
|
||||
props.guesses?.filter((e) =>
|
||||
e.filename.toLowerCase().includes(query.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
14
app/components/RedistEditor/Metadata.vue
Normal file
14
app/components/RedistEditor/Metadata.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div>{{ model }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { RedistModel, UserPlatformModel } from "~~/prisma/client/models";
|
||||
|
||||
type ModelType = SerializeObject<
|
||||
RedistModel & { platform?: UserPlatformModel }
|
||||
>;
|
||||
|
||||
const model = defineModel<ModelType>({ required: true });
|
||||
</script>
|
||||
@ -1,3 +1,12 @@
|
||||
<i18n>
|
||||
{
|
||||
"en": {
|
||||
"↓": "↓",
|
||||
"↑": "↑"
|
||||
}
|
||||
}
|
||||
</i18n>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
@ -176,9 +185,12 @@
|
||||
active ? 'bg-zinc-900 outline-hidden' : '',
|
||||
'w-full text-left block px-4 py-2 text-sm',
|
||||
]"
|
||||
@click="() => (currentSort = option.param)"
|
||||
@click.prevent="handleSortClick(option, $event)"
|
||||
>
|
||||
{{ option.name }}
|
||||
<span v-if="currentSort === option.param">
|
||||
{{ sortOrder === "asc" ? $t("↑") : $t("↓") }}
|
||||
</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
</div>
|
||||
@ -247,7 +259,7 @@
|
||||
<div
|
||||
v-for="(option, optionIdx) in section.options"
|
||||
:key="option.param"
|
||||
class="flex gap-3"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex h-5 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
@ -272,6 +284,12 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IconsPlatform
|
||||
v-if="option.platformIcon"
|
||||
:platform="option.platformIcon.key"
|
||||
:fallback="option.platformIcon.fallback"
|
||||
class="size-5 text-blue-500"
|
||||
/>
|
||||
<label
|
||||
:for="`filter-${section.param}-${optionIdx}`"
|
||||
class="text-sm text-zinc-400"
|
||||
@ -359,7 +377,7 @@ import {
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { GameModel, GameTagModel } from "~/prisma/client/models";
|
||||
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
|
||||
import MultiItemSelector from "./MultiItemSelector.vue";
|
||||
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
|
||||
|
||||
@ -376,6 +394,8 @@ const props = defineProps<{
|
||||
const tags =
|
||||
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
|
||||
|
||||
const userPlatforms = await $dropFetch("/api/v1/store/platforms");
|
||||
|
||||
const sorts: Array<StoreSortOption> = [
|
||||
{
|
||||
name: "Default",
|
||||
@ -389,8 +409,13 @@ const sorts: Array<StoreSortOption> = [
|
||||
name: "Recently Added",
|
||||
param: "recent",
|
||||
},
|
||||
{
|
||||
name: "Name",
|
||||
param: "name",
|
||||
},
|
||||
];
|
||||
const currentSort = ref(sorts[0].param);
|
||||
const sortOrder = ref<"asc" | "desc">("desc");
|
||||
|
||||
const options: Array<StoreFilterOption> = [
|
||||
...(tags.length > 0
|
||||
@ -407,7 +432,7 @@ const options: Array<StoreFilterOption> = [
|
||||
name: "Platform",
|
||||
param: "platform",
|
||||
multiple: true,
|
||||
options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })),
|
||||
options: renderPlatforms(userPlatforms),
|
||||
},
|
||||
...(props.extraOptions ?? []),
|
||||
];
|
||||
@ -466,7 +491,7 @@ async function updateGames(query: string, resetGames: boolean) {
|
||||
results: Array<SerializeObject<GameModel>>;
|
||||
count: number;
|
||||
}>(
|
||||
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
|
||||
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}&order=${sortOrder.value}${query ? "&" + query : ""}`,
|
||||
);
|
||||
if (resetGames) {
|
||||
games.value = newValues.results;
|
||||
@ -483,6 +508,19 @@ watch(filterQuery, (newUrl) => {
|
||||
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>
|
||||
55
app/components/TaskWidget.vue
Normal file
55
app/components/TaskWidget.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="task"
|
||||
class="flex w-full items-center justify-between space-x-6 p-6"
|
||||
>
|
||||
<div class="flex-1 truncate">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div>
|
||||
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
|
||||
<XMarkIcon v-else-if="task.error" class="size-5 text-red-600" />
|
||||
<div
|
||||
v-else
|
||||
class="size-2 bg-blue-600 rounded-full animate-pulse m-1"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="truncate text-sm font-medium text-zinc-100">
|
||||
{{ task.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
v-if="active"
|
||||
class="mt-2 w-full rounded-full overflow-hidden bg-zinc-900"
|
||||
>
|
||||
<div
|
||||
:style="{ width: `${task.progress}%` }"
|
||||
class="bg-blue-600 h-[3px] transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
|
||||
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
|
||||
</div>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
:href="`/admin/task/${task.id}`"
|
||||
class="mt-3 ml-1 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
|
||||
>
|
||||
<i18n-t keypath="tasks.admin.viewTask" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- renders server side when we don't want to access the current tasks -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { TaskMessage } from "~~/server/internal/tasks";
|
||||
|
||||
defineProps<{ task: TaskMessage | undefined; active?: boolean }>();
|
||||
</script>
|
||||
@ -91,10 +91,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center xl:col-span-3 mt-8">
|
||||
<p
|
||||
<NuxtLink
|
||||
:to="`https://github.com/Drop-OSS/drop/releases/tag/${versionInfo.version}`"
|
||||
class="text-xs text-zinc-700 hover:text-zinc-400 transition-colors duration-200 cursor-default select-none"
|
||||
>
|
||||
<i18n-t keypath="footer.version" tag="p" scope="global">
|
||||
<i18n-t keypath="footer.version" tag="span" scope="global">
|
||||
<template #version>
|
||||
<span>{{ versionInfo.version }}</span>
|
||||
</template>
|
||||
@ -102,7 +103,7 @@
|
||||
<span>{{ versionInfo.gitRef }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const props = defineProps<{ notifications: Array<NotificationModel> }>();
|
||||
</script>
|
||||
@ -81,8 +81,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
|
||||
import { useObject } from "~/composables/objects";
|
||||
import type { NavigationItem } from "~/composables/types";
|
||||
|
||||
const user = useUser();
|
||||
|
||||
@ -2,7 +2,7 @@ import type {
|
||||
CollectionModel,
|
||||
CollectionEntryModel,
|
||||
GameModel,
|
||||
} from "~/prisma/client/models";
|
||||
} from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
type FullCollection = CollectionModel & {
|
||||
@ -1,4 +1,4 @@
|
||||
import type { ArticleModel } from "~/prisma/client/models";
|
||||
import type { ArticleModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
export const useNews = () =>
|
||||
@ -1,4 +1,4 @@
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const ws = new WebSocketHandler("/api/v1/notifications/ws");
|
||||
|
||||
36
app/composables/platform.ts
Normal file
36
app/composables/platform.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { UserPlatform } from "~~/prisma/client/client";
|
||||
import { HardwarePlatform } from "~~/prisma/client/enums";
|
||||
|
||||
export type PlatformRenderable = {
|
||||
name: string;
|
||||
param: string;
|
||||
platformIcon: { key: string; fallback?: string };
|
||||
};
|
||||
|
||||
export function renderPlatforms(
|
||||
userPlatforms: { platformName: string; id: string; iconSvg: string }[],
|
||||
): PlatformRenderable[] {
|
||||
return [
|
||||
...Object.values(HardwarePlatform).map((e) => ({
|
||||
name: e,
|
||||
param: e,
|
||||
platformIcon: { key: e },
|
||||
})),
|
||||
...userPlatforms.map((e) => ({
|
||||
name: e.platformName,
|
||||
param: e.id,
|
||||
platformIcon: { key: e.id, fallback: e.iconSvg },
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
const rawUseAdminPlatforms = () => useState<Array<UserPlatform> | null>('adminPlatforms', () => null);
|
||||
|
||||
export async function useAdminPlatforms() {
|
||||
const platforms = rawUseAdminPlatforms();
|
||||
if(platforms.value === null){
|
||||
platforms.value = await $dropFetch("/api/v1/admin/platforms");
|
||||
}
|
||||
|
||||
return platforms.value!
|
||||
}
|
||||
@ -4,7 +4,7 @@ import type {
|
||||
NitroFetchRequest,
|
||||
TypedInternalResponse,
|
||||
} from "nitropack/types";
|
||||
import type { FetchError } from "ofetch";
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
interface DropFetch<
|
||||
DefaultT = unknown,
|
||||
@ -46,10 +46,31 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
});
|
||||
const request = requestParts.join("/");
|
||||
|
||||
// If not in setup
|
||||
if (!getCurrentInstance()?.proxy) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Excessive stack depth comparing types
|
||||
return await $fetch(request, opts);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Excessive stack depth comparing types
|
||||
return await $fetch(request, opts);
|
||||
} catch (e) {
|
||||
if (import.meta.client && opts?.failTitle) {
|
||||
console.warn(e);
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.message ?? (e as string).toString(),
|
||||
//buttonText: $t("common.close"),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
if(e instanceof FetchError) {
|
||||
e.message = e.data.message ?? e.message;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const id = request.toString();
|
||||
@ -64,26 +85,10 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
}
|
||||
|
||||
const headers = useRequestHeaders(["cookie", "authorization"]);
|
||||
try {
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (import.meta.client && opts?.failTitle) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.statusMessage ?? (e as string).toString(),
|
||||
buttonText: $t("common.close"),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
};
|
||||
@ -8,4 +8,5 @@ export type StoreFilterOption = {
|
||||
export type StoreSortOption = {
|
||||
name: string;
|
||||
param: string;
|
||||
platformIcon?: { key: string; fallback?: string };
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import type { TaskMessage } from "~/server/internal/tasks";
|
||||
import type { TaskMessage } from "~~/server/internal/tasks";
|
||||
import { WebSocketHandler } from "./ws";
|
||||
|
||||
const websocketHandler = new WebSocketHandler("/api/v1/task");
|
||||
@ -10,10 +10,4 @@ export type QuickActionNav = {
|
||||
icon: Component;
|
||||
notifications?: Ref<number>;
|
||||
action: () => Promise<void>;
|
||||
};
|
||||
|
||||
export enum PlatformClient {
|
||||
Windows = "Windows",
|
||||
Linux = "Linux",
|
||||
macOS = "macOS",
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
// undefined = haven't check
|
||||
// null = check, no user
|
||||
@ -1,6 +1,6 @@
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { AuthMec } from "~/prisma/client/enums";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
import type { AuthMec } from "~~/prisma/client/enums";
|
||||
|
||||
export const useUsers = () =>
|
||||
useState<
|
||||
@ -33,7 +33,7 @@ export class WebSocketHandler {
|
||||
case "unauthenticated": {
|
||||
const error = createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Unable to connect to websocket - unauthenticated",
|
||||
message: "Unable to connect to websocket - unauthenticated",
|
||||
});
|
||||
if (this.errorHandler) {
|
||||
return this.errorHandler(error);
|
||||
@ -8,6 +8,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
await updateUser();
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const user = useUser();
|
||||
@ -166,8 +166,6 @@ import {
|
||||
RectangleStackIcon,
|
||||
DocumentIcon,
|
||||
} 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";
|
||||
|
||||
@ -200,7 +198,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
icon: RectangleStackIcon,
|
||||
},
|
||||
{
|
||||
label: $t("header.admin.settings"),
|
||||
label: $t("header.admin.settings.title"),
|
||||
route: "/admin/settings",
|
||||
prefix: "/admin/settings",
|
||||
icon: Cog6ToothIcon,
|
||||
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
|
||||
<UserHeader class="z-50" hydrate-on-idle />
|
||||
<LazyUserHeader class="z-50" hydrate-on-idle />
|
||||
<div class="grow flex">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
<UserFooter class="z-50" hydrate-on-interaction />
|
||||
<LazyUserFooter class="z-50" hydrate-on-interaction />
|
||||
</div>
|
||||
<div v-else class="flex w-full min-h-screen bg-zinc-900">
|
||||
<NuxtPage />
|
||||
@ -92,7 +92,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
definePageMeta({
|
||||
229
app/pages/account/tokens.vue
Normal file
229
app/pages/account/tokens.vue
Normal file
@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div>
|
||||
<h2
|
||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
{{ $t("account.token.title") }}
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||
>
|
||||
{{ $t("account.token.subheader") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<LoadingButton :loading="false" @click="() => (createOpen = true)">
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newToken"
|
||||
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="shrink-0">
|
||||
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-green-300">
|
||||
{{ $t("account.token.success") }}
|
||||
</p>
|
||||
<p class="text-xs text-green-300/70">
|
||||
{{ $t("account.token.successNote") }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
|
||||
>
|
||||
{{ newToken }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto pl-3">
|
||||
<div class="-mx-1.5 -my-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
|
||||
@click="() => (newToken = undefined)"
|
||||
>
|
||||
<span class="sr-only">{{ $t("common.close") }}</span>
|
||||
<XMarkIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-800">
|
||||
<thead>
|
||||
<tr class="bg-zinc-800/50">
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ $t("common.name") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.token.acls") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.token.expiry") }}
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">{{ $t("actions") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tr
|
||||
v-for="(token, tokenIdx) in tokens"
|
||||
:key="token.id"
|
||||
class="transition-colors duration-150 hover:bg-zinc-800/50"
|
||||
>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ token.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="acl in token.acls"
|
||||
:key="acl"
|
||||
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
|
||||
>
|
||||
{{ acl }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
|
||||
<span v-else>{{ $t("account.token.noExpiry") }}</span>
|
||||
</td>
|
||||
<td
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
@click="() => revokeToken(tokenIdx)"
|
||||
>
|
||||
{{ $t("account.token.revoke") }}
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [token.name]) }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tokens.length === 0">
|
||||
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
|
||||
{{ $t("account.token.noTokens") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalCreateToken
|
||||
v-model="createOpen"
|
||||
:acls="acls"
|
||||
:loading="createLoading"
|
||||
:suggested-name="suggestedName"
|
||||
:suggested-acls="suggestedAcls"
|
||||
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import { DateTime, type DurationLike } from "luxon";
|
||||
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
|
||||
|
||||
const tokens = ref(await $dropFetch("/api/v1/user/token"));
|
||||
const acls = await $dropFetch("/api/v1/user/token/acls");
|
||||
|
||||
const createOpen = ref(false);
|
||||
const createLoading = ref(false);
|
||||
|
||||
const newToken = ref<string | undefined>();
|
||||
|
||||
const suggestedName = ref();
|
||||
const suggestedAcls = ref<string[]>([]);
|
||||
|
||||
const payloadParser = type({
|
||||
name: "string?",
|
||||
acls: "string[]?",
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
if (route.query.payload) {
|
||||
try {
|
||||
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
|
||||
const payload = payloadParser(rawPayload);
|
||||
if (payload instanceof ArkErrors) throw payload;
|
||||
suggestedName.value = payload.name;
|
||||
suggestedAcls.value = payload.acls ?? [];
|
||||
createOpen.value = true;
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: "Failed to parse the token create payload.",
|
||||
fatal: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken(
|
||||
name: string,
|
||||
acls: string[],
|
||||
expiry: DurationLike | undefined,
|
||||
) {
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const result = await $dropFetch("/api/v1/user/token", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name,
|
||||
acls,
|
||||
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
|
||||
},
|
||||
failTitle: "Failed to create API token.",
|
||||
});
|
||||
console.log(result);
|
||||
newToken.value = result.token;
|
||||
tokens.value.push(result);
|
||||
} finally {
|
||||
/* empty */
|
||||
}
|
||||
createOpen.value = false;
|
||||
createLoading.value = false;
|
||||
}
|
||||
|
||||
async function revokeToken(index: number) {
|
||||
const token = tokens.value[index];
|
||||
if (!token) return;
|
||||
|
||||
await $dropFetch("/api/v1/user/token/:id", {
|
||||
method: "DELETE",
|
||||
params: {
|
||||
id: token.id,
|
||||
},
|
||||
failTitle: "Failed to revoke token.",
|
||||
});
|
||||
|
||||
tokens.value.splice(index, 1);
|
||||
}
|
||||
</script>
|
||||
@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-4 max-w-lg">
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<Listbox
|
||||
as="div"
|
||||
:model-value="currentlySelectedVersion"
|
||||
class="max-w-lg"
|
||||
@update:model-value="(value) => updateCurrentlySelectedVersion(value)"
|
||||
>
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{
|
||||
@ -73,9 +74,32 @@
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<div v-if="versionGuesses" class="flex flex-col gap-8">
|
||||
<!-- setup executable -->
|
||||
<div>
|
||||
<div v-if="versionGuesses" class="flex flex-col gap-4">
|
||||
<!-- version name -->
|
||||
<div class="max-w-lg">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Version name</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
Shown to users when selecting what version to install.
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="name"
|
||||
v-model="versionSettings.name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="my version name"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- install command -->
|
||||
<div class="max-w-lg">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
@ -93,109 +117,14 @@
|
||||
>
|
||||
{{ $t("library.admin.import.version.installDir") }}
|
||||
</span>
|
||||
<Combobox
|
||||
as="div"
|
||||
:value="versionSettings.setup"
|
||||
nullable
|
||||
@update:model-value="(v) => updateSetupCommand(v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
:placeholder="
|
||||
$t('library.admin.import.version.setupPlaceholder')
|
||||
"
|
||||
@change="setupProcessQuery = $event.target.value"
|
||||
@blur="setupProcessQuery = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
v-if="setupFilteredVersionGuesses?.length ?? 0 > 0"
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="size-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="guess in setupFilteredVersionGuesses"
|
||||
:key="guess.filename"
|
||||
v-slot="{ active, selected }"
|
||||
:value="guess.filename"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-2 block truncate',
|
||||
selected && 'font-semibold',
|
||||
]"
|
||||
>
|
||||
{{ guess.filename }}
|
||||
<component
|
||||
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
|
||||
class="size-5"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<ComboboxOption
|
||||
v-if="setupProcessQuery"
|
||||
v-slot="{ active, selected }"
|
||||
:value="setupProcessQuery"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="['block truncate', selected && 'font-semibold']"
|
||||
>
|
||||
{{ $t("chars.quoted", { text: setupProcessQuery }) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
<PreloadSelector
|
||||
:value="versionSettings.install"
|
||||
:guesses="versionGuesses"
|
||||
@update="(v) => updateInstallCommand(v)"
|
||||
/>
|
||||
<input
|
||||
id="startup"
|
||||
v-model="versionSettings.setupArgs"
|
||||
v-model="versionSettings.installArgs"
|
||||
type="text"
|
||||
name="startup"
|
||||
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
@ -205,35 +134,43 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- setup mode -->
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
|
||||
<fieldset class="max-w-lg">
|
||||
<legend class="text-sm/6 font-semibold text-white">
|
||||
Select an import mode
|
||||
</legend>
|
||||
<div class="mt-2 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
|
||||
<label
|
||||
v-for="mode in setupModes"
|
||||
:key="mode.id"
|
||||
:aria-label="mode.title"
|
||||
:aria-description="mode.description"
|
||||
class="cursor-pointer group relative flex rounded-lg border border-white/10 bg-zinc-800/50 p-4 has-checked:bg-blue-500/10 has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-blue-500 has-focus-visible:outline-3 has-focus-visible:-outline-offset-1 has-disabled:bg-gray-800 has-disabled:opacity-25"
|
||||
>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
|
||||
$t("library.admin.import.version.setupModeDesc")
|
||||
}}</SwitchDescription>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="versionSettings.onlySetup"
|
||||
:class="[
|
||||
versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-800',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
versionSettings.onlySetup ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
]"
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
:value="mode.id"
|
||||
:checked="versionSettings.onlySetup === mode.value"
|
||||
class="absolute inset-0 appearance-none opacity-0 focus:outline-none"
|
||||
@click="versionSettings.onlySetup = mode.value"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="block text-sm font-medium text-white">{{
|
||||
mode.title
|
||||
}}</span>
|
||||
<span class="mt-1 block text-xs text-zinc-400">{{
|
||||
mode.description
|
||||
}}</span>
|
||||
</div>
|
||||
<CheckCircleIcon
|
||||
class="invisible size-5 text-blue-500 group-has-checked:visible"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- launch commands -->
|
||||
<div class="relative max-w-3xl">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
@ -242,134 +179,123 @@
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.launchDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<div class="mt-2 ml-4 flex flex-col gap-y-2 items-start">
|
||||
<div
|
||||
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||
v-for="(launch, launchIdx) in versionSettings.launches"
|
||||
:key="launchIdx"
|
||||
class="inline-flex items-center gap-x-2"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>{{ $t("library.admin.import.version.installDir") }}</span
|
||||
>
|
||||
<Combobox
|
||||
as="div"
|
||||
:value="versionSettings.launch"
|
||||
nullable
|
||||
@update:model-value="(v) => updateLaunchCommand(v)"
|
||||
>
|
||||
<div class="relative">
|
||||
<ComboboxInput
|
||||
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
:placeholder="
|
||||
$t('library.admin.import.version.launchPlaceholder')
|
||||
"
|
||||
@change="launchProcessQuery = $event.target.value"
|
||||
@blur="launchProcessQuery = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
v-if="launchFilteredVersionGuesses?.length ?? 0 > 0"
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="size-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="guess in launchFilteredVersionGuesses"
|
||||
:key="guess.filename"
|
||||
v-slot="{ active, selected }"
|
||||
:value="guess.filename"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-2 block truncate',
|
||||
selected && 'font-semibold',
|
||||
]"
|
||||
>
|
||||
{{ guess.filename }}
|
||||
<component
|
||||
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
|
||||
class="size-5"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<ComboboxOption
|
||||
v-if="launchProcessQuery"
|
||||
v-slot="{ active, selected }"
|
||||
:value="launchProcessQuery"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="['block truncate', selected && 'font-semibold']"
|
||||
>
|
||||
{{ $t("chars.quoted", { text: launchProcessQuery }) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
<input
|
||||
id="startup"
|
||||
v-model="versionSettings.launchArgs"
|
||||
id="launch-name"
|
||||
v-model="launch.name"
|
||||
type="text"
|
||||
name="startup"
|
||||
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="--launch"
|
||||
name="launch-name"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
placeholder="My Launch Command"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex w-full rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>{{ $t("library.admin.import.version.installDir") }}</span
|
||||
>
|
||||
<PreloadSelector
|
||||
:value="launch.launchCommand"
|
||||
:guesses="versionGuesses"
|
||||
@update="(v) => updateLaunchCommand(launchIdx, v)"
|
||||
/>
|
||||
<input
|
||||
id="startup"
|
||||
v-model="launch.launchArgs"
|
||||
type="text"
|
||||
name="startup"
|
||||
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="--launch"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="transition bg-zinc-800 rounded-sm aspect-square p-1 text-zinc-600 hover:text-red-600 hover:bg-red-600/20"
|
||||
@click="() => versionSettings.launches!.splice(launchIdx, 1)"
|
||||
>
|
||||
<TrashIcon class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="versionSettings.launches!.length == 0"
|
||||
class="uppercase font-display font-bold text-zinc-500 text-xs"
|
||||
>
|
||||
No launch commands
|
||||
</p>
|
||||
|
||||
<LoadingButton
|
||||
:loading="false"
|
||||
class="inline-flex items-center gap-x-4"
|
||||
@click="
|
||||
() =>
|
||||
versionSettings.launches!.push({
|
||||
name: '',
|
||||
description: '',
|
||||
launchCommand: '',
|
||||
launchArgs: '',
|
||||
})
|
||||
"
|
||||
>
|
||||
Add new <PlusIcon class="size-5" />
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="versionSettings.onlySetup"
|
||||
class="absolute inset-0 bg-zinc-900/50"
|
||||
/>
|
||||
</div>
|
||||
<!-- uninstall command -->
|
||||
<div class="max-w-lg">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Uninstall command</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
Executable to be run on uninstalling a game. Useful for installer-only
|
||||
games.
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>
|
||||
{{ $t("library.admin.import.version.installDir") }}
|
||||
</span>
|
||||
<PreloadSelector
|
||||
:value="versionSettings.uninstall"
|
||||
:guesses="versionGuesses"
|
||||
@update="(v) => updateUninstallCommand(v)"
|
||||
/>
|
||||
<input
|
||||
id="startup"
|
||||
v-model="versionSettings.uninstallArgs"
|
||||
type="text"
|
||||
name="startup"
|
||||
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="--uninstall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PlatformSelector v-model="versionSettings.platform">
|
||||
<PlatformSelector
|
||||
v-model="versionSettings.platform"
|
||||
class="max-w-lg"
|
||||
:platforms="allPlatforms"
|
||||
>
|
||||
{{ $t("library.admin.import.version.platform") }}
|
||||
</PlatformSelector>
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
<SwitchGroup as="div" class="flex items-center justify-between max-w-lg">
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
@ -383,7 +309,8 @@
|
||||
</SwitchDescription>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="versionSettings.delta"
|
||||
:model-value="versionSettings.delta || false"
|
||||
@update:model-value="(v) => (versionSettings.delta = v)"
|
||||
:class="[
|
||||
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-800',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
|
||||
@ -398,7 +325,7 @@
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<Disclosure v-slot="{ open }" as="div" class="py-2">
|
||||
<Disclosure v-slot="{ open }" as="div" class="py-2 max-w-lg">
|
||||
<dt>
|
||||
<DisclosureButton
|
||||
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
|
||||
@ -418,7 +345,7 @@
|
||||
>
|
||||
<!-- UMU launcher configuration -->
|
||||
<div
|
||||
v-if="versionSettings.platform == PlatformClient.Windows"
|
||||
v-if="versionSettings.platform == 'Linux'"
|
||||
class="flex flex-col gap-y-4"
|
||||
>
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
@ -467,7 +394,7 @@
|
||||
required
|
||||
:disabled="!umuIdEnabled"
|
||||
placeholder="umu-starcitizen"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -539,15 +466,17 @@ import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { ImportGameVersion } from "~~/server/api/v1/admin/import/version/index.post";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
@ -560,52 +489,32 @@ const gameId = route.params.id.toString();
|
||||
const versions = await $dropFetch(
|
||||
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
|
||||
);
|
||||
const userPlatforms = await useAdminPlatforms();
|
||||
const allPlatforms = renderPlatforms(userPlatforms);
|
||||
const currentlySelectedVersion = ref(-1);
|
||||
const versionSettings = ref<{
|
||||
platform: PlatformClient | undefined;
|
||||
|
||||
onlySetup: boolean;
|
||||
launch: string;
|
||||
launchArgs: string;
|
||||
setup: string;
|
||||
setupArgs: string;
|
||||
|
||||
delta: boolean;
|
||||
umuId: string;
|
||||
}>({
|
||||
platform: undefined,
|
||||
launch: "",
|
||||
launchArgs: "",
|
||||
setup: "",
|
||||
setupArgs: "",
|
||||
delta: false,
|
||||
const versionSettings = ref<Partial<ImportGameVersion>>({
|
||||
launches: [],
|
||||
onlySetup: false,
|
||||
umuId: "",
|
||||
});
|
||||
|
||||
const versionGuesses =
|
||||
ref<Array<{ platform: PlatformClient; filename: string }>>();
|
||||
const launchProcessQuery = ref("");
|
||||
const setupProcessQuery = ref("");
|
||||
ref<
|
||||
Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
|
||||
>();
|
||||
|
||||
const launchFilteredVersionGuesses = computed(() =>
|
||||
versionGuesses.value?.filter((e) =>
|
||||
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
const setupFilteredVersionGuesses = computed(() =>
|
||||
versionGuesses.value?.filter((e) =>
|
||||
e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
function updateLaunchCommand(value: string) {
|
||||
versionSettings.value.launch = value;
|
||||
function updateLaunchCommand(idx: number, value: string) {
|
||||
versionSettings.value.launches![idx].launchCommand = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function updateSetupCommand(value: string) {
|
||||
versionSettings.value.setup = value;
|
||||
function updateInstallCommand(value: string) {
|
||||
versionSettings.value.install = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
function updateUninstallCommand(value: string) {
|
||||
versionSettings.value.uninstall = value;
|
||||
autosetPlatform(value);
|
||||
}
|
||||
|
||||
@ -616,7 +525,8 @@ function autosetPlatform(value: string) {
|
||||
(e) => e.filename === value,
|
||||
);
|
||||
if (guessIndex == -1) return;
|
||||
versionSettings.value.platform = versionGuesses.value[guessIndex].platform;
|
||||
versionSettings.value.platform =
|
||||
versionGuesses.value[guessIndex].platform.param;
|
||||
}
|
||||
|
||||
const umuIdEnabled = ref(false);
|
||||
@ -639,15 +549,16 @@ async function updateCurrentlySelectedVersion(value: number) {
|
||||
if (currentlySelectedVersion.value == value) return;
|
||||
currentlySelectedVersion.value = value;
|
||||
const version = versions[currentlySelectedVersion.value];
|
||||
const results = await $dropFetch(
|
||||
const options = await $dropFetch(
|
||||
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
|
||||
gameId,
|
||||
)}&version=${encodeURIComponent(version)}`,
|
||||
)}&version=${encodeURIComponent(version)}&mode=game`,
|
||||
);
|
||||
versionGuesses.value = results.map((e) => ({
|
||||
versionGuesses.value = options.map((e) => ({
|
||||
...e,
|
||||
platform: e.platform as PlatformClient,
|
||||
platform: allPlatforms.find((v) => v.param === e.platform)!,
|
||||
}));
|
||||
versionSettings.value.name = version;
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
@ -657,6 +568,7 @@ async function startImport() {
|
||||
body: {
|
||||
id: gameId,
|
||||
version: versions[currentlySelectedVersion.value],
|
||||
mode: "game",
|
||||
...versionSettings.value,
|
||||
},
|
||||
});
|
||||
@ -667,10 +579,32 @@ function startImport_wrapper() {
|
||||
importLoading.value = true;
|
||||
startImport()
|
||||
.catch((error) => {
|
||||
importError.value = error.statusMessage ?? t("errors.unknown");
|
||||
importError.value = error.message ?? t("errors.unknown");
|
||||
})
|
||||
.finally(() => {
|
||||
importLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
const setupModes: Array<{
|
||||
id: string;
|
||||
value: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
id: "portable",
|
||||
value: false,
|
||||
title: "Portable",
|
||||
description:
|
||||
"This mode is for games that are designed to be launched directly from the install directory. Drop works best with these.",
|
||||
},
|
||||
{
|
||||
id: "setup",
|
||||
value: true,
|
||||
title: "Installer",
|
||||
description:
|
||||
"Also known as 'setup-only', this mode is for installers that modify the system directly, and install to directories like Program Files.",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
@ -7,66 +7,6 @@
|
||||
>
|
||||
<!--start-->
|
||||
<div>
|
||||
<Listbox v-if="false" v-model="currentMode" as="div">
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="col-start-1 row-start-1 truncate">{{
|
||||
currentMode
|
||||
}}</span>
|
||||
|
||||
<PencilIcon class="ml-auto size-5" />
|
||||
|
||||
<ChevronUpDownIcon
|
||||
class="text-gray-500 size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[value] in Object.entries(components)"
|
||||
v-slot="{ active, selected }"
|
||||
:key="value"
|
||||
as="template"
|
||||
:value="value"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-hidden'
|
||||
: 'text-zinc-100',
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ value }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
class="text-white absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
>
|
||||
<PencilIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<div class="pt-4 inline-flex gap-x-2">
|
||||
<div
|
||||
@ -112,18 +52,10 @@
|
||||
</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";
|
||||
@ -158,7 +90,6 @@ const components: {
|
||||
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}`,
|
||||
});
|
||||
|
||||
357
app/pages/admin/library/import.vue
Normal file
357
app/pages/admin/library/import.vue
Normal file
@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-6 w-full max-w-md">
|
||||
<Listbox
|
||||
as="div"
|
||||
:model="currentlySelectedGame"
|
||||
@update:model-value="(value) => updateSelectedGame(value)"
|
||||
>
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
|
||||
{{ $t("library.admin.import.selectGame") }}
|
||||
</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>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="({ game, library }, gameIdx) in games.unimportedGames"
|
||||
:key="game"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="gameIdx"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
gameIdx === currentlySelectedGame
|
||||
? 'font-semibold'
|
||||
: 'font-normal',
|
||||
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
|
||||
]"
|
||||
>{{ game }}
|
||||
<span
|
||||
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
|
||||
>{{ library.name }}</span
|
||||
></span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="gameIdx === currentlySelectedGame"
|
||||
: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>
|
||||
<div
|
||||
v-if="games.unimportedGames.length == 0"
|
||||
class="w-full uppercase font-display font-bold text-zinc-600 p-2 text-center"
|
||||
>
|
||||
Nothing to import
|
||||
</div>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
<Listbox v-model="currentImportMode" as="div">
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
|
||||
Import as
|
||||
</ListboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative inline-flex items-center w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span class="inline-flex items-top gap-x-2">
|
||||
<component
|
||||
:is="importModes[currentImportMode].icon"
|
||||
class="text-blue-600 size-8 p-1 bg-zinc-800 rounded-sm mt-1"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-sm font-bold text-zinc-200">
|
||||
{{ importModes[currentImportMode].name }}
|
||||
</h1>
|
||||
<p class="text-xs text-zinc-400 max-w-xs">
|
||||
{{ importModes[currentImportMode].description }}
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[mode, metadata] in Object.entries(importModes)"
|
||||
:key="mode"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="mode"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
mode == currentImportMode ? 'font-semibold' : 'font-normal',
|
||||
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
|
||||
]"
|
||||
>{{ metadata.name }}
|
||||
|
||||
<span
|
||||
v-if="mode == currentImportMode"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</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>
|
||||
|
||||
<component
|
||||
:is="importModes[currentImportMode].component"
|
||||
v-if="currentlySelectedGame !== -1"
|
||||
:game-name="games.unimportedGames[currentlySelectedGame].game"
|
||||
:loading="importLoading"
|
||||
:error="importError"
|
||||
@import="(...v: unknown[]) => importModes[currentImportMode].import(...v)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ImportGame, ImportRedist } from "#components";
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import { PuzzlePieceIcon, ArchiveBoxIcon } from "@heroicons/vue/24/solid";
|
||||
import type { FetchError } from "ofetch";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
type ImportMode = "Game" | "Redist";
|
||||
|
||||
const importModes = shallowRef<{
|
||||
[key in ImportMode]: {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
component: Component;
|
||||
import: (...v: unknown[]) => void;
|
||||
};
|
||||
}>({
|
||||
Game: {
|
||||
name: "Game",
|
||||
description: "Games can be added to user libraries, installed, and played.",
|
||||
icon: PuzzlePieceIcon,
|
||||
component: ImportGame,
|
||||
import: importGame_wrapper as (v: unknown) => void,
|
||||
},
|
||||
Redist: {
|
||||
name: "Redistributable",
|
||||
description:
|
||||
"Redistributables are packaged dependencies for games, that are installed alongside and required to play certain games.",
|
||||
icon: ArchiveBoxIcon,
|
||||
component: ImportRedist,
|
||||
import: importRedist as (v: unknown, k: unknown) => void,
|
||||
},
|
||||
});
|
||||
|
||||
const currentImportMode = ref<ImportMode>("Game");
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const rawGames = await $dropFetch("/api/v1/admin/import/game");
|
||||
const games = ref(rawGames);
|
||||
const currentlySelectedGame = ref(-1);
|
||||
const bulkImportMode = ref(false);
|
||||
|
||||
async function updateSelectedGame(value: number) {
|
||||
if (currentlySelectedGame.value == value || value == -1) return;
|
||||
const option = games.value.unimportedGames[value];
|
||||
if (!option) return;
|
||||
|
||||
currentlySelectedGame.value = value;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const importLoading = ref(false);
|
||||
const importError = ref<string | undefined>();
|
||||
async function importGame(metadata: GameMetadataSearchResult | undefined) {
|
||||
const option = games.value.unimportedGames[currentlySelectedGame.value];
|
||||
|
||||
const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
|
||||
method: "POST",
|
||||
body: {
|
||||
path: option.game,
|
||||
library: option.library.id,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bulkImportMode.value) {
|
||||
router.push(`/admin/task/${taskId}`);
|
||||
} else {
|
||||
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
|
||||
currentlySelectedGame.value = -1;
|
||||
}
|
||||
}
|
||||
async function importGame_wrapper(
|
||||
metadata: GameMetadataSearchResult | undefined,
|
||||
) {
|
||||
importLoading.value = true;
|
||||
importError.value = undefined;
|
||||
try {
|
||||
await importGame(metadata);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
importError.value =
|
||||
(error as FetchError)?.message || t("errors.unknown");
|
||||
}
|
||||
importLoading.value = false;
|
||||
}
|
||||
|
||||
async function importRedist(data: object, platform: object | undefined) {
|
||||
importLoading.value = true;
|
||||
importError.value = undefined;
|
||||
try {
|
||||
const option = games.value.unimportedGames[currentlySelectedGame.value];
|
||||
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
formData.append(
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
for (const [key, value] of Object.entries(platform)) {
|
||||
// Because we know there will be no file, and we need to handle more complex objects for
|
||||
// the platform, we do this.
|
||||
// Maybe we shouldn't.
|
||||
formData.append(
|
||||
`platform.${key}`,
|
||||
typeof value === "object" ? JSON.stringify(value) : value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
formData.append("library", option.library.id);
|
||||
formData.append("path", option.game);
|
||||
const redist = await $dropFetch("/api/v1/admin/import/redist", {
|
||||
body: formData,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!bulkImportMode.value) {
|
||||
router.push(`/admin/library/r/${redist.id}`);
|
||||
} else {
|
||||
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
|
||||
currentlySelectedGame.value = -1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
importError.value =
|
||||
(error as FetchError)?.message || t("errors.unknown");
|
||||
}
|
||||
importLoading.value = false;
|
||||
}
|
||||
</script>
|
||||
@ -71,41 +71,59 @@
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-x-4 text-zinc-300 font-bold uppercase font-display text-sm">
|
||||
<span class="inline-flex items-center gap-x-1"
|
||||
><div class="size-2 rounded-full bg-blue-600" />
|
||||
Game</span
|
||||
>
|
||||
<span class="inline-flex items-center gap-x-1"
|
||||
><div class="size-2 rounded-full bg-emerald-600" />
|
||||
Redistributable</span
|
||||
>
|
||||
</div>
|
||||
<ul
|
||||
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 filteredLibraryGames"
|
||||
:key="game.id"
|
||||
v-for="entry in filteredLibrary"
|
||||
:key="entry.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">
|
||||
<div
|
||||
v-if="entry.type === 'game'"
|
||||
class="relative flex flex-1 flex-row p-4 gap-x-4"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 right-0 w-10 bg-blue-600 h-4 rotate-[45deg] translate-x-1/2"
|
||||
/>
|
||||
|
||||
<img
|
||||
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
|
||||
:src="useObject(game.mIconObjectId)"
|
||||
:src="useObject(entry.mIconObjectId)"
|
||||
alt=""
|
||||
/>
|
||||
<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 }}
|
||||
{{ entry.mName }}
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
game.featured
|
||||
entry.featured
|
||||
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
|
||||
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
|
||||
]"
|
||||
@click="() => featureGame(game.id)"
|
||||
@click="() => featureGame(entry.id)"
|
||||
>
|
||||
<svg
|
||||
v-if="gameFeatureLoading[game.id]"
|
||||
v-if="gameFeatureLoading[entry.id]"
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
game.featured ? ' fill-zinc-900' : 'fill-zinc-100',
|
||||
entry.featured ? ' fill-zinc-900' : 'fill-zinc-100',
|
||||
'size-3 text-transparent animate-spin',
|
||||
]"
|
||||
viewBox="0 0 100 101"
|
||||
@ -126,13 +144,13 @@
|
||||
</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
|
||||
>{{ entry.library.name }}</span
|
||||
>
|
||||
</h3>
|
||||
<dl class="mt-1 flex flex-col justify-between">
|
||||
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
|
||||
<dd class="text-sm text-zinc-400">
|
||||
{{ game.mShortDescription }}
|
||||
{{ entry.mShortDescription }}
|
||||
</dd>
|
||||
<dt class="sr-only">
|
||||
{{ $t("library.admin.metadataProvider") }}
|
||||
@ -140,7 +158,7 @@
|
||||
</dl>
|
||||
<div class="mt-4 flex flex-col gap-y-1">
|
||||
<NuxtLink
|
||||
:href="`/admin/library/${game.id}`"
|
||||
:href="`/admin/library/g/${entry.id}`"
|
||||
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
@ -155,16 +173,79 @@
|
||||
</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="() => deleteGame(game.id)"
|
||||
@click="() => deleteGame(entry.id)"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="game.hasNotifications" class="flex flex-col gap-y-2 p-2">
|
||||
<div
|
||||
v-else-if="entry.type === 'redist'"
|
||||
class="relative flex flex-1 flex-row p-4 gap-x-4"
|
||||
>
|
||||
<div
|
||||
v-if="game.notifications.toImport"
|
||||
class="absolute top-0 right-0 w-10 bg-emerald-600 h-4 rotate-[45deg] translate-x-1/2"
|
||||
/>
|
||||
<img
|
||||
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
|
||||
:src="useObject(entry.mIconObjectId)"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<h3
|
||||
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
|
||||
>
|
||||
{{ entry.mName }}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
|
||||
>{{ entry.library.name }}</span
|
||||
>
|
||||
</h3>
|
||||
<dl class="mt-1 flex flex-col justify-between">
|
||||
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
|
||||
<dd class="text-sm text-zinc-400">
|
||||
{{ entry.mShortDescription }}
|
||||
</dd>
|
||||
</dl>
|
||||
<dl
|
||||
v-if="entry.platform"
|
||||
class="mt-2 flex items-center text-zinc-200 font-semibold text-sm gap-x-1 p-2 bg-zinc-800 rounded-xl"
|
||||
>
|
||||
<IconsPlatform
|
||||
:platform="entry.platform.id"
|
||||
:fallback="entry.platform.iconSvg"
|
||||
class="size-6 text-blue-600"
|
||||
/>
|
||||
<span>{{ entry.platform.platformName }}</span>
|
||||
</dl>
|
||||
<div class="mt-4 flex flex-col gap-y-1">
|
||||
<NuxtLink
|
||||
:href="`/admin/library/r/${entry.id}`"
|
||||
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.openEditor"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
@click="() => deleteRedist(entry.id)"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="entry.hasNotifications" class="flex flex-col gap-y-2 p-2">
|
||||
<div
|
||||
v-if="entry.notifications.toImport"
|
||||
class="rounded-md bg-blue-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
@ -180,7 +261,7 @@
|
||||
</p>
|
||||
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
||||
<NuxtLink
|
||||
:href="`/admin/library/${game.id}/import`"
|
||||
:href="`/admin/library/g/${entry.id}/import`"
|
||||
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||
>
|
||||
<i18n-t
|
||||
@ -198,7 +279,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="game.notifications.noVersions"
|
||||
v-if="entry.notifications.noVersions"
|
||||
class="rounded-md bg-yellow-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
@ -216,7 +297,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="game.notifications.offline"
|
||||
v-if="entry.notifications.offline"
|
||||
class="rounded-md bg-red-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
@ -236,17 +317,46 @@
|
||||
</div>
|
||||
</li>
|
||||
<p
|
||||
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
|
||||
v-if="filteredLibrary.length == 0 && libraryGames.length != 0"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("common.noResults") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
|
||||
v-if="
|
||||
filteredLibrary.length == 0 &&
|
||||
libraryGames.length == 0 &&
|
||||
libraryState.hasLibraries
|
||||
"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("library.admin.noGames") }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!libraryState.hasLibraries"
|
||||
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
|
||||
>
|
||||
<span class="text-sm font-display font-bold uppercase">{{
|
||||
$t("library.admin.libraryHint")
|
||||
}}</span>
|
||||
|
||||
<NuxtLink
|
||||
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
|
||||
href="https://docs.droposs.org/docs/library"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.libraryHintDocsLink"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@ -256,7 +366,11 @@ import {
|
||||
ExclamationTriangleIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/vue/16/solid";
|
||||
import { InformationCircleIcon, StarIcon } from "@heroicons/vue/20/solid";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
InformationCircleIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const { t } = useI18n();
|
||||
@ -272,29 +386,33 @@ useHead({
|
||||
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;
|
||||
};
|
||||
}
|
||||
>
|
||||
>(
|
||||
libraryState.games.map((e) => {
|
||||
// Potentially make a server-side transformation to make the client lighter
|
||||
function clientSideTransformation<T, V extends keyof T, K extends string>(
|
||||
values: Array<T & { status: (typeof libraryState.games)[number]["status"] }>,
|
||||
expand: V,
|
||||
type: K,
|
||||
): Array<
|
||||
T[V] & {
|
||||
status: "online" | "offline";
|
||||
type: K;
|
||||
hasNotifications?: boolean;
|
||||
notifications: {
|
||||
noVersions?: boolean;
|
||||
toImport?: boolean;
|
||||
offline?: boolean;
|
||||
};
|
||||
}
|
||||
> {
|
||||
return values.map((e) => {
|
||||
if (e.status == "offline") {
|
||||
return {
|
||||
...e.game,
|
||||
...e[expand],
|
||||
type: type,
|
||||
status: "offline" as const,
|
||||
hasNotifications: true,
|
||||
notifications: {
|
||||
@ -307,7 +425,8 @@ const libraryGames = ref<
|
||||
const toImport = e.status.unimportedVersions.length > 0;
|
||||
|
||||
return {
|
||||
...e.game,
|
||||
...e[expand],
|
||||
type: type,
|
||||
notifications: {
|
||||
noVersions,
|
||||
toImport,
|
||||
@ -315,13 +434,18 @@ const libraryGames = ref<
|
||||
hasNotifications: noVersions || toImport,
|
||||
status: "online" as const,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const libraryGames = ref(
|
||||
clientSideTransformation(libraryState.games, "value", "game"),
|
||||
);
|
||||
const libraryRedists = ref(
|
||||
clientSideTransformation(libraryState.redists, "value", "redist"),
|
||||
);
|
||||
|
||||
const filteredLibraryGames = computed(() =>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore excessively deep ts
|
||||
libraryGames.value.filter((e) => {
|
||||
const filteredLibrary = computed(() =>
|
||||
[...libraryGames.value, ...libraryRedists.value].filter((e) => {
|
||||
if (!searchQuery.value) return true;
|
||||
const searchQueryLower = searchQuery.value.toLowerCase();
|
||||
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
|
||||
@ -341,6 +465,16 @@ async function deleteGame(id: string) {
|
||||
toImport.value = true;
|
||||
}
|
||||
|
||||
async function deleteRedist(id: string) {
|
||||
await $dropFetch(`/api/v1/admin/redist/${id}`, {
|
||||
method: "DELETE",
|
||||
failTitle: "Failed to delete game",
|
||||
});
|
||||
const index = libraryRedists.value.findIndex((e) => e.id === id);
|
||||
libraryRedists.value.splice(index, 1);
|
||||
toImport.value = true;
|
||||
}
|
||||
|
||||
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
|
||||
async function featureGame(id: string) {
|
||||
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);
|
||||
1
app/pages/admin/library/r/[id]/import.vue
Normal file
1
app/pages/admin/library/r/[id]/import.vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
85
app/pages/admin/library/r/[id]/index.vue
Normal file
85
app/pages/admin/library/r/[id]/index.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
|
||||
>
|
||||
<!--start-->
|
||||
<div>
|
||||
<div class="pt-4 inline-flex gap-x-2">
|
||||
<div
|
||||
v-for="[value, { icon }] in Object.entries(components)"
|
||||
:key="value"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
|
||||
value == currentMode
|
||||
? 'bg-zinc-900 text-zinc-100'
|
||||
: 'bg-transparent text-zinc-500',
|
||||
]"
|
||||
@click="() => (currentMode = value as RedistEditorMode)"
|
||||
>
|
||||
<component :is="icon" class="size-4" />
|
||||
{{ value }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- open in store button -->
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
:is="components[currentMode].editor"
|
||||
v-model="redist"
|
||||
:unimported-versions="unimportedVersions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GameEditorVersion, RedistEditorMetadata } from "#components";
|
||||
import { DocumentIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const route = useRoute();
|
||||
const redistId = route.params.id.toString();
|
||||
const { redist: rawRedist, unimportedVersions } = await $dropFetch(
|
||||
`/api/v1/admin/redist/:id`,
|
||||
{ params: { id: redistId } },
|
||||
);
|
||||
const redist = ref(rawRedist);
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
enum RedistEditorMode {
|
||||
Metadata = "Metadata",
|
||||
Versions = "Versions",
|
||||
}
|
||||
|
||||
const components: {
|
||||
[key in RedistEditorMode]: { editor: Component; icon: Component };
|
||||
} = {
|
||||
[RedistEditorMode.Metadata]: { editor: RedistEditorMetadata, icon: DocumentIcon },
|
||||
[RedistEditorMode.Versions]: {
|
||||
editor: GameEditorVersion,
|
||||
icon: ServerStackIcon,
|
||||
},
|
||||
};
|
||||
|
||||
const currentMode = ref<RedistEditorMode>(RedistEditorMode.Metadata);
|
||||
|
||||
useHead({
|
||||
title: `${currentMode.value} - ${redist.value.mName}`,
|
||||
});
|
||||
|
||||
watch(currentMode, (v) => {
|
||||
useHead({
|
||||
title: `${v} - ${redist.value.mName}`,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@ -64,8 +64,14 @@
|
||||
>
|
||||
{{ source.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
{{ source.backend }}
|
||||
<td
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
|
||||
>
|
||||
<component
|
||||
:is="optionsMetadata[source.backend].icon"
|
||||
class="size-5 text-zinc-400"
|
||||
/>
|
||||
{{ optionsMetadata[source.backend].title }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<CheckIcon
|
||||
@ -189,11 +195,34 @@
|
||||
<RadioGroupLabel
|
||||
as="span"
|
||||
class="font-semibold text-zinc-100"
|
||||
>{{ source }}</RadioGroupLabel
|
||||
>{{ metadata.title }}
|
||||
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
|
||||
source
|
||||
}}</span>
|
||||
</RadioGroupLabel>
|
||||
<RadioGroupDescription
|
||||
as="span"
|
||||
class="text-zinc-400 text-xs"
|
||||
>
|
||||
<RadioGroupDescription as="span" class="text-zinc-400">
|
||||
{{ metadata.description }}
|
||||
</RadioGroupDescription>
|
||||
<NuxtLink
|
||||
:href="metadata.docsLink"
|
||||
:external="true"
|
||||
target="_blank"
|
||||
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.sources.documentationLink"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
@ -269,6 +298,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
DropLogo,
|
||||
SourceOptionsFilesystem,
|
||||
SourceOptionsFlatFilesystem,
|
||||
} from "#components";
|
||||
@ -279,12 +309,15 @@ import {
|
||||
RadioGroupLabel,
|
||||
RadioGroupOption,
|
||||
} from "@headlessui/vue";
|
||||
import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import { CheckIcon, DocumentIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import {
|
||||
XCircleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { Component } from "vue";
|
||||
import type { LibraryBackend } from "~/prisma/client/enums";
|
||||
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
|
||||
import type { LibraryBackend } from "~~/prisma/client/enums";
|
||||
import type { WorkingLibrarySource } from "~~/server/api/v1/admin/library/sources/index.get";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
@ -324,17 +357,23 @@ const optionUIs: { [key in LibraryBackend]: Component } = {
|
||||
};
|
||||
const optionsMetadata: {
|
||||
[key in LibraryBackend]: {
|
||||
title: string;
|
||||
description: string;
|
||||
docsLink: string;
|
||||
icon: Component;
|
||||
};
|
||||
} = {
|
||||
Filesystem: {
|
||||
title: t("library.admin.sources.fsTitle"),
|
||||
description: t("library.admin.sources.fsDesc"),
|
||||
icon: DocumentIcon,
|
||||
docsLink: "https://docs.droposs.org/docs/library#drop-style",
|
||||
icon: DropLogo,
|
||||
},
|
||||
FlatFilesystem: {
|
||||
title: t("library.admin.sources.fsFlatTitle"),
|
||||
description: t("library.admin.sources.fsFlatDesc"),
|
||||
icon: DocumentIcon,
|
||||
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
|
||||
icon: BackwardIcon,
|
||||
},
|
||||
};
|
||||
const optionsMetadataIter = Object.entries(optionsMetadata);
|
||||
@ -366,18 +405,21 @@ 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;
|
||||
}
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
actionSourceOpen.value = false;
|
||||
sourceConfig.value = {};
|
||||
sourceName.value = "";
|
||||
},
|
||||
(e) => {
|
||||
if (e instanceof FetchError) {
|
||||
console.log(e.data.message);
|
||||
modalError.value = e.message;
|
||||
} else {
|
||||
modalError.value = e as string;
|
||||
}
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
modalLoading.value = false;
|
||||
});
|
||||
@ -410,8 +452,8 @@ async function deleteSource(index: number) {
|
||||
{
|
||||
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"),
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user