mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-09 20:12:10 +10:00
Compare commits
190 Commits
p2p
...
2db8e753b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 545a6b154a | |||
| 442f940cc4 | |||
| 7d9525084d | |||
| 72c972a2a7 | |||
| b72e1ef7a4 | |||
| 786ad0ff82 | |||
| 00224bdd2f | |||
| 371e069e20 | |||
| 91bb7c7dd4 | |||
| 4e901164fb | |||
| 244f20b5f4 | |||
| e4c8d42cc8 | |||
| ed99e020df | |||
| 8363de2eed | |||
| 1ae051f066 | |||
| 45848d175e | |||
| 661dcf86a8 | |||
| a7b9bbc78a | |||
| 75842fbfb6 | |||
| 4ef2ab2587 | |||
| 935ff48b15 | |||
| 51390e115f | |||
| 7bfc441d1d | |||
| 2b70cea4e0 | |||
| e4fbc7cd50 | |||
| 706f2aac83 | |||
| 73c27f0984 | |||
| 12837d44fe | |||
| 12d87d6256 | |||
| 1bfdd73e4c | |||
| 60abc03091 | |||
| e32954ea7d | |||
| 72ae7a2884 | |||
| 9f5a3b3976 | |||
| de438b93d5 | |||
| 9f8890020f | |||
| 0e023534a7 | |||
| a199393e29 | |||
| ed90ae2775 | |||
| ca8ad37adf | |||
| 4184705b14 | |||
| d976ac87e3 | |||
| c3005813a2 | |||
| 9e929ddf98 | |||
| 681efe95af | |||
| c7fab132ab | |||
| 4f8ea3e4ff | |||
| f264fd0971 | |||
| 8a354f0674 | |||
| 3f78b6c94e | |||
| 2056871dc9 | |||
| 40e66def1e | |||
| 3e5c3678d5 | |||
| 490afd0bb7 | |||
| 3fbe514f65 | |||
| 185f37f135 | |||
| 6cc7e10fcd | |||
| 85edc4cca2 | |||
| 83a9b22d82 | |||
| fca85633c1 | |||
| 83a0ef2240 | |||
| 925f3cb4f0 | |||
| 2b61e9a371 | |||
| 4f789a2e5b | |||
| d1c09784a4 | |||
| 37fa9537d0 | |||
| f97a968e0d | |||
| 59b77b5a5e | |||
| ad2c0f982a | |||
| 04d5ad0519 | |||
| f08e1b40c3 | |||
| dc982df96b | |||
| d99c648259 | |||
| 233324d6fb | |||
| 15806a3c9f | |||
| c5f8b44537 | |||
| ea90a7f086 | |||
| be793ce0f7 | |||
| 093bb60eb2 | |||
| ddaba898ee | |||
| 0816d2ab3e | |||
| 4b009f1aca | |||
| 79a23ae1c6 | |||
| 0719ffe0fa | |||
| 21eec081ee | |||
| 4fbc730490 | |||
| a89c657fe1 | |||
| 831b20d737 | |||
| 59c3b9b76e | |||
| bee3b0c588 | |||
| 1165d86c2c | |||
| ce27f76856 | |||
| 8e3ae01a30 | |||
| 6dad3aeab7 | |||
| 1d141c117b | |||
| 1dba112bce | |||
| 9d2aded70f | |||
| 82b123a345 | |||
| a34f10d9b9 | |||
| 9bf36c8737 | |||
| bea26a9a6d | |||
| 6df2ef1740 | |||
| 56e1ba64ed | |||
| 898516b33d | |||
| 2cc3f1329c | |||
| b551788c4c | |||
| ccdbbcf01c | |||
| b033496710 | |||
| a101ff07c4 | |||
| 086664adfd | |||
| ce0e21be9a | |||
| dad2161754 | |||
| a8ee27eea9 | |||
| 1bbdf46a0e | |||
| fc74738643 | |||
| 60d22ea280 | |||
| 3df6818ffe | |||
| 856babbc21 | |||
| aad5c23f45 | |||
| 9f456cec9d | |||
| a95be39c17 | |||
| c6bb21d9ee |
@ -1,3 +1,9 @@
|
||||
Dockerfile
|
||||
.github
|
||||
.vscode
|
||||
*.md
|
||||
|
||||
#### gitignore below
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
@ -8,6 +14,7 @@ dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
.yarn
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@ -24,3 +31,13 @@ logs
|
||||
!.env.example
|
||||
|
||||
.data
|
||||
|
||||
|
||||
# deploy template
|
||||
deploy-template/*
|
||||
|
||||
!deploy-template/compose.yml
|
||||
|
||||
# generated prisma client
|
||||
/prisma/client
|
||||
/prisma/validate
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
|
||||
|
||||
CLIENT_CERTIFICATES="./.data/ca"
|
||||
|
||||
FS_BACKEND_PATH="./.data/objects"
|
||||
|
||||
GIANT_BOMB_API_KEY=""
|
||||
|
||||
EXTERNAL_URL="http://localhost:3000"
|
||||
|
||||
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
@ -1,6 +1,15 @@
|
||||
name: CI
|
||||
|
||||
on: [pull_request, push]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
@ -12,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
|
||||
@ -33,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
|
||||
|
||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@ -20,8 +20,29 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 3 # fix for when this gets triggered by tag
|
||||
fetch-tags: true
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine final version
|
||||
id: get_final_ver
|
||||
run: |
|
||||
BASE_VER=v$(jq -r '.version' package.json)
|
||||
TODAY=$(date +'%Y.%m.%d')
|
||||
|
||||
echo "Today will be: $TODAY"
|
||||
echo "today=$TODAY" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||
FINAL_VER="$BASE_VER"
|
||||
else
|
||||
FINAL_VER="${BASE_VER}-nightly.$TODAY"
|
||||
fi
|
||||
|
||||
echo "Drop's release tag will be: $FINAL_VER"
|
||||
echo "final_ver=$FINAL_VER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@ -46,6 +67,7 @@ jobs:
|
||||
ghcr.io/drop-OSS/drop
|
||||
tags: |
|
||||
type=schedule,pattern=nightly
|
||||
type=schedule,pattern=nightly.${{ steps.get_final_ver.outputs.today }}
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
@ -55,14 +77,33 @@ jobs:
|
||||
# set latest tag for stable releases
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v4
|
||||
id: cache
|
||||
with:
|
||||
path: cache-mount
|
||||
key: cache-mount-${{ hashFiles('Dockerfile') }}
|
||||
|
||||
- name: Restore Docker cache mounts
|
||||
uses: reproducible-containers/buildkit-cache-dance@v3
|
||||
with:
|
||||
builder: ${{ steps.setup-buildx.outputs.name }}
|
||||
cache-dir: cache-mount
|
||||
dockerfile: Dockerfile
|
||||
skip-extraction: ${{ steps.cache.outputs.cache-hit }}
|
||||
|
||||
- name: Build and push image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILD_DROP_VERSION=${{ steps.get_final_ver.outputs.final_ver }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -33,4 +33,5 @@ deploy-template/*
|
||||
!deploy-template/compose.yml
|
||||
|
||||
# generated prisma client
|
||||
/prisma/client
|
||||
/prisma/client
|
||||
/prisma/validate
|
||||
@ -1 +1,3 @@
|
||||
drop-base/
|
||||
# file is fully managed by pnpm, no reason to break it
|
||||
pnpm-lock.yaml
|
||||
|
||||
12
.vscode/extensions.json
vendored
Normal file
12
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"lokalise.i18n-ally",
|
||||
"esbenp.prettier-vscode",
|
||||
"Prisma.prisma",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"Vue.volar",
|
||||
"arktypeio.arkdark",
|
||||
"EditorConfig.EditorConfig",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
18
.vscode/settings.json
vendored
18
.vscode/settings.json
vendored
@ -17,5 +17,21 @@
|
||||
"strings": "on"
|
||||
},
|
||||
// prioritize ArkType's "type" for autoimports
|
||||
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
|
||||
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"],
|
||||
// i18n Ally settings
|
||||
"i18n-ally.sortKeys": true,
|
||||
"i18n-ally.keepFulfilled": true,
|
||||
"i18n-ally.extract.autoDetect": true,
|
||||
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.extract.ignored": [
|
||||
"string >= 14",
|
||||
"string.alphanumeric >= 5",
|
||||
"/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}"
|
||||
],
|
||||
"i18n-ally.extract.ignoredByFiles": {
|
||||
"pages/admin/library/sources/index.vue": ["Filesystem"],
|
||||
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
|
||||
"server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"]
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
73
Dockerfile
73
Dockerfile
@ -1,30 +1,61 @@
|
||||
# pull pre-configured and updated build environment
|
||||
FROM debian:testing-20250317-slim AS build-system
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# setup workdir - has to be the same filepath as app because fuckin' Prisma
|
||||
WORKDIR /app
|
||||
|
||||
# install dependencies and build
|
||||
RUN apt-get update -y
|
||||
RUN apt-get install node-corepack -y
|
||||
FROM node:lts-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
COPY . .
|
||||
RUN NUXT_TELEMETRY_DISABLED=1 yarn install --network-timeout 1000000
|
||||
RUN NUXT_TELEMETRY_DISABLED=1 yarn prisma generate
|
||||
RUN NUXT_TELEMETRY_DISABLED=1 yarn build
|
||||
|
||||
# create run environment for Drop
|
||||
FROM node:lts-slim AS run-system
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# so corepack knows pnpm's version
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# prevent prompt to download
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
# setup for offline
|
||||
RUN corepack pack
|
||||
# don't call out to network anymore
|
||||
ENV COREPACK_ENABLE_NETWORK=0
|
||||
|
||||
### Unified deps builder
|
||||
FROM base AS deps
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
### Build for app
|
||||
FROM base AS build-system
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# add git so drop can determine its git ref at build
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# copy deps and rest of project files
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG BUILD_DROP_VERSION
|
||||
ARG BUILD_GIT_REF
|
||||
|
||||
# build
|
||||
RUN pnpm run postinstall && pnpm run build
|
||||
|
||||
### create run environment for Drop
|
||||
FROM base AS run-system
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
|
||||
RUN apk add --no-cache pnpm
|
||||
RUN pnpm install prisma@6.11.1
|
||||
# init prisma to download all required files
|
||||
RUN pnpm prisma init
|
||||
|
||||
COPY --from=build-system /app/package.json ./
|
||||
COPY --from=build-system /app/.output ./app
|
||||
COPY --from=build-system /app/prisma ./prisma
|
||||
COPY --from=build-system /app/package.json ./
|
||||
COPY --from=build-system /app/build ./startup
|
||||
|
||||
# OpenSSL as a dependency for Drop (TODO: seperate build environment)
|
||||
RUN apt-get update -y && apt-get install -y openssl
|
||||
RUN yarn global add prisma@6.7.0
|
||||
ENV LIBRARY="/library"
|
||||
ENV DATA="/data"
|
||||
|
||||
CMD ["/app/startup/launch.sh"]
|
||||
CMD ["sh", "/app/startup/launch.sh"]
|
||||
|
||||
72
README.md
72
README.md
@ -6,72 +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>
|
||||
13
app/assets/tailwindcss.css
Normal file
13
app/assets/tailwindcss.css
Normal file
@ -0,0 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "@tailwindcss/forms";
|
||||
@config "../../tailwind.config.js";
|
||||
|
||||
@layer base {
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"] {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: textfield !important;
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4">
|
||||
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
|
||||
<UserIcon class="size-5" /> Account Settings
|
||||
<UserIcon class="size-5" /> {{ $t("account.title") }}
|
||||
</span>
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||
@ -45,35 +45,43 @@ import {
|
||||
LockClosedIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
CodeBracketIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { UserIcon } from "@heroicons/vue/24/solid";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const notifications = useNotifications();
|
||||
const { t } = useI18n();
|
||||
|
||||
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||
{ label: "Home", route: "/account", icon: HomeIcon, prefix: "/account" },
|
||||
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
|
||||
{
|
||||
label: "Security",
|
||||
label: t("security"),
|
||||
route: "/account/security",
|
||||
prefix: "/account/security",
|
||||
icon: LockClosedIcon,
|
||||
},
|
||||
{
|
||||
label: "Devices",
|
||||
label: t("account.devices.title"),
|
||||
route: "/account/devices",
|
||||
prefix: "/account/devices",
|
||||
icon: DevicePhoneMobileIcon,
|
||||
},
|
||||
{
|
||||
label: "Notifications",
|
||||
label: t("account.notifications.notifications"),
|
||||
route: "/account/notifications",
|
||||
prefix: "/account/notifications",
|
||||
icon: BellIcon,
|
||||
count: notifications.value.length,
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
label: t("account.token.title"),
|
||||
route: "/account/tokens",
|
||||
prefix: "/account/tokens",
|
||||
icon: CodeBracketIcon,
|
||||
},
|
||||
{
|
||||
label: t("account.settings"),
|
||||
route: "/account/settings",
|
||||
prefix: "/account/settings",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
@ -8,7 +8,7 @@
|
||||
class="transition w-full inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
|
||||
@click="() => toggleLibrary()"
|
||||
>
|
||||
{{ inLibrary ? "In Library" : "Add to Library" }}
|
||||
{{ inLibrary ? $t("library.inLib") : $t("library.addToLib") }}
|
||||
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||
</LoadingButton>
|
||||
@ -36,7 +36,7 @@
|
||||
<div
|
||||
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
|
||||
>
|
||||
Collections
|
||||
{{ $t("library.collection.collections") }}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-y-2 py-1 max-h-[150px] overflow-y-auto"
|
||||
@ -45,7 +45,7 @@
|
||||
v-if="collections.length === 0"
|
||||
class="px-3 py-2 text-sm text-zinc-500"
|
||||
>
|
||||
No collections
|
||||
{{ $t("library.collection.noCollections") }}
|
||||
</div>
|
||||
<MenuItem
|
||||
v-for="(collection, collectionIdx) in collections"
|
||||
@ -75,7 +75,7 @@
|
||||
@click="createCollectionModal = true"
|
||||
>
|
||||
<PlusIcon class="mr-2 h-4 w-4" />
|
||||
Add to new collection
|
||||
{{ $t("library.collection.addToNew") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
@ -84,7 +84,7 @@
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<CreateCollectionModal
|
||||
<ModalCreateCollection
|
||||
v-model="createCollectionModal"
|
||||
:game-id="props.gameId"
|
||||
/>
|
||||
@ -100,6 +100,7 @@ const props = defineProps<{
|
||||
|
||||
const isLibraryLoading = ref(false);
|
||||
|
||||
const { t } = useI18n();
|
||||
const createCollectionModal = ref(false);
|
||||
const collections = await useCollections();
|
||||
const library = await useLibrary();
|
||||
@ -121,18 +122,9 @@ async function toggleLibrary() {
|
||||
body: {
|
||||
id: props.gameId,
|
||||
},
|
||||
failTitle: t("errors.library.add.title"),
|
||||
});
|
||||
await refreshLibrary();
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to add game to library",
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
} finally {
|
||||
isLibraryLoading.value = false;
|
||||
}
|
||||
@ -144,24 +136,18 @@ async function toggleCollection(id: string) {
|
||||
if (!collection) return;
|
||||
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
|
||||
|
||||
await $dropFetch(`/api/v1/collection/${id}/entry`, {
|
||||
await $dropFetch(`/api/v1/collection/:id/entry`, {
|
||||
method: index == -1 ? "POST" : "DELETE",
|
||||
params: { id },
|
||||
body: {
|
||||
id: props.gameId,
|
||||
},
|
||||
failTitle: t("errors.library.add.title"),
|
||||
});
|
||||
|
||||
await refreshCollection(id);
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to add game to library",
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
} finally {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
18
app/components/Auth/OpenID.vue
Normal file
18
app/components/Auth/OpenID.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<a
|
||||
:href="`/auth/oidc?redirect=${route.query.redirect ?? '/'}`"
|
||||
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
|
||||
>
|
||||
<i18n-t keypath="auth.signin.externalProvider" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
</script>
|
||||
@ -4,7 +4,7 @@
|
||||
<label
|
||||
for="username"
|
||||
class="block text-sm font-medium leading-6 text-zinc-300"
|
||||
>Username</label
|
||||
>{{ $t("auth.username") }}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
@ -23,7 +23,7 @@
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium leading-6 text-zinc-300"
|
||||
>Password</label
|
||||
>{{ $t("auth.password") }}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
@ -50,19 +50,23 @@
|
||||
<label
|
||||
for="remember-me"
|
||||
class="ml-3 block text-sm leading-6 text-zinc-400"
|
||||
>Remember me</label
|
||||
>{{ $t("auth.signin.rememberMe") }}</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="text-sm leading-6">
|
||||
<NuxtLink to="#" class="font-semibold text-blue-600 hover:text-blue-500"
|
||||
>Forgot password?</NuxtLink
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="font-semibold text-blue-600 hover:text-blue-500"
|
||||
>{{ $t("auth.signin.forgot") }}</NuxtLink
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoadingButton class="w-full" :loading="loading"> Sign in</LoadingButton>
|
||||
<LoadingButton class="w-full" :loading="loading">{{
|
||||
$t("auth.signin.signin")
|
||||
}}</LoadingButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
|
||||
@ -82,7 +86,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import type { User } from "~/prisma/client";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
@ -93,6 +97,7 @@ const error = ref<string | undefined>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
function signin_wrapper() {
|
||||
loading.value = true;
|
||||
@ -101,7 +106,7 @@ function signin_wrapper() {
|
||||
router.push(route.query.redirect?.toString() ?? "/");
|
||||
})
|
||||
.catch((response) => {
|
||||
const message = response.statusMessage || "An unknown error occurred";
|
||||
const message = response.message || t("errors.unknown");
|
||||
error.value = message;
|
||||
})
|
||||
.finally(() => {
|
||||
@ -119,6 +124,6 @@ async function signin() {
|
||||
},
|
||||
});
|
||||
const user = useUser();
|
||||
user.value = await $dropFetch<User | null>("/api/v1/user");
|
||||
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
|
||||
}
|
||||
</script>
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
|
||||
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
|
||||
<Bars3Icon class="size-6" /> Library
|
||||
<Bars3Icon class="size-6" /> {{ $t("userHeader.links.library") }}
|
||||
</span>
|
||||
|
||||
<!-- Search bar -->
|
||||
@ -13,7 +13,7 @@
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
|
||||
placeholder="Search library..."
|
||||
:placeholder="$t('library.search')"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
|
||||
@ -31,11 +31,11 @@
|
||||
<li v-for="game in filteredLibrary" :key="game.id" class="flex">
|
||||
<NuxtLink
|
||||
:to="`/library/game/${game.id}`"
|
||||
class="flex flex-row items-center w-full p-1 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
|
||||
class="flex flex-row items-center w-full p-2 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
|
||||
>
|
||||
<img
|
||||
:src="useObject(game.mCoverObjectId)"
|
||||
class="h-9 w-9 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
|
||||
:src="useObject(game.mIconObjectId)"
|
||||
class="h-5 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
|
||||
alt=""
|
||||
/>
|
||||
<div class="min-w-0 flex-1 pl-2.5">
|
||||
@ -53,7 +53,7 @@
|
||||
v-else
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
|
||||
>
|
||||
{{ !!searchQuery ? "No results" : "No games in library" }}
|
||||
{{ !!searchQuery ? $t("common.noResults") : $t("library.noGames") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@ -6,7 +6,7 @@
|
||||
<!-- Search and filters -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label for="search" class="sr-only">Search articles</label>
|
||||
<label for="search" class="sr-only">{{ $t("news.search") }}</label>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
|
||||
@ -21,31 +21,35 @@
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||
placeholder="Search articles..."
|
||||
:placeholder="$t('news.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<label for="date" class="block text-sm font-medium text-zinc-400 mb-2"
|
||||
>Date</label
|
||||
<label
|
||||
for="date"
|
||||
class="block text-sm font-medium text-zinc-400 mb-2"
|
||||
>{{ $t("common.date") }}</label
|
||||
>
|
||||
<select
|
||||
id="date"
|
||||
v-model="dateFilter"
|
||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-800 py-2 pl-3 pr-10 text-zinc-100 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<option value="all">All time</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="week">This week</option>
|
||||
<option value="month">This month</option>
|
||||
<option value="year">This year</option>
|
||||
<option value="all">{{ $t("news.filter.all") }}</option>
|
||||
<option value="today">{{ $t("common.today") }}</option>
|
||||
<option value="week">{{ $t("news.filter.week") }}</option>
|
||||
<option value="month">{{ $t("news.filter.month") }}</option>
|
||||
<option value="year">{{ $t("news.filter.year") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-zinc-400 mb-2">Tags</label>
|
||||
<label class="block text-sm font-medium text-zinc-400 mb-2">
|
||||
{{ $t("common.tags") }}
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in availableTags"
|
||||
@ -87,9 +91,7 @@
|
||||
:src="useObject(article.imageObjectId)"
|
||||
class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-800 transition-all duration-200"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-zinc-900/50" />
|
||||
</div>
|
||||
|
||||
<h3 class="relative text-sm font-medium text-zinc-100">
|
||||
@ -102,9 +104,9 @@
|
||||
<div
|
||||
class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"
|
||||
>
|
||||
<time :datetime="article.publishedAt">{{
|
||||
formatDate(article.publishedAt)
|
||||
}}</time>
|
||||
<time :datetime="article.publishedAt">
|
||||
{{ $d(new Date(article.publishedAt), "short") }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
@ -146,20 +148,9 @@ const toggleTag = (tag: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatExcerpt = (excerpt: string) => {
|
||||
// TODO: same as one in NewsArticleCreateButton
|
||||
// Convert markdown to HTML
|
||||
const html = micromark(excerpt);
|
||||
// Strip HTML tags using regex
|
||||
return html.replace(/<[^>]*>/g, "");
|
||||
// Convert markdown to HTML, micromark is safe
|
||||
return micromark(excerpt);
|
||||
};
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
@ -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,9 +10,9 @@
|
||||
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z"
|
||||
/>
|
||||
</svg>
|
||||
<DropLogo class="h-6" />
|
||||
<span class="text-blue-400 font-display font-bold text-xl uppercase"
|
||||
>Drop</span
|
||||
>
|
||||
<DropLogo aria-hidden="true" class="h-6" />
|
||||
<span class="text-blue-400 font-display font-bold text-xl uppercase">
|
||||
{{ $t("drop.drop") }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
15
app/components/EmojiText.vue
Normal file
15
app/components/EmojiText.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<img ref="emojiEl" class="inline-block emoji" :src="url" :alt="emoji" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import twemoji from "@discordapp/twemoji";
|
||||
|
||||
const props = defineProps<{
|
||||
emoji: string;
|
||||
}>();
|
||||
|
||||
const url = computed(() => {
|
||||
return `/twemoji/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
|
||||
});
|
||||
</script>
|
||||
@ -7,7 +7,11 @@
|
||||
:key="gameIdx"
|
||||
class="justify-start"
|
||||
>
|
||||
<GamePanel :game="game" />
|
||||
<GamePanel
|
||||
:game="game"
|
||||
:href="game ? `/store/${game.id}` : undefined"
|
||||
:show-title-description="showGamePanelTextDecoration"
|
||||
/>
|
||||
</VueSlide>
|
||||
|
||||
<template #addons>
|
||||
@ -31,19 +35,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Game } from "~/prisma/client";
|
||||
import type { GameModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
items: Array<SerializeObject<Game>>;
|
||||
items: Array<SerializeObject<GameModel>>;
|
||||
min?: number;
|
||||
width?: number;
|
||||
}>();
|
||||
|
||||
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
|
||||
|
||||
const currentComponent = ref<HTMLDivElement>();
|
||||
|
||||
const min = computed(() => Math.max(props.min ?? 8, props.items.length));
|
||||
const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() =>
|
||||
const games: Ref<Array<SerializeObject<GameModel> | undefined>> = computed(() =>
|
||||
Array(min.value)
|
||||
.fill(0)
|
||||
.map((_, i) => props.items[i]),
|
||||
@ -1,16 +1,14 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="game && unimportedVersions !== undefined"
|
||||
class="grow flex flex-col gap-y-8"
|
||||
>
|
||||
<div class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col">
|
||||
<div v-if="game!">
|
||||
<div class="grow flex flex-row gap-y-8">
|
||||
<div class="grow w-full h-full px-6 py-4 flex flex-col">
|
||||
<div
|
||||
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
|
||||
>
|
||||
<div class="inline-flex items-center gap-4">
|
||||
<img :src="useObject(game.mIconObjectId)" class="size-20" />
|
||||
<!-- icon image -->
|
||||
<img :src="coreMetadataIconUrl" class="size-20" />
|
||||
<div>
|
||||
<h1 class="text-5xl font-bold font-display text-zinc-100">
|
||||
{{ game.mName }}
|
||||
@ -25,10 +23,14 @@
|
||||
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (showEditCoreMetadata = true)"
|
||||
>
|
||||
Edit <PencilIcon class="size-4" />
|
||||
{{ $t("common.edit") }} <PencilIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
|
||||
<MultiItemSelector v-model="currentTags" :items="tags" />
|
||||
</div>
|
||||
|
||||
<!-- image carousel pick -->
|
||||
<div class="border-b border-zinc-700">
|
||||
<div class="border-b border-zinc-700 py-4">
|
||||
@ -37,11 +39,10 @@
|
||||
>
|
||||
<div class="ml-4 mt-4">
|
||||
<h3 class="text-base font-semibold text-zinc-100">
|
||||
Image Carousel
|
||||
{{ $t("library.admin.game.imageCarousel") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
|
||||
Customise what images and what order are shown on the store
|
||||
page.
|
||||
{{ $t("library.admin.game.imageCarouselDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4 mt-4 shrink-0">
|
||||
@ -50,7 +51,7 @@
|
||||
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (showAddCarouselModal = true)"
|
||||
>
|
||||
Add from image library
|
||||
{{ $t("library.admin.game.addImageCarousel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -59,7 +60,7 @@
|
||||
v-if="game.mImageCarouselObjectIds.length == 0"
|
||||
class="text-zinc-400 text-center py-8"
|
||||
>
|
||||
No images added to the carousel yet.
|
||||
{{ $t("library.admin.game.imageCarouselEmpty") }}
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
@ -76,10 +77,10 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => removeImageFromCarousel(element)"
|
||||
>
|
||||
Remove image
|
||||
{{ $t("library.admin.game.removeImageCarousel") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -97,13 +98,18 @@
|
||||
>
|
||||
<div>
|
||||
<CheckIcon
|
||||
v-if="descriptionSaving == 0"
|
||||
v-if="descriptionSaving == DescriptionSavingState.NotLoading"
|
||||
class="size-5 text-zinc-100"
|
||||
/>
|
||||
<div v-else-if="descriptionSaving == 1">
|
||||
<div
|
||||
v-else-if="descriptionSaving == DescriptionSavingState.Waiting"
|
||||
>
|
||||
<PencilIcon class="animate-pulse size-5 text-zinc-100" />
|
||||
</div>
|
||||
<div v-else-if="descriptionSaving == 2" role="status">
|
||||
<div
|
||||
v-else-if="descriptionSaving == DescriptionSavingState.Loading"
|
||||
role="status"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5 text-transparent animate-spin fill-white"
|
||||
@ -120,7 +126,7 @@
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
<span class="sr-only">{{ $t("common.srLoading") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -173,36 +179,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:fixed lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col lg:right-0 gap-y-8 px-6 py-4"
|
||||
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
|
||||
>
|
||||
<!-- toolbar -->
|
||||
<div class="inline-flex justify-end items-stretch gap-x-4">
|
||||
<!-- open in library button -->
|
||||
<NuxtLink
|
||||
:href="`/admin/library/${game.id}`"
|
||||
type="button"
|
||||
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
Open in Library →
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="-mr-0.5 h-7 w-7 p-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<!-- open in store button -->
|
||||
<NuxtLink
|
||||
:href="`/store/${game.id}`"
|
||||
type="button"
|
||||
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
Open in Store
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="-mr-0.5 h-7 w-7 p-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- image library -->
|
||||
<div>
|
||||
<div class="border-b border-zinc-800 pb-3">
|
||||
@ -213,11 +191,10 @@
|
||||
<h3
|
||||
class="text-base font-semibold font-display leading-6 text-zinc-100"
|
||||
>
|
||||
Image library
|
||||
{{ $t("library.admin.game.imageLibrary") }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
|
||||
Please note all images uploaded are accessible to all users
|
||||
through browser dev-tools.
|
||||
{{ $t("library.admin.game.imageLibraryDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
@ -226,7 +203,7 @@
|
||||
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (showUploadModal = true)"
|
||||
>
|
||||
Upload
|
||||
{{ $t("upload") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -244,25 +221,25 @@
|
||||
<button
|
||||
v-if="image !== game.mBannerObjectId"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => updateBannerImage(image)"
|
||||
>
|
||||
Set as banner
|
||||
{{ $t("library.admin.game.setBanner") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="image !== game.mCoverObjectId"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => updateCoverImage(image)"
|
||||
>
|
||||
Set as cover
|
||||
{{ $t("library.admin.game.setCover") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
@click="() => deleteImage(image)"
|
||||
>
|
||||
Delete image
|
||||
{{ $t("library.admin.game.deleteImage") }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@ -270,34 +247,44 @@
|
||||
image === game.mBannerObjectId ||
|
||||
image === game.mCoverObjectId
|
||||
"
|
||||
class="absolute bottom-0 left-0 bg-zinc-950/75 text-zinc-100 text-sm font-semibold px-2 py-1 rounded-tr"
|
||||
class="absolute bottom-0 left-0 flex flex-row gap-x-1 p-1"
|
||||
>
|
||||
current
|
||||
{{
|
||||
[
|
||||
image === game.mBannerObjectId ? "banner" : undefined,
|
||||
image === game.mCoverObjectId ? "cover" : undefined,
|
||||
]
|
||||
.filter((e) => e)
|
||||
.join(" & ")
|
||||
}}
|
||||
<span
|
||||
v-for="[key] of (
|
||||
[
|
||||
[
|
||||
$t('library.admin.game.currentBanner'),
|
||||
image === game.mBannerObjectId,
|
||||
],
|
||||
[
|
||||
$t('library.admin.game.currentCover'),
|
||||
image === game.mCoverObjectId,
|
||||
],
|
||||
] as const
|
||||
).filter((e) => e[1])"
|
||||
:key="key"
|
||||
class="inline-flex items-center rounded-full bg-blue-900 px-2 py-1 text-xs font-medium text-blue-100"
|
||||
>{{ key }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<UploadFileDialog
|
||||
<ModalUploadFile
|
||||
v-model="showUploadModal"
|
||||
:options="{ id: game.id }"
|
||||
accept="image/*"
|
||||
endpoint="/api/v1/admin/game/image"
|
||||
@upload="(result: Game) => uploadAfterImageUpload(result)"
|
||||
:multiple="true"
|
||||
@upload="(result: GameModel) => uploadAfterImageUpload(result)"
|
||||
/>
|
||||
<ModalTemplate v-model="showAddCarouselModal">
|
||||
<template #default>
|
||||
<div class="grid grid-cols-2 grid-flow-dense gap-4">
|
||||
<div
|
||||
class="grid grid-cols-2 grid-flow-dense gap-4 max-h-[70vh] overflow-y-auto p-4"
|
||||
>
|
||||
<div
|
||||
v-for="(image, imageIdx) in validAddCarouselImages"
|
||||
:key="imageIdx"
|
||||
@ -309,10 +296,10 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => addImageToCarousel(image)"
|
||||
>
|
||||
Add
|
||||
{{ $t("add") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -320,7 +307,7 @@
|
||||
v-if="validAddCarouselImages.length == 0"
|
||||
class="text-zinc-400 col-span-2"
|
||||
>
|
||||
No images to add.
|
||||
{{ $t("library.admin.game.addCarouselNoImages") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -328,10 +315,10 @@
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
|
||||
@click="showAddCarouselModal = false"
|
||||
>
|
||||
Cancel
|
||||
{{ $t("common.close") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
@ -349,10 +336,10 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => insertImageAtCursor(image)"
|
||||
>
|
||||
Insert
|
||||
{{ $t("common.insert") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -360,7 +347,7 @@
|
||||
v-if="game.mImageLibraryObjectIds.length == 0"
|
||||
class="text-zinc-400 col-span-2"
|
||||
>
|
||||
No images to add.
|
||||
{{ $t("library.admin.game.addDescriptionNoImages") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -368,10 +355,10 @@
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
|
||||
@click="showAddCarouselModal = false"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
|
||||
@click="showAddImageDescriptionModal = false"
|
||||
>
|
||||
Cancel
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
@ -386,14 +373,14 @@
|
||||
type="button"
|
||||
class="cursor-pointer relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
Upload
|
||||
{{ $t("upload") }}
|
||||
</span>
|
||||
<input
|
||||
id="file-upload"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="(e) => coreMetadataUploadFiles(e as any)"
|
||||
@change="(e: Event) => coreMetadataUploadFiles(e as any)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@ -403,7 +390,7 @@
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>Game Name</label
|
||||
>{{ $t("library.admin.game.editGameName") }}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
@ -419,7 +406,7 @@
|
||||
<label
|
||||
for="description"
|
||||
class="block text-sm/6 font-medium text-zinc-100"
|
||||
>Game Description</label
|
||||
>{{ $t("library.admin.game.editGameDescription") }}</label
|
||||
>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
@ -441,15 +428,15 @@
|
||||
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
|
||||
@click="() => coreMetadataUpdate_wrapper()"
|
||||
>
|
||||
Save
|
||||
{{ $t("common.save") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 sm:mt-0 sm:w-auto"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
|
||||
@click="showEditCoreMetadata = false"
|
||||
>
|
||||
Cancel
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
@ -457,19 +444,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Game } from "~/prisma/client";
|
||||
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
|
||||
import { micromark } from "micromark";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CheckIcon,
|
||||
DocumentIcon,
|
||||
PencilIcon,
|
||||
PhotoIcon,
|
||||
} from "@heroicons/vue/24/solid";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { H3Error } from "h3";
|
||||
|
||||
const showUploadModal = ref(false);
|
||||
const showAddCarouselModal = ref(false);
|
||||
@ -477,13 +461,39 @@ const showAddImageDescriptionModal = ref(false);
|
||||
const showEditCoreMetadata = ref(false);
|
||||
const mobileShowFinalDescription = ref(true);
|
||||
|
||||
const route = useRoute();
|
||||
const gameId = route.params.id.toString();
|
||||
const { game: rawGame, unimportedVersions } = await $dropFetch(
|
||||
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`,
|
||||
);
|
||||
const game = ref(rawGame);
|
||||
type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
|
||||
const game = defineModel<ModelType>() as Ref<ModelType>;
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
const currentTags = ref<{ [key: string]: boolean }>(
|
||||
Object.fromEntries(game.value.tags.map((e) => [e.id, true])),
|
||||
);
|
||||
const tags = (await $dropFetch("/api/v1/admin/tags")).map(
|
||||
(e) => ({ name: e.name, param: e.id }) satisfies StoreSortOption,
|
||||
);
|
||||
|
||||
watch(
|
||||
currentTags,
|
||||
async (v) => {
|
||||
await $dropFetch(`/api/v1/admin/game/:id/tags`, {
|
||||
method: "PATCH",
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: { tags: Object.keys(v) },
|
||||
failTitle: "Failed to update game tags",
|
||||
});
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// I don't know why I split these fields off.
|
||||
const coreMetadataName = ref(game.value.mName);
|
||||
const coreMetadataDescription = ref(game.value.mShortDescription);
|
||||
const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId));
|
||||
@ -492,7 +502,6 @@ const coreMetadataLoading = ref(false);
|
||||
|
||||
function coreMetadataUploadFiles(e: InputEvent) {
|
||||
if (coreMetadataIconUrl.value.startsWith("blob")) {
|
||||
console.log("freed object URL");
|
||||
URL.revokeObjectURL(coreMetadataIconUrl.value);
|
||||
}
|
||||
|
||||
@ -503,9 +512,9 @@ function coreMetadataUploadFiles(e: InputEvent) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to upload file",
|
||||
description: "Drop couldn't upload this file.",
|
||||
buttonText: "Close",
|
||||
title: t("errors.upload.title"),
|
||||
description: t("errors.upload.description", [t("errors.unknown")]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
@ -522,14 +531,16 @@ async function coreMetadataUpdate() {
|
||||
formData.append("icon", newIcon);
|
||||
}
|
||||
|
||||
formData.append("id", game.value.id);
|
||||
formData.append("name", coreMetadataName.value);
|
||||
formData.append("description", coreMetadataDescription.value);
|
||||
|
||||
const result = await $dropFetch(`/api/v1/admin/game/metadata`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const result = await $dropFetch(
|
||||
`/api/v1/admin/game/${game.value.id}/metadata`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -540,18 +551,20 @@ function coreMetadataUpdate_wrapper() {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to update metadata",
|
||||
description: `Drop failed to update the game's metadata: ${
|
||||
e?.statusMessage || "An unknown error occurred. "
|
||||
}`,
|
||||
buttonText: "Close",
|
||||
title: t("errors.game.metadata.title"),
|
||||
description: t("errors.game.metadata.description", [
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
})
|
||||
.then((newGame) => {
|
||||
console.log(newGame);
|
||||
if (!newGame) return;
|
||||
Object.assign(game.value, newGame);
|
||||
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);
|
||||
})
|
||||
.finally(() => {
|
||||
coreMetadataLoading.value = false;
|
||||
@ -566,35 +579,44 @@ const descriptionEditor = ref<HTMLTextAreaElement | undefined>();
|
||||
// 0 is not loading
|
||||
// 1 is waiting for stop
|
||||
// 2 is loading
|
||||
const descriptionSaving = ref<number>(0);
|
||||
enum DescriptionSavingState {
|
||||
NotLoading,
|
||||
Waiting,
|
||||
Loading,
|
||||
}
|
||||
const descriptionSaving = ref<DescriptionSavingState>(
|
||||
DescriptionSavingState.NotLoading,
|
||||
);
|
||||
|
||||
let savingTimeout: undefined | NodeJS.Timeout;
|
||||
|
||||
type PatchGameBody = Partial<GameModel>;
|
||||
|
||||
watch(descriptionHTML, (_v) => {
|
||||
console.log(game.value.mDescription);
|
||||
descriptionSaving.value = 1;
|
||||
descriptionSaving.value = DescriptionSavingState.Waiting;
|
||||
if (savingTimeout) clearTimeout(savingTimeout);
|
||||
savingTimeout = setTimeout(async () => {
|
||||
try {
|
||||
descriptionSaving.value = 2;
|
||||
await $dropFetch("/api/v1/admin/game", {
|
||||
descriptionSaving.value = DescriptionSavingState.Loading;
|
||||
await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
mDescription: game.value.mDescription,
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: {
|
||||
mDescription: game.value.mDescription,
|
||||
} satisfies PatchGameBody,
|
||||
});
|
||||
descriptionSaving.value = 0;
|
||||
descriptionSaving.value = DescriptionSavingState.NotLoading;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to update game description",
|
||||
description: `Drop failed to update the game description: ${
|
||||
// @ts-expect-error attempt to get statusMessage on error
|
||||
e?.statusMessage ?? "An unknown error occurred."
|
||||
}`,
|
||||
buttonText: "Close",
|
||||
title: t("errors.game.description.title"),
|
||||
description: t("errors.game.description.description", [
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
@ -622,24 +644,25 @@ function insertImageAtCursor(id: string) {
|
||||
async function updateBannerImage(id: string) {
|
||||
try {
|
||||
if (game.value.mBannerObjectId == id) return;
|
||||
const { mBannerObjectId } = await $dropFetch("/api/v1/admin/game", {
|
||||
const { mBannerObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
mBannerId: id,
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: {
|
||||
mBannerObjectId: id,
|
||||
} satisfies PatchGameBody,
|
||||
});
|
||||
game.value.mBannerObjectId = mBannerObjectId;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "There an error while updating the banner image",
|
||||
description: `Drop encountered an error while updating the banner image: ${
|
||||
// @ts-expect-error attempt to get statusMessage on error
|
||||
e?.statusMessage ?? "An unknown error occurred"
|
||||
}`,
|
||||
buttonText: "Close",
|
||||
title: t("errors.game.banner.title"),
|
||||
description: t("errors.game.banner.description", [
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
@ -649,24 +672,25 @@ async function updateBannerImage(id: string) {
|
||||
async function updateCoverImage(id: string) {
|
||||
try {
|
||||
if (game.value.mCoverObjectId == id) return;
|
||||
const { mCoverObjectId } = await $dropFetch("/api/v1/admin/game", {
|
||||
const { mCoverObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
mCoverId: id,
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: {
|
||||
mCoverObjectId: id,
|
||||
} satisfies PatchGameBody,
|
||||
});
|
||||
game.value.mCoverObjectId = mCoverObjectId;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "There an error while updating the cover image",
|
||||
description: `Drop encountered an error while updating the cover image: ${
|
||||
// @ts-expect-error attempt to get statusMessage on error
|
||||
e?.statusMessage ?? "An unknown error occurred"
|
||||
}`,
|
||||
buttonText: "Close",
|
||||
title: t("errors.game.cover.title"),
|
||||
description: t("errors.game.cover.description", [
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
@ -691,27 +715,23 @@ async function deleteImage(id: string) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "There an error while deleting the image",
|
||||
description: `Drop encountered an error while deleting the image: ${
|
||||
// @ts-expect-error attempt to get statusMessage on error
|
||||
e?.statusMessage ?? "An unknown error occurred"
|
||||
}`,
|
||||
buttonText: "Close",
|
||||
title: t("errors.game.deleteImage.title"),
|
||||
description: t("errors.game.deleteImage.description", [
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAfterImageUpload(result: Game) {
|
||||
async function uploadAfterImageUpload(result: GameModel) {
|
||||
if (!game.value) return;
|
||||
game.value.mImageLibraryObjectIds = result.mImageLibraryObjectIds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function addImageToCarousel(id: string) {
|
||||
showAddCarouselModal.value = false;
|
||||
game.value.mImageCarouselObjectIds.push(id);
|
||||
updateImageCarousel();
|
||||
}
|
||||
@ -726,23 +746,24 @@ function removeImageFromCarousel(id: string) {
|
||||
|
||||
async function updateImageCarousel() {
|
||||
try {
|
||||
await $dropFetch("/api/v1/admin/game", {
|
||||
await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: gameId,
|
||||
mImageCarousel: game.value.mImageCarouselObjectIds,
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: {
|
||||
mImageCarouselObjectIds: game.value.mImageCarouselObjectIds,
|
||||
} satisfies PatchGameBody,
|
||||
});
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "There an error while updating the image carousel",
|
||||
description: `Drop encountered an error while updating image carousel: ${
|
||||
// @ts-expect-error attempt to get statusMessage on error
|
||||
e?.statusMessage ?? "An unknown error occurred"
|
||||
}`,
|
||||
buttonText: "Close",
|
||||
title: t("errors.game.carousel.title"),
|
||||
description: t("errors.game.carousel.description", [
|
||||
(e as H3Error)?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
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>
|
||||
104
app/components/GamePanel.vue
Normal file
104
app/components/GamePanel.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
v-if="game || defaultPlaceholder"
|
||||
:href="href"
|
||||
:class="{
|
||||
'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5':
|
||||
animate,
|
||||
}"
|
||||
class="group relative flex-1 min-w-42 max-w-48 h-64 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'transition-all duration-300 group-hover:scale-110': animate,
|
||||
}"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<img
|
||||
:src="imageProps.src"
|
||||
class="w-full h-full object-cover brightness-[90%]"
|
||||
:alt="imageProps.alt"
|
||||
/>
|
||||
<div
|
||||
v-if="showTitleDescription"
|
||||
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/0 to-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showTitleDescription"
|
||||
class="absolute bottom-0 left-0 w-full p-3"
|
||||
>
|
||||
<h1
|
||||
:class="{ 'group-hover:text-white transition-colors': animate }"
|
||||
class="text-zinc-100 text-sm font-bold font-display"
|
||||
>
|
||||
{{
|
||||
game ? game.mName : $t("settings.admin.store.dropGameNamePlaceholder")
|
||||
}}
|
||||
</h1>
|
||||
<p
|
||||
:class="{
|
||||
'group-hover:text-zinc-300 transition-colors': animate,
|
||||
}"
|
||||
class="text-zinc-400 text-xs line-clamp-2"
|
||||
>
|
||||
{{
|
||||
game
|
||||
? game.mShortDescription
|
||||
: $t("settings.admin.store.dropGameDescriptionPlaceholder")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<SkeletonCard
|
||||
v-else-if="defaultPlaceholder === false"
|
||||
:message="$t('store.noGame')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
game,
|
||||
href = undefined,
|
||||
showTitleDescription = true,
|
||||
animate = true,
|
||||
defaultPlaceholder = false,
|
||||
} = defineProps<{
|
||||
game:
|
||||
| SerializeObject<{
|
||||
id: string;
|
||||
mCoverObjectId: string;
|
||||
mName: string;
|
||||
mShortDescription: string;
|
||||
}>
|
||||
| undefined
|
||||
| null;
|
||||
href?: string | undefined;
|
||||
showTitleDescription?: boolean;
|
||||
animate?: boolean;
|
||||
defaultPlaceholder?: boolean;
|
||||
}>();
|
||||
|
||||
const imageProps = {
|
||||
src: "",
|
||||
alt: t("settings.admin.store.dropGameAltPlaceholder"),
|
||||
};
|
||||
|
||||
if (game) {
|
||||
imageProps.src = useObject(game.mCoverObjectId);
|
||||
imageProps.alt = game.mName;
|
||||
} else if (defaultPlaceholder) {
|
||||
imageProps.src = "/game-panel-placeholder.png";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
img.active {
|
||||
view-transition-name: selected-game;
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
@ -16,9 +16,9 @@
|
||||
</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: GameMetadataSearchResult & { sourceName?: string };
|
||||
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
|
||||
}>();
|
||||
</script>
|
||||
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>
|
||||
29
app/components/LanguageSelector.vue
Normal file
29
app/components/LanguageSelector.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<LanguageSelectorListbox />
|
||||
<NuxtLink
|
||||
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
|
||||
to="https://translate.droposs.org/engage/drop/"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="helpUsTranslate"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1 hover:underline"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
|
||||
<DevOnly>
|
||||
<h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
|
||||
</DevOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
</script>
|
||||
155
app/components/LanguageSelectorListbox.vue
Normal file
155
app/components/LanguageSelectorListbox.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<Listbox v-model="wiredLocale" as="div">
|
||||
<ListboxLabel
|
||||
v-if="showText"
|
||||
class="block text-sm/6 font-medium text-zinc-400"
|
||||
>{{ $t("selectLanguage") }}</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="grid w-full cursor-default grid-cols-1 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-300 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="col-start-1 row-start-1 flex items-center gap-3 pr-6">
|
||||
<EmojiText
|
||||
:emoji="localeToEmoji(wiredLocale)"
|
||||
class="-mt-0.5 shrink-0 max-w-6"
|
||||
/>
|
||||
<span class="block truncate">{{
|
||||
currentLocaleInformation?.name ?? wiredLocale
|
||||
}}</span>
|
||||
</span>
|
||||
<ChevronUpDownIcon
|
||||
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-gray-500 sm:size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="listLocale in locales"
|
||||
:key="listLocale.code"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="listLocale.code"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-hidden'
|
||||
: 'text-zinc-300',
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<EmojiText
|
||||
:emoji="localeToEmoji(listLocale.code)"
|
||||
class="-mt-0.5 shrink-0 max-w-6"
|
||||
/>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'ml-3 block truncate',
|
||||
]"
|
||||
>{{ listLocale.name }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon } from "@heroicons/vue/24/outline";
|
||||
import type { Locale } from "vue-i18n";
|
||||
|
||||
const { showText = true } = defineProps<{ showText?: boolean }>();
|
||||
|
||||
const { availableLocales, locale: currLocale, setLocale } = useI18n();
|
||||
|
||||
function changeLocale(locale: Locale) {
|
||||
setLocale(locale);
|
||||
|
||||
// dynamically update the HTML attributes for language and direction
|
||||
// this is necessary for proper rendering of the page in the new language
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: locale,
|
||||
// dir: availableLocales.find((l) => l === locale)?.dir || "ltr",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function localeToEmoji(local: string): string {
|
||||
switch (local) {
|
||||
// Default locale
|
||||
case "en":
|
||||
case "en-us":
|
||||
return "🇺🇸";
|
||||
|
||||
case "en-gb":
|
||||
return "🇬🇧";
|
||||
case "en-ca":
|
||||
return "🇨🇦";
|
||||
case "en-au":
|
||||
return "🇦🇺";
|
||||
case "en-pirate":
|
||||
return "🏴☠️";
|
||||
case "fr":
|
||||
return "🇫🇷";
|
||||
case "de":
|
||||
return "🇩🇪";
|
||||
case "es":
|
||||
return "🇪🇸";
|
||||
case "it":
|
||||
return "🇮🇹";
|
||||
case "zh":
|
||||
return "🇨🇳";
|
||||
case "zh-tw":
|
||||
return "🇹🇼";
|
||||
|
||||
default: {
|
||||
return "❓";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wiredLocale = computed({
|
||||
get() {
|
||||
return currLocale.value;
|
||||
},
|
||||
set(v) {
|
||||
changeLocale(v);
|
||||
},
|
||||
});
|
||||
const currentLocaleInformation = computed(() =>
|
||||
availableLocales.find((e) => e == wiredLocale.value),
|
||||
);
|
||||
</script>
|
||||
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>
|
||||
246
app/components/Modal/AddCompanyGame.vue
Normal file
246
app/components/Modal/AddCompanyGame.vue
Normal file
@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<ModalTemplate v-model="open">
|
||||
<template #default>
|
||||
<div>
|
||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||
{{ $t("library.admin.metadata.companies.addGame.title") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
{{ $t("library.admin.metadata.companies.addGame.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form @submit.prevent="() => addGame()">
|
||||
<Listbox v-model="currentGame" as="div">
|
||||
<ListboxLabel
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<GameSearchResultWidget
|
||||
v-if="currentGame"
|
||||
:game="currentGame"
|
||||
/>
|
||||
<span v-else class="block truncate text-zinc-600">
|
||||
{{ $t("library.admin.import.selectGamePlaceholder") }}
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="result in metadataGames"
|
||||
:key="result.id"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="result"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<GameSearchResultWidget :game="result" />
|
||||
</li>
|
||||
</ListboxOption>
|
||||
<p
|
||||
v-if="metadataGames.length == 0"
|
||||
class="w-full text-center p-2 uppercase font-display text-zinc-700 font-bold"
|
||||
>
|
||||
{{ $t("library.admin.metadata.companies.addGame.noGames") }}
|
||||
</p>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
<div class="mt-6 flex items-center justify-between gap-3">
|
||||
<label
|
||||
id="published-label"
|
||||
for="published"
|
||||
class="font-medium text-md text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.addGame.publisher")
|
||||
}}</label
|
||||
>
|
||||
|
||||
<div
|
||||
class="group/published relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-5"
|
||||
/>
|
||||
<input
|
||||
id="published"
|
||||
v-model="published"
|
||||
type="checkbox"
|
||||
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
|
||||
aria-labelledby="published-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between gap-3">
|
||||
<label
|
||||
id="developer-label"
|
||||
for="developer"
|
||||
class="font-medium text-md text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.addGame.developer")
|
||||
}}</label
|
||||
>
|
||||
|
||||
<div
|
||||
class="group/developer relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developer:translate-x-5"
|
||||
/>
|
||||
<input
|
||||
id="developer"
|
||||
v-model="developed"
|
||||
type="checkbox"
|
||||
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
|
||||
aria-labelledby="developer-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hidden" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="addError" class="mt-3 rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ addError }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons="{ close }">
|
||||
<LoadingButton
|
||||
:loading="addGameLoading"
|
||||
:disabled="!(currentGame && (developed || published))"
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => addGame()"
|
||||
>
|
||||
{{ $t("common.add") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="() => close()"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { GameModel } from "~~/prisma/client/models";
|
||||
import {
|
||||
DialogTitle,
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
const props = defineProps<{
|
||||
companyId: string;
|
||||
exclude?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [
|
||||
game: SerializeObject<GameModel>,
|
||||
published: boolean,
|
||||
developed: boolean,
|
||||
];
|
||||
}>();
|
||||
|
||||
const games = await $dropFetch("/api/v1/admin/game");
|
||||
const metadataGames = computed(() =>
|
||||
games
|
||||
.filter((e) => !(props.exclude ?? []).includes(e.id))
|
||||
.map(
|
||||
(e) =>
|
||||
({
|
||||
id: e.id,
|
||||
name: e.mName,
|
||||
icon: useObject(e.mIconObjectId),
|
||||
description: e.mShortDescription,
|
||||
}) satisfies Omit<GameMetadataSearchResult, "year">,
|
||||
),
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const currentGame = ref<NonNullable<(typeof metadataGames.value)[number]> | null>(null);
|
||||
const developed = ref(false);
|
||||
const published = ref(false);
|
||||
const addGameLoading = ref(false);
|
||||
const addError = ref<string | undefined>(undefined);
|
||||
|
||||
async function addGame() {
|
||||
if (!currentGame.value) return;
|
||||
addGameLoading.value = true;
|
||||
|
||||
try {
|
||||
const game = await $dropFetch("/api/v1/admin/company/:id/game", {
|
||||
method: "POST",
|
||||
params: { id: props.companyId },
|
||||
body: {
|
||||
id: currentGame.value.id,
|
||||
developed: developed.value,
|
||||
published: published.value,
|
||||
},
|
||||
});
|
||||
emit("created", game, published.value, developed.value);
|
||||
} catch (e) {
|
||||
if (e instanceof FetchError) {
|
||||
addError.value = e.message ?? t("errors.unknown");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
currentGame.value = null;
|
||||
developed.value = false;
|
||||
published.value = false;
|
||||
addGameLoading.value = false;
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -3,11 +3,10 @@
|
||||
<template #default>
|
||||
<div>
|
||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||
Create collection
|
||||
{{ $t("library.collection.create") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
Collections can used to organise your games and find them more easily,
|
||||
especially if you have a large library.
|
||||
{{ $t("library.collection.createDesc") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
@ -15,7 +14,7 @@
|
||||
<input
|
||||
v-model="collectionName"
|
||||
type="text"
|
||||
placeholder="Collection name"
|
||||
:placeholder="$t('library.collection.namePlaceholder')"
|
||||
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
<button class="hidden" type="submit" />
|
||||
@ -30,7 +29,7 @@
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => createCollection()"
|
||||
>
|
||||
Create
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
@ -38,7 +37,7 @@
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="() => close()"
|
||||
>
|
||||
Cancel
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
@ -47,7 +46,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { CollectionEntry, Game } from "~/prisma/client";
|
||||
import type { CollectionEntryModel, GameModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -60,6 +59,7 @@ const emit = defineEmits<{
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const { t } = useI18n();
|
||||
const collectionName = ref("");
|
||||
const createCollectionLoading = ref(false);
|
||||
const collections = await useCollections();
|
||||
@ -79,7 +79,7 @@ async function createCollection() {
|
||||
// Add the game if provided
|
||||
if (props.gameId) {
|
||||
const entry = await $dropFetch<
|
||||
CollectionEntry & { game: SerializeObject<Game> }
|
||||
CollectionEntryModel & { game: SerializeObject<GameModel> }
|
||||
>(`/api/v1/collection/${response.id}/entry`, {
|
||||
method: "POST",
|
||||
body: { id: props.gameId },
|
||||
@ -97,12 +97,14 @@ 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: "Failed to create collection",
|
||||
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
|
||||
title: t("errors.library.collection.create.title"),
|
||||
description: t("errors.library.collection.create.desc", [
|
||||
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>
|
||||
78
app/components/Modal/CreateTag.vue
Normal file
78
app/components/Modal/CreateTag.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<ModalTemplate v-model="open">
|
||||
<template #default>
|
||||
<div>
|
||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||
{{ $t("library.admin.metadata.tags.modal.title") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
{{ $t("library.admin.metadata.tags.modal.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form @submit.prevent="() => createTag()">
|
||||
<input
|
||||
v-model="tagName"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
<button class="hidden" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons="{ close }">
|
||||
<LoadingButton
|
||||
:loading="createTagLoading"
|
||||
:disabled="!tagName"
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => createTag()"
|
||||
>
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="() => close()"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { GameTagModel } from "~~/prisma/client/models";
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [tag: GameTagModel];
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const tagName = ref("");
|
||||
const createTagLoading = ref(false);
|
||||
|
||||
async function createTag() {
|
||||
if (!tagName.value || createTagLoading.value) return;
|
||||
|
||||
createTagLoading.value = true;
|
||||
|
||||
// Create the collection
|
||||
const tag = await $dropFetch("/api/v1/admin/tags", {
|
||||
method: "POST",
|
||||
body: { name: tagName.value },
|
||||
failTitle: "Failed to create tag",
|
||||
});
|
||||
|
||||
// Reset and emit
|
||||
tagName.value = "";
|
||||
open.value = false;
|
||||
|
||||
emit("created", tag);
|
||||
createTagLoading.value = false;
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
@ -6,13 +6,13 @@
|
||||
as="h3"
|
||||
class="text-lg font-bold font-display text-zinc-100"
|
||||
>
|
||||
Delete Collection
|
||||
{{ $t("library.collection.delete") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-sm text-zinc-400">
|
||||
Are you sure you want to delete "{{ collection?.name }}"?
|
||||
{{ $t("common.deleteConfirm", [collection?.name]) }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-bold text-red-500">
|
||||
This action cannot be undone.
|
||||
{{ $t("common.cannotUndo") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@ -22,35 +22,38 @@
|
||||
class="bg-red-600 text-white hover:bg-red-500"
|
||||
@click="() => deleteCollection()"
|
||||
>
|
||||
Delete
|
||||
{{ $t("delete") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||
@click="() => (collection = undefined)"
|
||||
>
|
||||
Cancel
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Collection } from "~/prisma/client";
|
||||
import type { CollectionModel } from "~~/prisma/client/models";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
|
||||
const collection = defineModel<Collection | undefined>();
|
||||
const collection = defineModel<CollectionModel | undefined>();
|
||||
const deleteLoading = ref(false);
|
||||
|
||||
const collections = await useCollections();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function deleteCollection() {
|
||||
try {
|
||||
if (!collection.value) return;
|
||||
|
||||
deleteLoading.value = true;
|
||||
await $dropFetch(`/api/v1/collection/${collection.value.id}`, {
|
||||
// @ts-expect-error not documented
|
||||
await $dropFetch(`/api/v1/collection/:id`, {
|
||||
method: "DELETE",
|
||||
params: {
|
||||
id: collection.value.id,
|
||||
},
|
||||
});
|
||||
const index = collections.value.findIndex(
|
||||
(e) => e.id == collection.value?.id,
|
||||
@ -62,9 +65,11 @@ async function deleteCollection() {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to add game to library",
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
|
||||
title: t("errors.library.add.title"),
|
||||
description: t("errors.library.add.desc", [
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
@ -6,13 +6,13 @@
|
||||
as="h3"
|
||||
class="text-lg font-bold font-display text-zinc-100"
|
||||
>
|
||||
Delete Article
|
||||
{{ $t("news.delete") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-sm text-zinc-400">
|
||||
Are you sure you want to delete "{{ article?.title }}"?
|
||||
{{ $t("common.deleteConfirm", [article?.title]) }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-bold text-red-500">
|
||||
This action cannot be undone.
|
||||
{{ $t("common.cannotUndo") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@ -22,13 +22,13 @@
|
||||
class="bg-red-600 text-white hover:bg-red-500"
|
||||
@click="() => deleteArticle()"
|
||||
>
|
||||
Delete
|
||||
{{ $t("delete") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||
@click="() => (article = undefined)"
|
||||
>
|
||||
Cancel
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
@ -45,6 +45,7 @@ interface Article {
|
||||
const article = defineModel<Article | undefined>();
|
||||
const deleteLoading = ref(false);
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const news = useNews();
|
||||
if (!news.value) {
|
||||
news.value = await fetchNews();
|
||||
@ -68,9 +69,11 @@ async function deleteArticle() {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to delete article",
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
description: `Drop couldn't delete this article: ${e?.statusMessage}`,
|
||||
title: t("errors.news.article.delete.title"),
|
||||
description: t("errors.news.article.delete.desc", [
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
75
app/components/Modal/DeleteUser.vue
Normal file
75
app/components/Modal/DeleteUser.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<ModalTemplate :model-value="!!user">
|
||||
<template #default>
|
||||
<div>
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
class="text-lg font-bold font-display text-zinc-100"
|
||||
>
|
||||
{{ $t("users.admin.deleteUser", [user?.username]) }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-sm text-zinc-400">
|
||||
{{ $t("common.deleteConfirm", [user?.username]) }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-bold text-red-500">
|
||||
{{ $t("common.cannotUndo") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #buttons>
|
||||
<LoadingButton
|
||||
:loading="deleteLoading"
|
||||
class="bg-red-600 text-white hover:bg-red-500"
|
||||
@click="() => deleteUser()"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||
@click="() => (user = undefined)"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
const user = defineModel<UserModel | undefined>();
|
||||
const deleteLoading = ref(false);
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function deleteUser() {
|
||||
try {
|
||||
if (!user.value) return;
|
||||
|
||||
deleteLoading.value = true;
|
||||
await $dropFetch(`/api/v1/admin/users/${user.value.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
user.value = undefined;
|
||||
|
||||
await fetchUsers();
|
||||
router.push("/admin/users");
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.admin.user.delete.title"),
|
||||
description: t("errors.admin.user.delete.desc", [
|
||||
// @ts-expect-error attempt to display message on error
|
||||
e?.message ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -49,31 +49,38 @@
|
||||
/>
|
||||
<span
|
||||
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
||||
>Upload file</span
|
||||
>{{ $t("uploadFile") }}</span
|
||||
>
|
||||
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
|
||||
{{ currentFile.name }}
|
||||
</p>
|
||||
<div v-if="currentFileList">
|
||||
<p
|
||||
v-for="currentFile in currentFileList"
|
||||
:key="currentFile"
|
||||
class="mt-1 text-[10px] text-zinc-500 whitespace-nowrap"
|
||||
>
|
||||
{{ currentFile }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
:accept="props.accept"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="(e) => (file = (e.target as any)?.files)"
|
||||
:multiple="props.multiple"
|
||||
@change="(e: Event) => (file = (e.target as any)?.files)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<LoadingButton
|
||||
:disabled="currentFile == undefined"
|
||||
:disabled="currentFiles == undefined"
|
||||
type="button"
|
||||
:loading="uploadLoading"
|
||||
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
|
||||
@click="() => uploadFile_wrapper()"
|
||||
>
|
||||
Upload
|
||||
{{ $t("upload") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
@ -81,7 +88,7 @@
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="open = false"
|
||||
>
|
||||
Cancel
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="uploadError" class="mt-3 rounded-md bg-red-600/10 p-4">
|
||||
@ -122,11 +129,21 @@ const open = defineModel<boolean>({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const file = ref<FileList | undefined>();
|
||||
const currentFile = computed(() => file.value?.item(0));
|
||||
const currentFiles = computed(() => file.value);
|
||||
const currentFileList = computed(() => {
|
||||
if (!currentFiles.value) return undefined;
|
||||
const list = [];
|
||||
for (const file of currentFiles.value) {
|
||||
list.push(file.name);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
const props = defineProps<{
|
||||
endpoint: string;
|
||||
accept: string;
|
||||
multiple?: boolean;
|
||||
options?: { [key: string]: string };
|
||||
}>();
|
||||
const emit = defineEmits(["upload"]);
|
||||
@ -134,10 +151,12 @@ const emit = defineEmits(["upload"]);
|
||||
const uploadLoading = ref(false);
|
||||
const uploadError = ref<string | undefined>();
|
||||
async function uploadFile() {
|
||||
if (!currentFile.value) return;
|
||||
if (!currentFiles.value) return;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", currentFile.value);
|
||||
for (const file of currentFiles.value) {
|
||||
form.append(file.name, file);
|
||||
}
|
||||
|
||||
if (props.options) {
|
||||
for (const [key, value] of Object.entries(props.options)) {
|
||||
@ -158,7 +177,7 @@ function uploadFile_wrapper() {
|
||||
uploadLoading.value = true;
|
||||
uploadFile()
|
||||
.catch((error) => {
|
||||
uploadError.value = error.statusMessage ?? "An unknown error occurred.";
|
||||
uploadError.value = error.message ?? t("errors.unknown");
|
||||
})
|
||||
.finally(() => {
|
||||
uploadLoading.value = false;
|
||||
115
app/components/MultiItemSelector.vue
Normal file
115
app/components/MultiItemSelector.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="inline-flex gap-1 items-center flex-wrap">
|
||||
<span
|
||||
v-for="item in enabledItems"
|
||||
:key="item.param"
|
||||
class="inline-flex items-center gap-x-0.5 rounded-md bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-500 ring-1 ring-blue-800 ring-inset"
|
||||
>
|
||||
{{ item.name }}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-600/20"
|
||||
@click="() => remove(item.param)"
|
||||
>
|
||||
<span class="sr-only">{{ $t("common.remove") }}</span>
|
||||
<svg
|
||||
viewBox="0 0 14 14"
|
||||
class="size-3.5 stroke-blue-500 group-hover:stroke-blue-400"
|
||||
>
|
||||
<path d="M4 4l6 6m0-6l-6 6" />
|
||||
</svg>
|
||||
<span class="absolute -inset-1" />
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="enabledItems.length == 0"
|
||||
class="font-display uppercase text-xs font-bold text-zinc-700"
|
||||
>
|
||||
{{ $t("common.noSelected") }}
|
||||
</span>
|
||||
</div>
|
||||
<Combobox as="div" @update:model-value="add">
|
||||
<div class="relative mt-2">
|
||||
<ComboboxInput
|
||||
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
:display-value="(item) => (item as StoreSortOption)?.name"
|
||||
placeholder="Start typing..."
|
||||
@change="search = $event.target.value"
|
||||
@blur="search = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
|
||||
>
|
||||
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
v-if="filteredItems.length > 0 || search.length > 0"
|
||||
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="item in filteredItems"
|
||||
:key="item.param"
|
||||
v-slot="{ active }"
|
||||
:value="item.param"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-hidden'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span class="block truncate">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from "@heroicons/vue/20/solid";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
const props = defineProps<{
|
||||
items: Array<StoreSortOption>;
|
||||
}>();
|
||||
|
||||
const model = defineModel<{ [key: string]: boolean }>();
|
||||
|
||||
const search = ref("");
|
||||
const filteredItems = computed(() =>
|
||||
props.items.filter(
|
||||
(item) =>
|
||||
!model.value?.[item.param] &&
|
||||
item.name.toLowerCase().includes(search.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
const enabledItems = computed(() =>
|
||||
props.items.filter((e) => model.value?.[e.param]),
|
||||
);
|
||||
|
||||
function add(item: string) {
|
||||
search.value = "";
|
||||
model.value ??= {};
|
||||
model.value[item] = true;
|
||||
}
|
||||
|
||||
function remove(item: string) {
|
||||
model.value ??= {};
|
||||
model.value[item] = false;
|
||||
}
|
||||
</script>
|
||||
@ -11,18 +11,18 @@
|
||||
class="h-5 w-5 transition-transform duration-200"
|
||||
:class="{ 'rotate-90': modalOpen }"
|
||||
/>
|
||||
<span>New article</span>
|
||||
<span>{{ $t("news.article.new") }}</span>
|
||||
</button>
|
||||
|
||||
<ModalTemplate v-model="modalOpen" size-class="sm:max-w-[80vw]">
|
||||
<h3 class="text-lg font-semibold text-zinc-100 mb-4">
|
||||
Create New Article
|
||||
{{ $t("news.article.create") }}
|
||||
</h3>
|
||||
<form class="space-y-4" @submit.prevent="() => createArticle()">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-zinc-400"
|
||||
>Title</label
|
||||
>
|
||||
<label for="title" class="block text-sm font-medium text-zinc-400">{{
|
||||
$t("news.article.titles")
|
||||
}}</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="newArticle.title"
|
||||
@ -34,8 +34,10 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="excerpt" class="block text-sm font-medium text-zinc-400"
|
||||
>Short description</label
|
||||
<label
|
||||
for="excerpt"
|
||||
class="block text-sm font-medium text-zinc-400"
|
||||
>{{ $t("news.article.shortDesc") }}</label
|
||||
>
|
||||
<input
|
||||
id="excerpt"
|
||||
@ -47,8 +49,10 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="content" class="block text-sm font-medium text-zinc-400"
|
||||
>Content (Markdown)</label
|
||||
<label
|
||||
for="content"
|
||||
class="block text-sm font-medium text-zinc-400"
|
||||
>{{ $t("news.article.content") }}</label
|
||||
>
|
||||
<div class="mt-1 flex flex-col gap-4">
|
||||
<!-- Markdown shortcuts -->
|
||||
@ -69,7 +73,9 @@
|
||||
>
|
||||
<!-- Editor -->
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-zinc-500 mb-2">Editor</span>
|
||||
<span class="text-sm text-zinc-500 mb-2">{{
|
||||
$t("news.article.editor")
|
||||
}}</span>
|
||||
<textarea
|
||||
id="content"
|
||||
ref="contentEditor"
|
||||
@ -82,7 +88,9 @@
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm text-zinc-500 mb-2">Preview</span>
|
||||
<span class="text-sm text-zinc-500 mb-2">{{
|
||||
$t("news.article.preview")
|
||||
}}</span>
|
||||
<div
|
||||
class="flex-1 p-4 rounded-md bg-zinc-900 border border-zinc-700 overflow-y-auto"
|
||||
>
|
||||
@ -95,8 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-zinc-500">
|
||||
Use the shortcuts above or write Markdown directly. Supports
|
||||
**bold**, *italic*, [links](url), and more.
|
||||
{{ $t("news.article.editorGuide") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -114,7 +121,7 @@
|
||||
/>
|
||||
<span
|
||||
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
||||
>Upload cover image</span
|
||||
>{{ $t("news.article.uploadCover") }}</span
|
||||
>
|
||||
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
|
||||
{{ currentFile.name }}
|
||||
@ -125,14 +132,14 @@
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="(e) => (file = (e.target as any)?.files)"
|
||||
@change="(e: Event) => (file = (e.target as any)?.files)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-zinc-400 mb-2"
|
||||
>Tags</label
|
||||
>
|
||||
<label class="block text-sm font-medium text-zinc-400 mb-2">{{
|
||||
$t("common.tags")
|
||||
}}</label>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<span
|
||||
v-for="tag in newArticle.tags"
|
||||
@ -153,7 +160,7 @@
|
||||
<input
|
||||
v-model="newTagInput"
|
||||
type="text"
|
||||
placeholder="Add a tag..."
|
||||
:placeholder="$t('news.article.tagPlaceholder')"
|
||||
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
@keydown.enter.prevent="addTag"
|
||||
/>
|
||||
@ -162,7 +169,7 @@
|
||||
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
@click="addTag"
|
||||
>
|
||||
Add
|
||||
{{ $t("news.article.add") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -186,15 +193,16 @@
|
||||
<LoadingButton
|
||||
:loading="loading"
|
||||
class="bg-blue-600 text-white hover:bg-blue-500"
|
||||
:disabled="!isValidArticle"
|
||||
@click="() => createArticle()"
|
||||
>
|
||||
Submit
|
||||
{{ $t("news.article.submit") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||
@click="() => (modalOpen = !modalOpen)"
|
||||
>
|
||||
Cancel
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
@ -228,6 +236,13 @@ const newArticle = ref({
|
||||
tags: [] as string[],
|
||||
});
|
||||
|
||||
const isValidArticle = computed(
|
||||
() =>
|
||||
newArticle.value.title &&
|
||||
newArticle.value.description &&
|
||||
newArticle.value.content,
|
||||
);
|
||||
|
||||
const markdownPreview = computed(() => {
|
||||
// TODO: maybe?? add https://github.com/cure53/DOMPurify
|
||||
// micromark says its safe, but this is straight html we are injecting
|
||||
@ -236,18 +251,49 @@ const markdownPreview = computed(() => {
|
||||
|
||||
const file = ref<FileList | undefined>();
|
||||
const currentFile = computed(() => file.value?.item(0));
|
||||
const { t } = useI18n();
|
||||
|
||||
const error = ref<string | undefined>();
|
||||
|
||||
const contentEditor = ref<HTMLTextAreaElement>();
|
||||
|
||||
const markdownShortcuts = [
|
||||
{ label: "Bold", prefix: "**", suffix: "**", placeholder: "bold text" },
|
||||
{ label: "Italic", prefix: "_", suffix: "_", placeholder: "italic text" },
|
||||
{ label: "Link", prefix: "[", suffix: "](url)", placeholder: "link text" },
|
||||
{ label: "Code", prefix: "`", suffix: "`", placeholder: "code" },
|
||||
{ label: "List Item", prefix: "- ", suffix: "", placeholder: "list item" },
|
||||
{ label: "Heading", prefix: "## ", suffix: "", placeholder: "heading" },
|
||||
{
|
||||
label: t("editor.bold"),
|
||||
prefix: "**",
|
||||
suffix: "**",
|
||||
placeholder: t("editor.boldPlaceholder"),
|
||||
},
|
||||
{
|
||||
label: t("editor.italic"),
|
||||
prefix: "_",
|
||||
suffix: "_",
|
||||
placeholder: t("editor.italicPlaceholder"),
|
||||
},
|
||||
{
|
||||
label: t("editor.link"),
|
||||
prefix: "[",
|
||||
suffix: "](url)",
|
||||
placeholder: t("editor.linkPlaceholder"),
|
||||
},
|
||||
{
|
||||
label: t("editor.code"),
|
||||
prefix: "`",
|
||||
suffix: "`",
|
||||
placeholder: t("editor.codePlaceholder"),
|
||||
},
|
||||
{
|
||||
label: t("editor.listItem"),
|
||||
prefix: "- ",
|
||||
suffix: "",
|
||||
placeholder: t("editor.listItemPlaceholder"),
|
||||
},
|
||||
{
|
||||
label: t("editor.heading"),
|
||||
prefix: "## ",
|
||||
suffix: "",
|
||||
placeholder: t("editor.headingPlaceholder"),
|
||||
},
|
||||
];
|
||||
|
||||
function handleContentKeydown(e: KeyboardEvent) {
|
||||
@ -368,8 +414,8 @@ async function createArticle() {
|
||||
|
||||
modalOpen.value = false;
|
||||
} catch (e) {
|
||||
// @ts-expect-error attempt to get statusMessage on error
|
||||
error.value = e?.statusMessage ?? "An unknown error occured.";
|
||||
// @ts-expect-error attempt to get message on error
|
||||
error.value = e?.message ?? t("errors.unknown");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@ -33,7 +33,7 @@
|
||||
class="inline-flex rounded-md text-zinc-400 hover:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="() => deleteMe()"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<span class="sr-only">{{ $t("common.close") }}</span>
|
||||
<XMarkIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@ -44,13 +44,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { Notification } from "~/prisma/client";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const props = defineProps<{ notification: Notification }>();
|
||||
const props = defineProps<{ notification: NotificationModel }>();
|
||||
|
||||
async function deleteMe() {
|
||||
await $dropFetch(`/api/v1/notifications/${props.notification.id}`, {
|
||||
await $dropFetch(`/api/v1/notifications/:id`, {
|
||||
method: "DELETE",
|
||||
params: {
|
||||
id: props.notification.id,
|
||||
},
|
||||
});
|
||||
const notifications = useNotifications();
|
||||
const indexOfMe = notifications.value.findIndex(
|
||||
22
app/components/OptionWrapper.vue
Normal file
22
app/components/OptionWrapper.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'transition border border-3 rounded-xl relative cursor-pointer',
|
||||
active ? 'border-blue-600' : 'border-zinc-700',
|
||||
]"
|
||||
>
|
||||
<div v-if="active" class="absolute top-1 right-1 z-1">
|
||||
<CheckIcon
|
||||
class="rounded-full p-1.5 bg-blue-600 size-6 text-transparent stroke-3 stroke-zinc-900 font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
const { active = false } = defineProps<{ active?: boolean }>();
|
||||
</script>
|
||||
@ -1,21 +1,21 @@
|
||||
<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>Please select a platform...</span>
|
||||
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2"
|
||||
>
|
||||
@ -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;
|
||||
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>
|
||||
32
app/components/RelativeTime.vue
Normal file
32
app/components/RelativeTime.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="relative inline-block group/relative-time">
|
||||
<!-- Visible relative time -->
|
||||
<time :datetime="isoDate" class="text-sm text-muted-foreground">
|
||||
{{ DateTime.fromJSDate(date).toRelative({ locale: $i18n.locale }) }}
|
||||
</time>
|
||||
|
||||
<!-- Custom tooltip that shows on hover -->
|
||||
<div
|
||||
role="tooltip"
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 rounded bg-zinc-900 text-white text-xs whitespace-nowrap shadow z-10 opacity-0 group-hover/relative-time:opacity-100 transition-opacity pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ $d(date, "long") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DateTime } from "luxon";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
date: string | Date;
|
||||
}>();
|
||||
|
||||
const date = computed(() =>
|
||||
typeof props.date === "string" ? new Date(props.date) : props.date,
|
||||
);
|
||||
|
||||
const isoDate = computed(() => date.value.toISOString());
|
||||
</script>
|
||||
159
app/components/Setup/Account.vue
Normal file
159
app/components/Setup/Account.vue
Normal file
@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="p-2 lg:p-4">
|
||||
<div class="px-4 py-2 max-w-xl">
|
||||
<h1 class="font-semibold text-zinc-100 text-xl">
|
||||
{{ $t("setup.auth.title") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("setup.auth.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid lg:grid-cols-2 xl:grid-cols-3 h-fit p-4 gap-4">
|
||||
<div class="p-4 border-1 border-zinc-800 rounded-xl">
|
||||
<div>
|
||||
<h1 class="text-zinc-100 font-semibold text-lg">
|
||||
{{ $t("setup.auth.simple.title") }}
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("setup.auth.simple.description") }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
|
||||
href="https://docs.droposs.org/docs/authentication/simple"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="setup.auth.docs"
|
||||
tag="span"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<span class="text-zinc-100 font-semibold text-sm">{{
|
||||
$t("setup.auth.enabled")
|
||||
}}</span>
|
||||
<CheckIcon
|
||||
v-if="enabledAuth.Simple"
|
||||
class="size-5 text-green-600"
|
||||
/>
|
||||
<XMarkIcon v-else class="size-5 text-red-600" />
|
||||
</div>
|
||||
<LoadingButton
|
||||
class="mt-4"
|
||||
:loading="invitationLoading"
|
||||
:disabled="!enabledAuth.Simple"
|
||||
@click="() => registerAsAdmin()"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="setup.auth.simple.register"
|
||||
tag="span"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
{{ $t("chars.arrow") }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-1 border-zinc-800 rounded-xl">
|
||||
<div>
|
||||
<h1 class="text-zinc-100 font-semibold text-lg">
|
||||
{{ $t("setup.auth.openid.title") }}
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("setup.auth.openid.description") }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
|
||||
href="https://docs.droposs.org/docs/authentication/oidc"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="setup.auth.docs"
|
||||
tag="span"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<span class="text-zinc-100 font-semibold text-sm">{{
|
||||
$t("setup.auth.enabled")
|
||||
}}</span>
|
||||
<CheckIcon
|
||||
v-if="enabledAuth.OpenID"
|
||||
class="size-5 text-green-600"
|
||||
/>
|
||||
<XMarkIcon v-else class="size-5 text-red-600" />
|
||||
</div>
|
||||
<LoadingButton
|
||||
class="mt-4"
|
||||
:loading="false"
|
||||
:disabled="!enabledAuth.OpenID"
|
||||
@click="() => (complete = true)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="setup.auth.openid.skip"
|
||||
tag="span"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
{{ $t("chars.arrow") }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
const complete = defineModel<boolean>({ required: true });
|
||||
|
||||
const { token } = defineProps<{ token: string }>();
|
||||
|
||||
const invitationLoading = ref(false);
|
||||
|
||||
const enabledAuth = await $dropFetch("/api/v1/admin/auth", {
|
||||
headers: { Authorization: token },
|
||||
});
|
||||
|
||||
async function registerAsAdmin() {
|
||||
invitationLoading.value = true;
|
||||
const expiryDate = DateTime.now().plus({ year: 5000 }).toJSON();
|
||||
|
||||
const invitation = await $dropFetch("/api/v1/admin/auth/invitation", {
|
||||
method: "POST",
|
||||
body: { isAdmin: true, expires: expiryDate },
|
||||
headers: { Authorization: token },
|
||||
failTitle: "Failed to create admin invitation",
|
||||
});
|
||||
|
||||
window.open(`${invitation.inviteUrl}&after=close`, "_blank")?.focus();
|
||||
invitationLoading.value = false;
|
||||
complete.value = true;
|
||||
}
|
||||
</script>
|
||||
15
app/components/Setup/Library.vue
Normal file
15
app/components/Setup/Library.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<AdminSourcesPage :token="token" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AdminSourcesPage from "~~/pages/admin/library/sources/index.vue";
|
||||
|
||||
const complete = defineModel<boolean>({ required: true });
|
||||
// Only runs on component load, so it's fine
|
||||
complete.value = true;
|
||||
|
||||
const { token } = defineProps<{ token: string }>();
|
||||
</script>
|
||||
27
app/components/SourceOptions/Filesystem.vue
Normal file
27
app/components/SourceOptions/Filesystem.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div>
|
||||
<label
|
||||
for="path"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.sources.fsPath") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("library.admin.sources.fsPathDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="path"
|
||||
v-model="model!.baseDir"
|
||||
name="path"
|
||||
type="text"
|
||||
autocomplete="path"
|
||||
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<{ baseDir: string }>();
|
||||
</script>
|
||||
27
app/components/SourceOptions/FlatFilesystem.vue
Normal file
27
app/components/SourceOptions/FlatFilesystem.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div>
|
||||
<label
|
||||
for="path"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.sources.fsPath") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("library.admin.sources.fsPathDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="path"
|
||||
v-model="model!.baseDir"
|
||||
name="path"
|
||||
type="text"
|
||||
autocomplete="path"
|
||||
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<{ baseDir: string }>();
|
||||
</script>
|
||||
496
app/components/StoreView.vue
Normal file
496
app/components/StoreView.vue
Normal file
@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<!-- Mobile filter dialog -->
|
||||
<TransitionRoot as="template" :show="mobileFiltersOpen">
|
||||
<Dialog
|
||||
class="relative z-100 lg:hidden"
|
||||
@close="mobileFiltersOpen = false"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-black/25" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-40 flex">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enter-from="translate-x-full"
|
||||
enter-to="translate-x-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leave-from="translate-x-0"
|
||||
leave-to="translate-x-full"
|
||||
>
|
||||
<DialogPanel
|
||||
class="relative ml-auto flex size-full max-w-sm flex-col overflow-y-auto bg-zinc-900 pt-4 pb-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<h2 class="text-lg font-medium text-zinc-100">
|
||||
{{ $t("store.view.srFilters") }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="relative -mr-2 flex size-10 items-center justify-center rounded-md bg-zinc-900 p-2 text-zinc-500 hover:bg-zinc-800 focus:ring-2 focus:ring-blue-500 focus:outline-hidden"
|
||||
@click="mobileFiltersOpen = false"
|
||||
>
|
||||
<span class="absolute -inset-0.5" />
|
||||
<span class="sr-only">{{ $t("common.close") }}</span>
|
||||
<XMarkIcon class="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form class="mt-4 border-t border-zinc-700">
|
||||
<Disclosure
|
||||
v-for="section in options"
|
||||
v-slot="{ open }"
|
||||
:key="section.param"
|
||||
as="div"
|
||||
class="border-t border-zinc-700 px-4 py-6"
|
||||
>
|
||||
<h3 class="-mx-2 -my-3 flow-root">
|
||||
<DisclosureButton
|
||||
class="flex w-full items-center justify-between bg-zinc-900 px-2 py-3 text-zinc-500 hover:text-zinc-400"
|
||||
>
|
||||
<span class="font-medium text-zinc-100">{{
|
||||
section.name
|
||||
}}</span>
|
||||
<span class="ml-6 flex items-center">
|
||||
<PlusIcon
|
||||
v-if="!open"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MinusIcon v-else class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
</h3>
|
||||
<DisclosurePanel class="pt-6">
|
||||
<div
|
||||
v-if="section.options.length <= 10"
|
||||
class="gap-3 grid grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="(option, optionIdx) in section.options"
|
||||
:key="option.param"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div class="flex h-5 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
<input
|
||||
v-if="section.multiple"
|
||||
:id="`filter-${section.param}-${option}`"
|
||||
v-model="
|
||||
(optionValues[section.param] as any)[
|
||||
option.param
|
||||
]
|
||||
"
|
||||
:name="`${section.param}[]`"
|
||||
type="checkbox"
|
||||
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-900 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="`filter-${section.param}`"
|
||||
:value="optionValues[section.param]"
|
||||
:name="`${section.param}[]`"
|
||||
type="checkbox"
|
||||
class="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||
@update:value="
|
||||
() =>
|
||||
(optionValues[section.param] = option.param)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
:for="`filter-mobile-${section.param}-${optionIdx}`"
|
||||
class="min-w-0 flex-1 text-zinc-400"
|
||||
>{{ option.name }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<MultiItemSelector
|
||||
v-else
|
||||
v-model="[optionValues[section.param] as any][0]"
|
||||
:items="section.options"
|
||||
/>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</form>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
|
||||
<main class="mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="flex items-baseline justify-between border-b border-zinc-700 py-6"
|
||||
>
|
||||
<div />
|
||||
<div class="flex items-center">
|
||||
<Menu as="div" class="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton
|
||||
class="group inline-flex justify-center text-sm font-medium text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
{{ $t("store.view.sort") }}
|
||||
<ChevronDownIcon
|
||||
class="-mr-1 ml-1 size-5 shrink-0 text-gray-400 group-hover:text-zinc-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-zinc-950 shadow-2xl ring-1 ring-white/5 focus:outline-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<MenuItem
|
||||
v-for="option in sorts"
|
||||
:key="option.param"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
currentSort == option.param
|
||||
? 'font-medium text-zinc-100'
|
||||
: 'text-zinc-400',
|
||||
active ? 'bg-zinc-900 outline-hidden' : '',
|
||||
'w-full text-left block px-4 py-2 text-sm',
|
||||
]"
|
||||
@click="() => (currentSort = option.param)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
|
||||
<button
|
||||
v-if="false"
|
||||
type="button"
|
||||
class="-m-2 ml-5 p-2 text-zinc-500 hover:text-zinc-400 sm:ml-7"
|
||||
>
|
||||
<span class="sr-only">{{ $t("store.view.srViewGrid") }}</span>
|
||||
<Squares2X2Icon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'-m-2 ml-4 p-2 sm:ml-6 lg:hidden',
|
||||
filterQuery
|
||||
? 'text-zinc-100 hover:text-zinc-200'
|
||||
: 'text-zinc-500 hover:text-zinc-400',
|
||||
]"
|
||||
@click="mobileFiltersOpen = true"
|
||||
>
|
||||
<span class="sr-only"> {{ $t("store.view.srFilters") }} </span>
|
||||
<FunnelIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="games-heading" class="pt-6 pb-24">
|
||||
<h2 id="games-heading" class="sr-only">
|
||||
{{ $t("store.view.srGames") }}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-10 lg:grid-cols-5">
|
||||
<!-- Filters -->
|
||||
<form class="hidden lg:block">
|
||||
<Disclosure
|
||||
v-for="section in options"
|
||||
:key="section.param"
|
||||
v-slot="{ open }"
|
||||
as="div"
|
||||
class="border-b border-zinc-700 py-6"
|
||||
>
|
||||
<h3 class="-my-3 flow-root">
|
||||
<DisclosureButton
|
||||
class="flex w-full items-center justify-between bg-zinc-900 py-3 text-sm text-zinc-500 hover:text-zinc-400"
|
||||
>
|
||||
<span class="font-medium text-zinc-100">{{
|
||||
section.name
|
||||
}}</span>
|
||||
<span class="ml-6 flex items-center">
|
||||
<PlusIcon
|
||||
v-if="!open"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MinusIcon v-else class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
</h3>
|
||||
<DisclosurePanel class="pt-6">
|
||||
<div v-if="section.options.length <= 10" class="space-y-4">
|
||||
<div
|
||||
v-for="(option, optionIdx) in section.options"
|
||||
:key="option.param"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex h-5 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
<input
|
||||
v-if="section.multiple"
|
||||
:id="`filter-${section.param}-${optionIdx}`"
|
||||
v-model="
|
||||
(optionValues[section.param] as any)[option.param]
|
||||
"
|
||||
:name="`${section.param}[]`"
|
||||
type="checkbox"
|
||||
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="`filter-${section.param}-${optionIdx}`"
|
||||
:value="optionValues[section.param]"
|
||||
:name="`${section.param}[]`"
|
||||
type="radio"
|
||||
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||
@input="optionValues[section.param] = option.param"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>{{ option.name }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<MultiItemSelector
|
||||
v-else
|
||||
v-model="[optionValues[section.param] as any][0]"
|
||||
:items="section.options"
|
||||
/>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</form>
|
||||
|
||||
<!-- Product grid -->
|
||||
<div
|
||||
v-if="games?.length ?? 0 > 0"
|
||||
ref="product-grid"
|
||||
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
|
||||
>
|
||||
<!-- Your content -->
|
||||
<GamePanel
|
||||
v-for="game in games"
|
||||
:key="game.id"
|
||||
:game="game"
|
||||
:href="`/store/${game.id}`"
|
||||
:show-title-description="showGamePanelTextDecoration"
|
||||
/>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 bg-zinc-900/40 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex lg:col-span-4 items-start justify-center">
|
||||
<span class="uppercase text-zinc-700 font-display font-bold">{{
|
||||
$t("common.noResults")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
TransitionChild,
|
||||
TransitionRoot,
|
||||
} from "@headlessui/vue";
|
||||
import { XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
FunnelIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
|
||||
import MultiItemSelector from "./MultiItemSelector.vue";
|
||||
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
|
||||
|
||||
const mobileFiltersOpen = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
params?: { [key: string]: string };
|
||||
extraOptions?: Array<StoreFilterOption>;
|
||||
prefilled?: {
|
||||
[key: string]: { [key: string]: string | { [key: string]: boolean } };
|
||||
};
|
||||
}>();
|
||||
|
||||
const tags =
|
||||
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
|
||||
|
||||
const userPlatforms = await $dropFetch("/api/v1/store/platforms");
|
||||
|
||||
const sorts: Array<StoreSortOption> = [
|
||||
{
|
||||
name: "Default",
|
||||
param: "default",
|
||||
},
|
||||
{
|
||||
name: "Newest",
|
||||
param: "newest",
|
||||
},
|
||||
{
|
||||
name: "Recently Added",
|
||||
param: "recent",
|
||||
},
|
||||
];
|
||||
const currentSort = ref(sorts[0].param);
|
||||
|
||||
const options: Array<StoreFilterOption> = [
|
||||
...(tags.length > 0
|
||||
? [
|
||||
{
|
||||
name: "Tags",
|
||||
param: "tags",
|
||||
multiple: true,
|
||||
options: tags.map((e) => ({ name: e.name, param: e.id })),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Platform",
|
||||
param: "platform",
|
||||
multiple: true,
|
||||
options: renderPlatforms(userPlatforms),
|
||||
},
|
||||
...(props.extraOptions ?? []),
|
||||
];
|
||||
const optionValues = ref<{
|
||||
[key: string]: string | undefined | { [key: string]: boolean | undefined };
|
||||
}>(
|
||||
Object.fromEntries(
|
||||
options.map((v) => [v.param, v.multiple ? {} : undefined]),
|
||||
),
|
||||
);
|
||||
Object.assign(optionValues.value, props.prefilled);
|
||||
|
||||
const filterQuery = computed(() => {
|
||||
const query = Object.entries(optionValues.value)
|
||||
.filter(
|
||||
([_, v]) =>
|
||||
v &&
|
||||
(typeof v !== "object" || Object.values(v).filter((e) => e).length > 0),
|
||||
)
|
||||
.map(([n, v]) => {
|
||||
if (typeof v === "string") return [`${n}=${v}`];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const enabledOptions = Object.entries(v as any).filter(([_, e]) => e);
|
||||
return `${n}=${enabledOptions.map(([k, _]) => k).join(",")}`;
|
||||
})
|
||||
.join("&");
|
||||
const extraFilters = props.params
|
||||
? Object.entries(props.params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("&")
|
||||
: props.params;
|
||||
return `${query}${extraFilters ? (query ? "&" : "") + extraFilters : ""}`;
|
||||
});
|
||||
|
||||
const games = ref<Array<SerializeObject<GameModel>>>();
|
||||
const loading = ref(false);
|
||||
|
||||
const productGrid = useTemplateRef<HTMLElement>("product-grid");
|
||||
|
||||
const { reset } = useInfiniteScroll(
|
||||
productGrid,
|
||||
async () => await updateGames(filterQuery.value, false),
|
||||
{
|
||||
distance: 10,
|
||||
canLoadMore: () => {
|
||||
return canLoadMore.value;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const canLoadMore = ref(true);
|
||||
async function updateGames(query: string, resetGames: boolean) {
|
||||
loading.value = true;
|
||||
games.value ??= [];
|
||||
const newValues = await $dropFetch<{
|
||||
results: Array<SerializeObject<GameModel>>;
|
||||
count: number;
|
||||
}>(
|
||||
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
|
||||
);
|
||||
if (resetGames) {
|
||||
games.value = newValues.results;
|
||||
if (import.meta.client) await reset();
|
||||
} else {
|
||||
games.value.push(...newValues.results);
|
||||
}
|
||||
canLoadMore.value = games.value.length < newValues.count;
|
||||
loading.value = false;
|
||||
}
|
||||
watch(filterQuery, (newUrl) => {
|
||||
updateGames(newUrl, true);
|
||||
});
|
||||
watch(currentSort, (_) => {
|
||||
updateGames(filterQuery.value, true);
|
||||
});
|
||||
|
||||
await updateGames(filterQuery.value, true);
|
||||
</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>
|
||||
@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
|
||||
<h2 id="footer-heading" class="sr-only">Footer</h2>
|
||||
<h2 id="footer-heading" class="sr-only">{{ $t("footer.footer") }}</h2>
|
||||
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
|
||||
<!-- Drop Info -->
|
||||
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||
<div class="space-y-8">
|
||||
<DropWordmark class="h-10" />
|
||||
<p class="text-sm leading-6 text-zinc-300">
|
||||
An open-source game distribution platform built for speed,
|
||||
flexibility and beauty.
|
||||
{{ $t("drop.desc") }}
|
||||
</p>
|
||||
|
||||
<LanguageSelector />
|
||||
|
||||
<div class="flex space-x-6">
|
||||
<NuxtLink
|
||||
v-for="item in navigation.social"
|
||||
@ -22,10 +25,14 @@
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Foot links -->
|
||||
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
||||
<div class="md:grid md:grid-cols-2 md:gap-8">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
|
||||
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||
{{ $t("footer.games") }}
|
||||
</h3>
|
||||
<ul role="list" class="mt-6 space-y-4">
|
||||
<li v-for="item in navigation.games" :key="item.name">
|
||||
<NuxtLink
|
||||
@ -38,7 +45,7 @@
|
||||
</div>
|
||||
<div class="mt-10 md:mt-0">
|
||||
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||
Community
|
||||
{{ $t("userHeader.links.community") }}
|
||||
</h3>
|
||||
<ul role="list" class="mt-6 space-y-4">
|
||||
<li v-for="item in navigation.community" :key="item.name">
|
||||
@ -54,7 +61,7 @@
|
||||
<div class="md:grid md:grid-cols-2 md:gap-8">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||
Documentation
|
||||
{{ $t("footer.documentation") }}
|
||||
</h3>
|
||||
<ul role="list" class="mt-6 space-y-4">
|
||||
<li v-for="item in navigation.documentation" :key="item.name">
|
||||
@ -67,7 +74,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-10 md:mt-0">
|
||||
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
|
||||
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||
{{ $t("footer.about") }}
|
||||
</h3>
|
||||
<ul role="list" class="mt-6 space-y-4">
|
||||
<li v-for="item in navigation.about" :key="item.name">
|
||||
<NuxtLink
|
||||
@ -80,6 +89,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center xl:col-span-3 mt-8">
|
||||
<NuxtLink
|
||||
:to="`https://github.com/Drop-OSS/drop/releases/tag/${versionInfo.version}`"
|
||||
class="text-xs text-zinc-700 hover:text-zinc-400 transition-colors duration-200 cursor-default select-none"
|
||||
>
|
||||
<i18n-t keypath="footer.version" tag="span" scope="global">
|
||||
<template #version>
|
||||
<span>{{ versionInfo.version }}</span>
|
||||
</template>
|
||||
<template #gitRef>
|
||||
<span>{{ versionInfo.gitRef }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@ -88,45 +113,49 @@
|
||||
<script setup lang="ts">
|
||||
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
|
||||
|
||||
const navigation = {
|
||||
const { t } = useI18n();
|
||||
|
||||
const versionInfo = await $dropFetch("/api/v1");
|
||||
|
||||
const navigation = computed(() => ({
|
||||
games: [
|
||||
{ name: "Newly Added", href: "#" },
|
||||
{ name: "New Releases", href: "#" },
|
||||
{ name: "Top Sellers", href: "#" },
|
||||
{ name: "Find a Game", href: "#" },
|
||||
{ name: t("store.recentlyAdded"), href: "#" },
|
||||
{ name: t("store.recentlyReleased"), href: "#" },
|
||||
{ name: t("footer.topSellers"), href: "#" },
|
||||
{ name: t("footer.findGame"), href: "#" },
|
||||
],
|
||||
community: [
|
||||
{ name: "Friends", href: "#" },
|
||||
{ name: "Groups", href: "#" },
|
||||
{ name: "Servers", href: "#" },
|
||||
{ name: t("common.friends"), href: "#" },
|
||||
{ name: t("common.groups"), href: "#" },
|
||||
{ name: t("common.servers"), href: "#" },
|
||||
],
|
||||
documentation: [
|
||||
{ name: "API", href: "https://api.droposs.org/" },
|
||||
// TODO: public API docs
|
||||
// { name: t("footer.api"), href: "https://api.droposs.org/" },
|
||||
{
|
||||
name: "Server Docs",
|
||||
href: "https://wiki.droposs.org/guides/quickstart.html",
|
||||
name: t("footer.docs.server"),
|
||||
href: "https://docs.droposs.org/docs/guides/quickstart",
|
||||
},
|
||||
{
|
||||
name: "Client Docs",
|
||||
href: "https://wiki.droposs.org/guides/client.html",
|
||||
name: t("footer.docs.client"),
|
||||
href: "https://docs.droposs.org/docs/guides/client",
|
||||
},
|
||||
],
|
||||
about: [
|
||||
{ name: "About Drop", href: "https://droposs.org/" },
|
||||
{ name: "Features", href: "https://droposs.org/features" },
|
||||
{ name: "FAQ", href: "https://droposs.org/faq" },
|
||||
{ name: t("footer.aboutDrop"), href: "https://droposs.org/" },
|
||||
{ name: t("footer.comparison"), href: "https://droposs.org/comparison" },
|
||||
],
|
||||
social: [
|
||||
{
|
||||
name: "GitHub",
|
||||
name: t("footer.social.github"),
|
||||
href: "https://github.com/Drop-OSS",
|
||||
icon: IconsGithubLogo,
|
||||
},
|
||||
{
|
||||
name: "Discord",
|
||||
name: t("footer.social.discord"),
|
||||
href: "https://discord.gg/NHx46XKJWA",
|
||||
icon: IconsDiscordLogo,
|
||||
},
|
||||
],
|
||||
};
|
||||
}));
|
||||
</script>
|
||||
@ -76,7 +76,7 @@
|
||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
|
||||
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@ -125,7 +125,9 @@
|
||||
class="-m-2.5 p-2.5"
|
||||
@click="sidebarOpen = false"
|
||||
>
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<span class="sr-only">{{
|
||||
$t("userHeader.closeSidebar")
|
||||
}}</span>
|
||||
<XMarkIcon class="h-6 w-6 text-zinc-400" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@ -172,6 +174,9 @@
|
||||
<BellIcon class="h-5" />
|
||||
</UserHeaderWidget>
|
||||
</li>
|
||||
<li class="w-full">
|
||||
<UserHeaderWidget class="w-full" />
|
||||
</li>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
@ -198,32 +203,33 @@ import { Bars3Icon } from "@heroicons/vue/24/outline";
|
||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const homepageURL = "/store";
|
||||
const navigation: Array<NavigationItem> = [
|
||||
const navigation: Ref<Array<NavigationItem>> = computed(() => [
|
||||
{
|
||||
prefix: "/store",
|
||||
route: "/store",
|
||||
label: "Store",
|
||||
label: t("store.title"),
|
||||
},
|
||||
{
|
||||
prefix: "/library",
|
||||
route: "/library",
|
||||
label: "Library",
|
||||
label: t("userHeader.links.library"),
|
||||
},
|
||||
{
|
||||
prefix: "/community",
|
||||
route: "/community",
|
||||
label: "Community",
|
||||
label: t("userHeader.links.community"),
|
||||
},
|
||||
{
|
||||
prefix: "/news",
|
||||
route: "/news",
|
||||
label: "News",
|
||||
label: t("userHeader.links.news"),
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const currentPageIndex = useCurrentNavigationIndex(navigation);
|
||||
const currentPageIndex = useCurrentNavigationIndex(navigation.value);
|
||||
|
||||
const notifications = useNotifications();
|
||||
const unreadNotifications = computed(() =>
|
||||
@ -6,7 +6,7 @@
|
||||
>
|
||||
<div class="ml-4 mt-2">
|
||||
<h3 class="text-base font-semibold text-zinc-100 text-sm">
|
||||
Unread notifications
|
||||
{{ $t("account.notifications.unread") }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="ml-4 mt-2 shrink-0">
|
||||
@ -15,7 +15,15 @@
|
||||
type="button"
|
||||
class="text-sm text-zinc-400"
|
||||
>
|
||||
View all →
|
||||
<i18n-t
|
||||
keypath="account.notifications.all"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
@ -32,13 +40,13 @@
|
||||
v-if="props.notifications.length == 0"
|
||||
class="text-sm text-zinc-400 p-3 text-center w-full"
|
||||
>
|
||||
No notifications
|
||||
{{ $t("account.notifications.none") }}
|
||||
</div>
|
||||
</PanelWidget>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Notification } from "~/prisma/client";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const props = defineProps<{ notifications: Array<Notification> }>();
|
||||
const props = defineProps<{ notifications: Array<NotificationModel> }>();
|
||||
</script>
|
||||
@ -46,29 +46,30 @@
|
||||
hydrate-on-visible
|
||||
as="div"
|
||||
>
|
||||
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
|
||||
<button
|
||||
:href="nav.route"
|
||||
<NuxtLink
|
||||
:to="nav.route"
|
||||
:class="[
|
||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
||||
'w-full text-left transition block px-4 py-2 text-sm',
|
||||
]"
|
||||
@click="() => navigateTo(nav.route, close)"
|
||||
@click="close"
|
||||
>
|
||||
{{ nav.label }}
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }" hydrate-on-visible as="div">
|
||||
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
|
||||
<a
|
||||
<MenuItem v-slot="{ active, close }" hydrate-on-visible as="div">
|
||||
<NuxtLink
|
||||
to="/auth/signout"
|
||||
:class="[
|
||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
||||
'w-full text-left transition block px-4 py-2 text-sm',
|
||||
]"
|
||||
href="/auth/signout"
|
||||
:data-comment="'external=true is required because we implemented the signout as a route on the server for performance'"
|
||||
:external="true"
|
||||
@click="close"
|
||||
>
|
||||
Signout
|
||||
</a>
|
||||
{{ $t("auth.signout") }}
|
||||
</NuxtLink>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</PanelWidget>
|
||||
@ -80,23 +81,28 @@
|
||||
<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();
|
||||
|
||||
const navigation: NavigationItem[] = [
|
||||
user.value?.admin
|
||||
? {
|
||||
label: "Admin Dashboard",
|
||||
route: "/admin",
|
||||
prefix: "",
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
label: "Account settings",
|
||||
route: "/account",
|
||||
prefix: "",
|
||||
},
|
||||
].filter((e) => e !== undefined);
|
||||
const navigation = computed<NavigationItem[]>(() =>
|
||||
[
|
||||
user.value?.admin
|
||||
? {
|
||||
label: $t("userHeader.profile.admin"),
|
||||
route: "/admin",
|
||||
prefix: "",
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
label: $t("userHeader.profile.settings"),
|
||||
route: "/account",
|
||||
prefix: "",
|
||||
},
|
||||
{
|
||||
label: "Authorize client",
|
||||
route: "/client/code",
|
||||
prefix: "",
|
||||
},
|
||||
].filter((e) => e !== undefined),
|
||||
);
|
||||
</script>
|
||||
@ -1,8 +1,12 @@
|
||||
import type { Collection, CollectionEntry, Game } from "~/prisma/client";
|
||||
import type {
|
||||
CollectionModel,
|
||||
CollectionEntryModel,
|
||||
GameModel,
|
||||
} from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
type FullCollection = Collection & {
|
||||
entries: Array<CollectionEntry & { game: SerializeObject<Game> }>;
|
||||
type FullCollection = CollectionModel & {
|
||||
entries: Array<CollectionEntryModel & { game: SerializeObject<GameModel> }>;
|
||||
};
|
||||
|
||||
export const useCollections = async () => {
|
||||
@ -1,11 +1,11 @@
|
||||
import type { Article } from "~/prisma/client";
|
||||
import type { ArticleModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
export const useNews = () =>
|
||||
useState<
|
||||
| Array<
|
||||
SerializeObject<
|
||||
Article & {
|
||||
ArticleModel & {
|
||||
tags: Array<{ id: string; name: string }>;
|
||||
author: { displayName: string; id: string } | null;
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
import type { Notification } from "~/prisma/client";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
|
||||
const ws = new WebSocketHandler("/api/v1/notifications/ws");
|
||||
|
||||
export const useNotifications = () =>
|
||||
useState<Array<Notification>>("notifications", () => []);
|
||||
useState<Array<NotificationModel>>("notifications", () => []);
|
||||
|
||||
ws.listen((e) => {
|
||||
const notification = JSON.parse(e) as Notification;
|
||||
const notification = JSON.parse(e) as NotificationModel;
|
||||
const notifications = useNotifications();
|
||||
notifications.value.push(notification);
|
||||
});
|
||||
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!
|
||||
}
|
||||
94
app/composables/request.ts
Normal file
94
app/composables/request.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import type {
|
||||
ExtractedRouteMethod,
|
||||
NitroFetchOptions,
|
||||
NitroFetchRequest,
|
||||
TypedInternalResponse,
|
||||
} from "nitropack/types";
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
interface DropFetch<
|
||||
DefaultT = unknown,
|
||||
DefaultR extends NitroFetchRequest = NitroFetchRequest,
|
||||
> {
|
||||
<
|
||||
T = DefaultT,
|
||||
R extends NitroFetchRequest = DefaultR,
|
||||
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
|
||||
>(
|
||||
request: R,
|
||||
opts?: O & { failTitle?: string },
|
||||
): Promise<
|
||||
// sometimes there is an error, other times there isn't
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
TypedInternalResponse<
|
||||
R,
|
||||
T,
|
||||
NitroFetchOptions<R> extends O ? "get" : ExtractedRouteMethod<R, O>
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
const requestParts = rawRequest.toString().split("/");
|
||||
requestParts.forEach((part, index) => {
|
||||
if (!part.startsWith(":")) {
|
||||
return;
|
||||
}
|
||||
const partName = part.slice(1);
|
||||
const replacement = opts?.params?.[partName] as string | undefined;
|
||||
if (!replacement) {
|
||||
return;
|
||||
}
|
||||
requestParts[index] = replacement;
|
||||
|
||||
delete opts?.params?.[partName];
|
||||
});
|
||||
const request = requestParts.join("/");
|
||||
|
||||
// If not in setup
|
||||
if (!getCurrentInstance()?.proxy) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Excessive stack depth comparing types
|
||||
return await $fetch(request, opts);
|
||||
} catch (e) {
|
||||
if (import.meta.client && opts?.failTitle) {
|
||||
console.warn(e);
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.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();
|
||||
|
||||
const state = useState(id);
|
||||
if (state.value) {
|
||||
// Deep copy
|
||||
const object = JSON.parse(JSON.stringify(state.value));
|
||||
// Never use again on client
|
||||
if (import.meta.client) state.value = undefined;
|
||||
return object;
|
||||
}
|
||||
|
||||
const headers = useRequestHeaders(["cookie", "authorization"]);
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
};
|
||||
12
app/composables/store.ts
Normal file
12
app/composables/store.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type StoreFilterOption = {
|
||||
name: string;
|
||||
param: string;
|
||||
options: Array<StoreSortOption>;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
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",
|
||||
}
|
||||
};
|
||||
13
app/composables/user.ts
Normal file
13
app/composables/user.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
|
||||
// undefined = haven't check
|
||||
// null = check, no user
|
||||
// {} = check, user
|
||||
|
||||
export const useUser = () => useState<UserModel | undefined | null>(undefined);
|
||||
export const updateUser = async () => {
|
||||
const user = useUser();
|
||||
if (user.value === null) return;
|
||||
|
||||
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
|
||||
};
|
||||
25
app/composables/users.ts
Normal file
25
app/composables/users.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { UserModel } from "~~/prisma/client/models";
|
||||
import type { AuthMec } from "~~/prisma/client/enums";
|
||||
|
||||
export const useUsers = () =>
|
||||
useState<
|
||||
| Array<
|
||||
SerializeObject<
|
||||
UserModel & {
|
||||
authMecs?: Array<{ id: string; mec: AuthMec }>;
|
||||
}
|
||||
>
|
||||
>
|
||||
| undefined
|
||||
>("users", () => undefined);
|
||||
|
||||
export const fetchUsers = async () => {
|
||||
const users = useUsers();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore forget why this ignor exists
|
||||
const newValue: User[] = await $dropFetch("/api/v1/admin/users");
|
||||
users.value = newValue;
|
||||
return newValue;
|
||||
};
|
||||
@ -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,13 +8,14 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
await updateUser();
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const user = useUser();
|
||||
const statusCode = props.error?.statusCode;
|
||||
const message =
|
||||
props.error?.statusMessage ||
|
||||
props.error?.message ||
|
||||
"An unknown error occurred.";
|
||||
props.error?.message || props.error?.statusMessage || t("errors.unknown");
|
||||
const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
|
||||
|
||||
async function signIn() {
|
||||
@ -22,13 +23,18 @@ async function signIn() {
|
||||
redirect: `/auth/signin?redirect=${encodeURIComponent(route.fullPath)}`,
|
||||
});
|
||||
}
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
case 403:
|
||||
await signIn();
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: `${statusCode ?? message} | Drop`,
|
||||
title: t("errors.pageTitle", [statusCode ?? message]),
|
||||
});
|
||||
|
||||
if (import.meta.client) {
|
||||
console.log(props.error);
|
||||
console.warn(props.error);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -51,7 +57,7 @@ if (import.meta.client) {
|
||||
<h1
|
||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||
>
|
||||
Oh no!
|
||||
{{ $t("errors.ohNo") }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="message"
|
||||
@ -60,24 +66,32 @@ if (import.meta.client) {
|
||||
{{ message }}
|
||||
</p>
|
||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||
An error occurred while responding to your request. If you believe
|
||||
this to be a bug, please report it. Try signing in and see if it
|
||||
resolves the issue.
|
||||
{{ $t("errors.occurred") }}
|
||||
</p>
|
||||
<!-- <p>{{ error. }}</p> -->
|
||||
<div class="mt-10">
|
||||
<!-- full app reload to fix errors -->
|
||||
<NuxtLink
|
||||
v-if="user && !showSignIn"
|
||||
to="/"
|
||||
class="text-sm font-semibold leading-7 text-blue-600"
|
||||
><span aria-hidden="true">←</span> Back to home</NuxtLink
|
||||
>
|
||||
<i18n-t keypath="errors.backHome" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
<button
|
||||
v-else
|
||||
class="text-sm font-semibold leading-7 text-blue-600"
|
||||
@click="signIn"
|
||||
>
|
||||
Sign in <span aria-hidden="true">→</span>
|
||||
<i18n-t keypath="errors.signIn" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -87,7 +101,7 @@ if (import.meta.client) {
|
||||
<nav
|
||||
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
|
||||
>
|
||||
<NuxtLink href="/docs">Documentation</NuxtLink>
|
||||
<NuxtLink href="/docs">{{ $t("footer.documentation") }}</NuxtLink>
|
||||
<svg
|
||||
viewBox="0 0 2 2"
|
||||
aria-hidden="true"
|
||||
@ -96,7 +110,7 @@ if (import.meta.client) {
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
|
||||
Support Discord
|
||||
{{ $t("errors.support") }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
@ -42,7 +42,9 @@
|
||||
class="-m-2.5 p-2.5"
|
||||
@click="sidebarOpen = false"
|
||||
>
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<span class="sr-only">{{
|
||||
$t("userHeader.closeSidebar")
|
||||
}}</span>
|
||||
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@ -96,7 +98,7 @@
|
||||
<DropLogo class="h-8 w-auto" />
|
||||
<span
|
||||
class="mt-1 bg-blue-400 px-1 py-0.5 rounded-md text-xs font-bold font-display"
|
||||
>Admin</span
|
||||
>{{ $t("header.admin.admin") }}</span
|
||||
>
|
||||
</div>
|
||||
<nav class="mt-8">
|
||||
@ -131,7 +133,7 @@
|
||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
|
||||
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@ -160,43 +162,50 @@ import {
|
||||
ServerStackIcon,
|
||||
HomeIcon,
|
||||
Cog6ToothIcon,
|
||||
DocumentIcon,
|
||||
UserGroupIcon,
|
||||
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";
|
||||
|
||||
const i18nHead = useLocaleHead();
|
||||
|
||||
const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
{ label: "Home", route: "/admin", prefix: "/admin", icon: HomeIcon },
|
||||
{ label: $t("home"), route: "/admin", prefix: "/admin", icon: HomeIcon },
|
||||
{
|
||||
label: "Library",
|
||||
label: $t("userHeader.links.library"),
|
||||
route: "/admin/library",
|
||||
prefix: "/admin/library",
|
||||
icon: ServerStackIcon,
|
||||
},
|
||||
{
|
||||
label: "Meta",
|
||||
label: $t("header.admin.metadata"),
|
||||
route: "/admin/metadata",
|
||||
prefix: "/admin/metadata",
|
||||
icon: DocumentIcon,
|
||||
},
|
||||
{
|
||||
label: "Users",
|
||||
label: $t("header.admin.users"),
|
||||
route: "/admin/users",
|
||||
prefix: "/admin/users",
|
||||
icon: UserGroupIcon,
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
label: $t("header.admin.tasks"),
|
||||
route: "/admin/task",
|
||||
prefix: "/admin/task",
|
||||
icon: RectangleStackIcon,
|
||||
},
|
||||
{
|
||||
label: $t("header.admin.settings.title"),
|
||||
route: "/admin/settings",
|
||||
prefix: "/admin/settings",
|
||||
icon: Cog6ToothIcon,
|
||||
},
|
||||
{
|
||||
label: "Back",
|
||||
route: "/",
|
||||
label: $t("header.back"),
|
||||
route: "/store",
|
||||
prefix: ".",
|
||||
icon: ArrowLeftIcon,
|
||||
},
|
||||
@ -217,17 +226,12 @@ router.afterEach(() => {
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: "en",
|
||||
lang: i18nHead.value.htmlAttrs.lang,
|
||||
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
|
||||
dir: i18nHead.value.htmlAttrs.dir,
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
href: "/favicon.png",
|
||||
},
|
||||
],
|
||||
titleTemplate(title) {
|
||||
return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`;
|
||||
return title ? $t("adminTitleTemplate", [title]) : $t("adminTitle");
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@ -13,22 +13,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const i18nHead = useLocaleHead();
|
||||
const noWrapper = !!route.query.noWrapper;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: "en",
|
||||
lang: i18nHead.value.htmlAttrs.lang,
|
||||
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
|
||||
dir: i18nHead.value.htmlAttrs.dir,
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
href: "/favicon.png",
|
||||
},
|
||||
],
|
||||
// // seo headers
|
||||
// link: [...i18nHead.value.link],
|
||||
// meta: [...i18nHead.value.meta],
|
||||
titleTemplate(title) {
|
||||
if (title) return `${title} | Drop`;
|
||||
return `Drop`;
|
||||
return title ? t("titleTemplate", [title]) : t("title");
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@ -42,7 +42,9 @@
|
||||
class="-m-2.5 p-2.5"
|
||||
@click="sidebarOpen = false"
|
||||
>
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<span class="sr-only">{{
|
||||
$t("userHeader.closeSidebar")
|
||||
}}</span>
|
||||
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@ -73,13 +75,13 @@
|
||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
|
||||
<Bars3Icon class="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
|
||||
>
|
||||
Account
|
||||
{{ $t("account.title") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -100,6 +102,7 @@ import {
|
||||
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
router.afterEach(() => {
|
||||
@ -107,6 +110,6 @@ router.afterEach(() => {
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Account",
|
||||
title: t("account.title"),
|
||||
});
|
||||
</script>
|
||||
149
app/pages/account/devices.vue
Normal file
149
app/pages/account/devices.vue
Normal file
@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2
|
||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
{{ $t("account.devices.title") }}
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||
>
|
||||
{{ $t("account.devices.subheader") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-800">
|
||||
<thead>
|
||||
<tr class="bg-zinc-800/50">
|
||||
<th
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ $t("common.name") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.devices.platform") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.devices.capabilities") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||
>
|
||||
{{ $t("account.devices.lastConnected") }}
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">{{ $t("actions") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-800">
|
||||
<tr
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
class="transition-colors duration-150 hover:bg-zinc-800/50"
|
||||
>
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ client.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<span
|
||||
class="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20"
|
||||
>
|
||||
{{ client.platform }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="capability in client.capabilities"
|
||||
:key="capability"
|
||||
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
|
||||
>
|
||||
<CheckIcon class="size-3" />
|
||||
{{ capability }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||
<RelativeTime :date="client.lastConnected" />
|
||||
</td>
|
||||
<td
|
||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
@click="() => revokeClientWrapper(client.id)"
|
||||
>
|
||||
{{ $t("account.devices.revoke") }}
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [client.name]) }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="clients.length === 0">
|
||||
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
|
||||
{{ $t("account.devices.noDevices") }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore pending https://github.com/nitrojs/nitro/issues/2758
|
||||
const clients = ref(await $dropFetch("/api/v1/user/client"));
|
||||
const { t } = useI18n();
|
||||
|
||||
async function revokeClient(id: string) {
|
||||
await $dropFetch(`/api/v1/user/client/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// clients.value.push({
|
||||
// id: "example-client",
|
||||
// userId: "example-user",
|
||||
// name: "Example Client",
|
||||
// platform: "Windows",
|
||||
// capabilities: ["TrackPlaytime"],
|
||||
// lastConnected: new Date().toISOString(),
|
||||
// });
|
||||
|
||||
function revokeClientWrapper(id: string) {
|
||||
revokeClient(id)
|
||||
.then(() => {
|
||||
const index = clients.value.findIndex((e) => e.id == id);
|
||||
clients.value.splice(index, 1);
|
||||
})
|
||||
.catch((e) => {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.revokeClient"),
|
||||
description: t("errors.revokeClientFull", String(e)),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
94
app/pages/account/index.vue
Normal file
94
app/pages/account/index.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<!-- go away eslint -->
|
||||
<div />
|
||||
<!-- I don't want to localize this -->
|
||||
<!--
|
||||
<div>
|
||||
<div v-if="user" class="mx-auto max-w-2xl lg:mx-0">
|
||||
<h2
|
||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
Hello, {{ user.displayName }}!
|
||||
</h2>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||
>
|
||||
Welcome to your Drop account. Here you can view and manage your account
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="user" class="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h3 class="text-base font-semibold text-zinc-100">
|
||||
Account Information
|
||||
</h3>
|
||||
<dl class="mt-4 space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-zinc-400">Username</dt>
|
||||
<dd class="text-sm text-zinc-100">{{ user.username }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-zinc-400">Email</dt>
|
||||
<dd class="text-sm text-zinc-100">{{ user.email }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-sm font-medium text-zinc-400">Account Type</dt>
|
||||
<dd>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
|
||||
user.admin
|
||||
? 'bg-blue-400/10 text-blue-400 ring-blue-400/20'
|
||||
: 'bg-zinc-400/10 text-zinc-400 ring-zinc-400/20',
|
||||
]"
|
||||
>
|
||||
{{ user.admin ? "Administrator" : "Standard User" }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h3 class="text-base font-semibold text-zinc-100">Account Actions</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
|
||||
>
|
||||
Update Email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center min-h-[200px]">
|
||||
<div class="text-zinc-400">Loading account information...</div>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: "default",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Account",
|
||||
});
|
||||
</script>
|
||||
150
app/pages/account/notifications.vue
Normal file
150
app/pages/account/notifications.vue
Normal file
@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border-b border-zinc-800 pb-4 w-full">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<h2
|
||||
class="text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||
>
|
||||
{{ $t("account.notifications.notifications") }}
|
||||
</h2>
|
||||
<button
|
||||
:disabled="notifications.length === 0"
|
||||
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800 disabled:hover:scale-100 disabled:hover:shadow-none"
|
||||
@click="markAllAsRead"
|
||||
>
|
||||
<CheckIcon class="size-4" />
|
||||
{{ $t("account.notifications.markAllAsRead") }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||
>
|
||||
{{ $t("account.notifications.desc") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
|
||||
:class="{ 'opacity-75': notification.read }"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-base font-semibold text-zinc-100">
|
||||
{{ notification.title }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-zinc-400">
|
||||
{{ notification.description }}
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<NuxtLink
|
||||
v-for="[name, href] in notification.actions.map((v) =>
|
||||
v.split('|'),
|
||||
)"
|
||||
:key="href"
|
||||
:href="href"
|
||||
class="inline-flex items-center rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20 transition-all duration-200 hover:bg-blue-400/20 hover:scale-105 active:scale-95"
|
||||
>
|
||||
{{ name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2">
|
||||
<span class="text-xs text-zinc-500">
|
||||
<RelativeTime :date="notification.created" />
|
||||
</span>
|
||||
<button
|
||||
v-if="!notification.read"
|
||||
type="button"
|
||||
class="inline-flex gap-x-1 items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20 transition-all duration-200 hover:bg-zinc-400/20 hover:scale-105 active:scale-95"
|
||||
@click="markAsRead(notification.id)"
|
||||
>
|
||||
<CheckIcon class="size-3" />
|
||||
{{ $t("account.notifications.markAsRead") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex gap-x-1 items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
@click="deleteNotification(notification.id)"
|
||||
>
|
||||
<TrashIcon class="size-3" />
|
||||
{{ $t("delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notifications.length === 0"
|
||||
class="rounded-xl border border-zinc-800 bg-zinc-900 p-8 text-center"
|
||||
>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("account.notifications.none") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
||||
import type { NotificationModel } from "~~/prisma/client/models";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
definePageMeta({
|
||||
layout: "default",
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead({
|
||||
title: t("account.notifications.title"),
|
||||
});
|
||||
|
||||
// Fetch notifications
|
||||
const notifications = ref<SerializeObject<NotificationModel>[]>([]);
|
||||
|
||||
async function fetchNotifications() {
|
||||
const { data } = await useFetch("/api/v1/notifications");
|
||||
notifications.value = data.value || [];
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
await fetchNotifications();
|
||||
|
||||
// Mark a notification as read
|
||||
async function markAsRead(id: string) {
|
||||
await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" });
|
||||
const notification = notifications.value.find(
|
||||
(n: SerializeObject<NotificationModel>) => n.id === id,
|
||||
);
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all notifications as read
|
||||
async function markAllAsRead() {
|
||||
await $dropFetch("/api/v1/notifications/readall", { method: "POST" });
|
||||
notifications.value.forEach(
|
||||
(notification: SerializeObject<NotificationModel>) => {
|
||||
notification.read = true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Delete a notification
|
||||
async function deleteNotification(id: string) {
|
||||
await $dropFetch(`/api/v1/notifications/${id}`, { method: "DELETE" });
|
||||
const index = notifications.value.findIndex(
|
||||
(n: SerializeObject<NotificationModel>) => n.id === id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
11
app/pages/admin/index.vue
Normal file
11
app/pages/admin/index.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template><div /></template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Home",
|
||||
});
|
||||
</script>
|
||||
@ -1,13 +1,14 @@
|
||||
<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"
|
||||
>Select version to import</ListboxLabel
|
||||
>
|
||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{
|
||||
$t("library.admin.import.version.version")
|
||||
}}</ListboxLabel>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
@ -15,9 +16,9 @@
|
||||
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
|
||||
versions[currentlySelectedVersion]
|
||||
}}</span>
|
||||
<span v-else class="block truncate text-zinc-600"
|
||||
>Please select a directory...</span
|
||||
>
|
||||
<span v-else class="block truncate text-zinc-600">{{
|
||||
$t("library.admin.import.selectDir")
|
||||
}}</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
@ -73,124 +74,57 @@
|
||||
</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"
|
||||
>Setup executable/command</label
|
||||
>Version name</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">Ran once when the game is installed</p>
|
||||
<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"
|
||||
>{{ $t("library.admin.import.version.setupCmd") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.setupDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||
>
|
||||
<span
|
||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||
>(install_dir)/</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="'setup.exe'"
|
||||
@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']"
|
||||
>
|
||||
"{{ 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>
|
||||
{{ $t("library.admin.import.version.installDir") }}
|
||||
</span>
|
||||
<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"
|
||||
@ -200,20 +134,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- setup mode -->
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
<SwitchGroup as="div" class="max-w-lg 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
|
||||
>Setup mode</SwitchLabel
|
||||
>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
||||
>When enabled, this version does not have a launch command, and
|
||||
simply runs the executable on the user's computer. Useful for games
|
||||
that only distribute installer and not portable
|
||||
files.</SwitchDescription
|
||||
>{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
|
||||
>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
|
||||
$t("library.admin.import.version.setupModeDesc")
|
||||
}}</SwitchDescription>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="versionSettings.onlySetup"
|
||||
@ -231,151 +162,143 @@
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<div class="relative">
|
||||
<!-- launch commands -->
|
||||
<div class="relative max-w-3xl">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>Launch executable/command</label
|
||||
>{{ $t("library.admin.import.version.launchCmd") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">Executable to launch the game</p>
|
||||
<div class="mt-2">
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.launchDesc") }}
|
||||
</p>
|
||||
<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"
|
||||
>(install_dir)/</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="'game.exe'"
|
||||
@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']"
|
||||
>
|
||||
"{{ 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">
|
||||
Version 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"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>Update mode</SwitchLabel
|
||||
>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
||||
>When enabled, these files will be installed on top of (overwriting)
|
||||
the previous version's. If multiple "update modes" are chained
|
||||
together, they are applied in order.</SwitchDescription
|
||||
>
|
||||
{{ $t("library.admin.import.version.updateMode") }}
|
||||
</SwitchLabel>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.import.version.updateModeDesc") }}
|
||||
</SwitchDescription>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="versionSettings.delta"
|
||||
@ -393,12 +316,14 @@
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<span class="text-base/7 font-semibold">Advanced options</span>
|
||||
<span class="text-base/7 font-semibold">
|
||||
{{ $t("library.admin.import.version.advancedOptions") }}
|
||||
</span>
|
||||
<span class="ml-6 flex h-7 items-center">
|
||||
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
|
||||
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
|
||||
@ -411,7 +336,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">
|
||||
@ -420,13 +345,12 @@
|
||||
as="span"
|
||||
class="text-sm font-medium leading-6 text-zinc-100"
|
||||
passive
|
||||
>Override UMU Launcher Game ID</SwitchLabel
|
||||
>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
||||
>By default, Drop uses a non-ID when launching with UMU
|
||||
Launcher. In order to get the right patches for some games,
|
||||
you may have to manually set this field.</SwitchDescription
|
||||
>
|
||||
{{ $t("library.admin.import.version.umuOverride") }}
|
||||
</SwitchLabel>
|
||||
<SwitchDescription as="span" class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.import.version.umuOverrideDesc") }}
|
||||
</SwitchDescription>
|
||||
</span>
|
||||
<Switch
|
||||
v-model="umuIdEnabled"
|
||||
@ -448,8 +372,9 @@
|
||||
<label
|
||||
for="umu-id"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>UMU Launcher ID</label
|
||||
>
|
||||
{{ $t("library.admin.import.version.umuLauncherId") }}
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="umu-id"
|
||||
@ -460,14 +385,14 @@
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-zinc-400">
|
||||
No advanced options for this configuration.
|
||||
{{ $t("library.admin.import.version.noAdv") }}
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
@ -477,7 +402,7 @@
|
||||
:loading="importLoading"
|
||||
@click="startImport_wrapper"
|
||||
>
|
||||
Import
|
||||
{{ $t("library.admin.import.import") }}
|
||||
</LoadingButton>
|
||||
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
@ -497,7 +422,7 @@
|
||||
role="status"
|
||||
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||
>
|
||||
Loading version metadata...
|
||||
{{ $t("library.admin.import.version.loadingVersion") }}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-6 h-6 text-transparent animate-spin fill-white"
|
||||
@ -514,7 +439,6 @@
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -533,73 +457,51 @@ 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 { PlusIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { ImportVersion } from "~~/server/api/v1/admin/import/version/index.post";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
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,
|
||||
onlySetup: false,
|
||||
umuId: "",
|
||||
const versionSettings = ref<Partial<typeof ImportVersion.infer>>({
|
||||
id: gameId,
|
||||
launches: [],
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -610,7 +512,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);
|
||||
@ -633,15 +536,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)}`,
|
||||
);
|
||||
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() {
|
||||
@ -661,7 +565,7 @@ function startImport_wrapper() {
|
||||
importLoading.value = true;
|
||||
startImport()
|
||||
.catch((error) => {
|
||||
importError.value = error.statusMessage ?? "An unknown error occurred.";
|
||||
importError.value = error.message ?? t("errors.unknown");
|
||||
})
|
||||
.finally(() => {
|
||||
importLoading.value = false;
|
||||
101
app/pages/admin/library/g/[id]/index.vue
Normal file
101
app/pages/admin/library/g/[id]/index.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<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 GameEditorMode)"
|
||||
>
|
||||
<component :is="icon" class="size-4" />
|
||||
{{ value }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- open in store button -->
|
||||
<NuxtLink
|
||||
:href="`/store/${game.id}`"
|
||||
type="button"
|
||||
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
{{ $t("library.admin.openStore") }}
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="-mr-0.5 h-7 w-7 p-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
:is="components[currentMode].editor"
|
||||
v-model="game"
|
||||
:unimported-versions="unimportedVersions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GameEditorMetadata, GameEditorVersion } from "#components";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
DocumentIcon,
|
||||
ServerStackIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const route = useRoute();
|
||||
const gameId = route.params.id.toString();
|
||||
const { game: rawGame, unimportedVersions } = await $dropFetch(
|
||||
`/api/v1/admin/game/:id`,
|
||||
{ params: { id: gameId } },
|
||||
);
|
||||
const game = ref(rawGame);
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
enum GameEditorMode {
|
||||
Metadata = "Metadata",
|
||||
Versions = "Versions",
|
||||
}
|
||||
|
||||
const components: {
|
||||
[key in GameEditorMode]: { editor: Component; icon: Component };
|
||||
} = {
|
||||
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
|
||||
[GameEditorMode.Versions]: {
|
||||
editor: GameEditorVersion,
|
||||
icon: ServerStackIcon,
|
||||
},
|
||||
};
|
||||
|
||||
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
|
||||
|
||||
useHead({
|
||||
title: `${currentMode.value} - ${game.value.mName}`,
|
||||
});
|
||||
|
||||
watch(currentMode, (v) => {
|
||||
useHead({
|
||||
title: `${v} - ${game.value.mName}`,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user