mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
Compare commits
450 Commits
0.2.0-beta
...
d92957f450
| Author | SHA1 | Date | |
|---|---|---|---|
| d92957f450 | |||
| a201b62c04 | |||
| 9bf164ab77 | |||
| 97c6f3490c | |||
| f5cb856d3d | |||
| 67de1f6c02 | |||
| 1002265000 | |||
| 37a2dff0dd | |||
| 799cd6c394 | |||
| 2a005a2222 | |||
| 3942d5c442 | |||
| a520d52ad3 | |||
| aa1de921ee | |||
| bfeacbbdfe | |||
| afce9f159a | |||
| fd828d5b50 | |||
| b33e27e446 | |||
| c97a56eb42 | |||
| 5e5519ece7 | |||
| 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 | |||
| a0bc4bbc4c | |||
| 90277653cb | |||
| ac355918ed | |||
| d6830c3428 | |||
| cbf480bef9 | |||
| afaaaf2eb5 | |||
| 14f0833d17 | |||
| 7f7d8c8f45 | |||
| 52a7de0a8b | |||
| dbded55113 | |||
| aa3105aecd | |||
| 0c48d42c2d | |||
| 836ba33fe4 | |||
| 1f41e52a86 | |||
| 17e4734cfb | |||
| b681476373 | |||
| 3a9eb82fdf | |||
| 7e5e7b032b | |||
| df291c3e9a | |||
| bf691a7f5c | |||
| 597a2264e8 | |||
| 1a2d3c8207 | |||
| 471e85d7c6 | |||
| f9f437dd85 | |||
| 143846c48a | |||
| 2fb909f73d | |||
| 0f773a9779 | |||
| 92a98a5984 | |||
| 464af37afb | |||
| f8dc3fef55 | |||
| 6d35e2697d | |||
| 0d02be2392 | |||
| 48f796ae4b | |||
| 125fe9e6e2 | |||
| 29f3094ad4 | |||
| 733aee3977 | |||
| e3ed60feae | |||
| bfa2c0a641 | |||
| 952df560ec | |||
| 1db2229ad3 | |||
| 731499be81 | |||
| 5aa0899bcf | |||
| 30b065dde3 | |||
| 1f510bee57 | |||
| 07b34c874d | |||
| 19ff73cc30 | |||
| e8633ceca2 | |||
| 770294d559 | |||
| c0c55d35f4 | |||
| a47debda91 | |||
| c449b45009 | |||
| f1f19c8263 | |||
| 31ad8505b7 | |||
| feedcfc5c4 | |||
| a5facbd648 | |||
| 3b1d04251c | |||
| fd4ec3fd1c | |||
| 0a270b267c | |||
| ec6d38d7af | |||
| a9d8ddc0f5 | |||
| dada379354 | |||
| 86c7aa33ce | |||
| 26abb75e94 | |||
| 8eec8b19dd | |||
| 582acfb385 | |||
| 456902c784 | |||
| 87215c4a1e | |||
| d361e01eef | |||
| 8e109dd562 | |||
| 8f429e1e56 | |||
| e362f732e7 | |||
| 86c2d00726 | |||
| d4b89b5dc5 | |||
| ff1255f948 | |||
| 96742cc918 | |||
| c2bb835b0f | |||
| f384492ed2 | |||
| 22a7cfa544 | |||
| 228d109692 | |||
| dc89ff95d8 | |||
| 04c56fd985 | |||
| ca03be7f43 | |||
| 35a2d98790 | |||
| c4d8b24295 | |||
| 42349ad4e1 | |||
| e7566a6316 | |||
| fdffd9a32a | |||
| 4c3413ae63 | |||
| 30e3e7289a | |||
| 12ba416ca5 | |||
| e4aeaee6e7 | |||
| 9d943bc5dc | |||
| 66d1413eb5 | |||
| e572b61af9 | |||
| f9b774ddb5 | |||
| 106b3f66a4 | |||
| 657fd50702 | |||
| 7400fae11b | |||
| 043ef6dcd2 | |||
| 6ea50bffc8 | |||
| 9584d69e97 | |||
| 5ceff44993 | |||
| 372a9bdd97 | |||
| fe82c78571 | |||
| fd11d41dc5 | |||
| 9242a810b0 | |||
| 0b9d0a4ad9 | |||
| 17d3e0ef54 | |||
| 4fd2b159a6 | |||
| d6d457f999 | |||
| 54b3bc3a7e | |||
| 2cbee3d495 | |||
| 7263ec53ac | |||
| 0edfdbdfce | |||
| 114d235a6a | |||
| a47615a274 | |||
| 1987c578bc | |||
| b2327b21fe | |||
| b22681c555 | |||
| 931913b836 | |||
| 2be0e2f88c | |||
| 7df175b747 | |||
| b6d05a6d09 | |||
| 8d88728c99 | |||
| 7141735664 | |||
| 82baeb909a | |||
| 2a85322f64 | |||
| 088cb68604 | |||
| 81be7ccf58 | |||
| a9d1a442f6 | |||
| 97043d6366 | |||
| 756bf8f93f | |||
| 9dc35c80c5 | |||
| 0f35d4a445 | |||
| 57f50b0306 | |||
| 065951d91f | |||
| 36e6c92938 | |||
| e7109e58bb | |||
| 17372a9c06 | |||
| d7297707d7 | |||
| c809c8fcbf | |||
| 68f5f88347 | |||
| 6f8e28d711 | |||
| 47dc364d4e | |||
| 3b4f940983 | |||
| 1048653eef | |||
| f1c932b7d7 | |||
| 7c420ba7d7 | |||
| 7974361e5b | |||
| 01171d788c | |||
| eec709a6e9 | |||
| 5384759261 | |||
| e3022bc52b | |||
| c7af02c15e | |||
| 96a1199fff | |||
| 2cfc2cee7c | |||
| f5e52321b8 | |||
| 58d558159d | |||
| e4e1c66bca | |||
| 1996b97e99 | |||
| cb4937b590 | |||
| 57042892c4 | |||
| 1f309606c9 | |||
| f9e6702d40 | |||
| 690aa042df | |||
| 87f01a9984 | |||
| c1272dc7a7 | |||
| 257cdacad4 | |||
| 2c9fdebf25 | |||
| 2027c69c0e | |||
| e08a13f2c3 | |||
| 6ed7e76b17 | |||
| 573bf87cbb | |||
| e8afa274a7 | |||
| d4d1eaf2e2 | |||
| 6918e78cf9 | |||
| cd93ba2197 | |||
| c052511ff3 | |||
| 19d1a9dd0e | |||
| 66400f4875 | |||
| 88a5dc2a58 | |||
| cf0af15854 | |||
| 61764e81b8 | |||
| 98c8258127 | |||
| 3527f678e5 | |||
| fce084f95a | |||
| 1ad1ebb3fd | |||
| 1de9ebdfa5 | |||
| bd1cb67cd0 | |||
| 3225f536ce | |||
| 58f91f4ac4 | |||
| 8fc37936dc | |||
| 0ca9a3b2f7 | |||
| f8ae5b70c0 | |||
| 7a3b30b012 | |||
| 4e8cffd778 | |||
| bf7eb5b986 | |||
| 77d06df7d3 | |||
| 2755aa472b | |||
| 2b7bc6965d | |||
| 08164cae68 | |||
| 2ca96c34f7 | |||
| 4b4e067fac | |||
| d2b485456a | |||
| 793b57a163 | |||
| d9218dea59 | |||
| 789361ea73 | |||
| ffc1537d7f | |||
| 9d07070ef6 | |||
| 0f0874c10a | |||
| 137241fdbb | |||
| 9515a21dc6 | |||
| c3ee948682 | |||
| 9608cc9742 | |||
| 88aaa2a71b | |||
| 133503582a | |||
| 1eede0f035 | |||
| b6f52f660a | |||
| a1f65b7e59 | |||
| 1ce707788d | |||
| 31aaec74af | |||
| 97792f0707 | |||
| b6189d12e7 | |||
| 0877638fc4 | |||
| 090d2e6586 | |||
| a64a2479ba | |||
| 856babbc21 | |||
| aad5c23f45 | |||
| d8d5b938f0 | |||
| 3afd36a821 | |||
| ce8887528f | |||
| d4dd259b5f | |||
| 256fbd6afa | |||
| 9344d94e4c | |||
| 1286248207 | |||
| 2ef8f2f93c | |||
| 86053815f0 | |||
| 88453f1ec4 | |||
| 623ab7d786 | |||
| 1ed15902a3 | |||
| 3a55075532 | |||
| 6c7866ad14 | |||
| f78b29b7fd | |||
| d8e964e06b | |||
| 5d8f9d3813 | |||
| 28bf070ce2 | |||
| 866c4d354e | |||
| e7837af0e7 | |||
| 97b9b6dc11 | |||
| 09fd01d9b5 | |||
| 8fbe02a1b7 | |||
| dce116b66f | |||
| d167780562 | |||
| 6e057afb6d | |||
| 1967de72c8 | |||
| bfcc7519bf | |||
| 1a2aca9999 | |||
| 282e5bc2a6 | |||
| f369462e7f | |||
| 6317ad2657 | |||
| 42ebbf2922 | |||
| 7c1dec9401 | |||
| ecd26a42a8 | |||
| cf0aa948fe | |||
| 934c176974 | |||
| eea8f82bf9 | |||
| 892f64fe3a | |||
| 6bc3173d3a | |||
| 93a58c0d04 | |||
| 3298b5f3ee | |||
| 6d03266ade | |||
| 1b3cf498f4 | |||
| 0bfe9803ac | |||
| 617278281e | |||
| 994db6c26a | |||
| 9f456cec9d | |||
| a95be39c17 | |||
| c6bb21d9ee |
@ -1,3 +1,9 @@
|
|||||||
|
Dockerfile
|
||||||
|
.github
|
||||||
|
.vscode
|
||||||
|
*.md
|
||||||
|
|
||||||
|
#### gitignore below
|
||||||
# Nuxt dev/build outputs
|
# Nuxt dev/build outputs
|
||||||
.output
|
.output
|
||||||
.data
|
.data
|
||||||
@ -8,6 +14,7 @@ dist
|
|||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules
|
node_modules
|
||||||
|
.yarn
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@ -24,3 +31,13 @@ logs
|
|||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
.data
|
.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"
|
DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
|
||||||
|
|
||||||
CLIENT_CERTIFICATES="./.data/ca"
|
|
||||||
|
|
||||||
FS_BACKEND_PATH="./.data/objects"
|
|
||||||
|
|
||||||
GIANT_BOMB_API_KEY=""
|
GIANT_BOMB_API_KEY=""
|
||||||
|
|
||||||
|
EXTERNAL_URL="http://localhost:3000"
|
||||||
|
|||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -2,3 +2,4 @@
|
|||||||
/.yarn/releases/* binary
|
/.yarn/releases/* binary
|
||||||
/.yarn/plugins/**/* binary
|
/.yarn/plugins/**/* binary
|
||||||
/.pnp.* binary linguist-generated
|
/.pnp.* binary linguist-generated
|
||||||
|
* text=auto eol=lf
|
||||||
|
|||||||
61
.github/workflows/ci.yml
vendored
Normal file
61
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
typecheck:
|
||||||
|
name: Typecheck
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm run typecheck
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js environment
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm run lint
|
||||||
109
.github/workflows/release.yml
vendored
Normal file
109
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
name: Release Workflow
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: {}
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
# This can be used to automatically publish nightlies at UTC nighttime
|
||||||
|
schedule:
|
||||||
|
- cron: "0 2 * * *" # run at 2 AM UTC
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
web:
|
||||||
|
name: Push website Docker image to registry
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
buildkitd-flags: --debug
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
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}}
|
||||||
|
type=ref,event=branch,prefix=branch-
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha
|
||||||
|
# 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 }}
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -30,4 +30,8 @@ logs
|
|||||||
# deploy template
|
# deploy template
|
||||||
deploy-template/*
|
deploy-template/*
|
||||||
|
|
||||||
!deploy-template/compose.yml
|
!deploy-template/compose.yml
|
||||||
|
|
||||||
|
# generated prisma client
|
||||||
|
/prisma/client
|
||||||
|
/prisma/validate
|
||||||
@ -29,3 +29,26 @@ build:
|
|||||||
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
|
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
|
||||||
docker push $PUBLISH_IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
|
docker push $PUBLISH_IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
build-arm64:
|
||||||
|
stage: build
|
||||||
|
image: arm64v8/docker:latest
|
||||||
|
tags:
|
||||||
|
- aarch64
|
||||||
|
variables:
|
||||||
|
IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHORT_SHA-arm64
|
||||||
|
LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE/$CI_COMMIT_REF_NAME:latest-arm64
|
||||||
|
PUBLISH_IMAGE_NAME: $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG-arm64
|
||||||
|
PUBLISH_LATEST_IMAGE_NAME: $CI_REGISTRY_IMAGE:latest-arm64
|
||||||
|
script:
|
||||||
|
- docker build -t $IMAGE_NAME . --platform=linux/arm64
|
||||||
|
- docker image tag $IMAGE_NAME $LATEST_IMAGE_NAME
|
||||||
|
- docker push $IMAGE_NAME
|
||||||
|
- docker push $LATEST_IMAGE_NAME
|
||||||
|
- |
|
||||||
|
if [ $CI_COMMIT_TAG ]; then
|
||||||
|
docker image tag $IMAGE_NAME $PUBLISH_IMAGE_NAME
|
||||||
|
docker image tag $IMAGE_NAME $PUBLISH_LATEST_IMAGE_NAME
|
||||||
|
docker push $PUBLISH_IMAGE_NAME
|
||||||
|
docker push $PUBLISH_LATEST_IMAGE_NAME
|
||||||
|
fi
|
||||||
|
|||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@ -0,0 +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"
|
||||||
|
]
|
||||||
|
}
|
||||||
51
.vscode/settings.json
vendored
51
.vscode/settings.json
vendored
@ -1,18 +1,37 @@
|
|||||||
{
|
{
|
||||||
"spellchecker.ignoreWordsList": [
|
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
|
||||||
"mTLS",
|
"sqltools.connections": [
|
||||||
"Wireguard"
|
{
|
||||||
],
|
"previewLimit": 50,
|
||||||
"sqltools.connections": [
|
"server": "localhost",
|
||||||
{
|
"port": 5432,
|
||||||
"previewLimit": 50,
|
"driver": "PostgreSQL",
|
||||||
"server": "localhost",
|
"name": "drop",
|
||||||
"port": 5432,
|
"database": "drop",
|
||||||
"driver": "PostgreSQL",
|
"username": "drop",
|
||||||
"name": "drop",
|
"password": "drop"
|
||||||
"database": "drop",
|
}
|
||||||
"username": "drop",
|
],
|
||||||
"password": "drop"
|
// allow autocomplete for ArkType expressions like "string | num"
|
||||||
}
|
"editor.quickSuggestions": {
|
||||||
]
|
"strings": "on"
|
||||||
|
},
|
||||||
|
// prioritize ArkType's "type" for autoimports
|
||||||
|
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"],
|
||||||
|
// i18n Ally settings
|
||||||
|
"i18n-ally.sortKeys": true,
|
||||||
|
"i18n-ally.keepFulfilled": true,
|
||||||
|
"i18n-ally.extract.autoDetect": true,
|
||||||
|
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
|
||||||
|
"i18n-ally.keystyle": "nested",
|
||||||
|
"i18n-ally.extract.ignored": [
|
||||||
|
"string >= 14",
|
||||||
|
"string.alphanumeric >= 5",
|
||||||
|
"/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}"
|
||||||
|
],
|
||||||
|
"i18n-ally.extract.ignoredByFiles": {
|
||||||
|
"pages/admin/library/sources/index.vue": ["Filesystem"],
|
||||||
|
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
|
||||||
|
"server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
.yarnrc
1
.yarnrc
@ -1 +0,0 @@
|
|||||||
"@drop:registry" "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/"
|
|
||||||
236
CONTRIBUTING.md
236
CONTRIBUTING.md
@ -1,235 +1,3 @@
|
|||||||
# CONTRIBUTING GUIDELINES
|
# Contributing
|
||||||
|
|
||||||
Drop is a community-driven project. Contribution is welcome, encouraged, and appreciated.
|
Check out our contributing guidelines on our developer docs: [https://developer.droposs.org/contributing](https://developer.droposs.org/contributing).
|
||||||
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.
|
|
||||||
|
|||||||
77
Dockerfile
77
Dockerfile
@ -1,28 +1,61 @@
|
|||||||
# pull pre-configured and updated build environment
|
# syntax=docker/dockerfile:1
|
||||||
FROM registry.deepcore.dev/drop-oss/drop-server-build-environment/main:latest AS build-system
|
|
||||||
|
|
||||||
# setup workdir
|
FROM node:lts-alpine AS base
|
||||||
RUN mkdir /build
|
ENV PNPM_HOME="/pnpm"
|
||||||
WORKDIR /build
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
||||||
# install dependencies and build
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
COPY . .
|
|
||||||
RUN NUXT_TELEMETRY_DISABLED=1 yarn install
|
|
||||||
RUN NUXT_TELEMETRY_DISABLED=1 yarn build
|
|
||||||
|
|
||||||
# create run environment for Drop
|
|
||||||
FROM node:lts-slim AS run-system
|
|
||||||
|
|
||||||
RUN mkdir /app
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=build-system /build/.output ./app
|
# so corepack knows pnpm's version
|
||||||
COPY --from=build-system /build/prisma ./prisma
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY --from=build-system /build/build ./startup
|
# 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
|
||||||
|
|
||||||
# OpenSSL as a dependency for Drop (TODO: seperate build environment)
|
### Unified deps builder
|
||||||
RUN apt-get update -y && apt-get install -y openssl
|
FROM base AS deps
|
||||||
RUN yarn global add prisma
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
CMD ["/app/startup/launch.sh"]
|
### 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/build ./startup
|
||||||
|
|
||||||
|
ENV LIBRARY="/library"
|
||||||
|
ENV DATA="/data"
|
||||||
|
|
||||||
|
CMD ["sh", "/app/startup/launch.sh"]
|
||||||
|
|||||||
88
README.md
88
README.md
@ -1,79 +1,37 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://raw.githubusercontent.com/Drop-OSS/media-sources/refs/heads/main/drop.svg" width="400rem"/>
|
<img src="https://raw.githubusercontent.com/Drop-OSS/media-sources/refs/heads/main/drop.svg" width="200rem"/>
|
||||||
</div>
|
</div>
|
||||||
<div align="center">
|
<br/>
|
||||||
<a href="CONTRIBUTING.md">Contribution guide</a>
|
|
||||||
<a href="https://deepcore.dev">Our website</a>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
[](LICENSE)
|
|
||||||
[](https://lab.deepcore.dev/drop-oss/drop/-/pipelines)
|
|
||||||
[](https://discord.gg/ZVGggfXN)
|
|
||||||
[](https://conventionalcommits.org)
|
|
||||||
|
|
||||||
# Drop
|
# 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.
|
[](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, 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
|
## Philosophy
|
||||||
|
|
||||||
1. Drop is flexible. While abstractions and interfaces can make the codebase more complicated, the flexibility is worth 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 a username/password to SSO.
|
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 complexity available to the users who want it.
|
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
|
## Deployment
|
||||||
|
|
||||||
To just deploy Drop, we've set up a simple docker compose file in deploy-template.
|
See our documentation on how to [deploy Drop](https://docs.droposs.org/docs/guides/quickstart) for more information.
|
||||||
|
|
||||||
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. Copy the `.env.example` to `.env` and add your GiantBomb metadata key (more metadata providers coming)
|
|
||||||
2. Create the `.data` directory with `mkdir .data`
|
|
||||||
3. Ensure that your user owns the `.data` directory with `sudo chown -R $(id -u $(whoami))`
|
|
||||||
4. Open up a terminal and navigate to `dev-tools`, and run `docker compose up`
|
|
||||||
5. 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/register?id=admin
|
|
||||||
|
|
||||||
## Contributing
|
## 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/)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
To report a vulnerability, please DO NOT create an issue for it
|
To report a vulnerability, please DO NOT create an issue for it
|
||||||
as this may lead to the vulnerability being exploited before it
|
as this may lead to the vulnerability being exploited before it
|
||||||
can be fixed. Instead, please email [security@deepcore.dev](mailto:security@deepcore.dev)
|
can be fixed. Instead, please email [security@deepcore.dev](mailto:security@deepcore.dev)
|
||||||
|
|||||||
55
app.vue
55
app.vue
@ -1,10 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<NuxtLoadingIndicator color="#2563eb" />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
<ModalStack />
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { XMarkIcon } from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
await updateUser();
|
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>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* You can customise the default animation here. */
|
||||||
|
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
$motiva: (
|
$motiva: (
|
||||||
("MotivaSansThin.ttf", "ttf", 100, normal),
|
("MotivaSansThin.ttf", "ttf", 100, normal),
|
||||||
("MotivaSansLight.woff.ttf", "woff", 300, normal),
|
("MotivaSansLight.woff.ttf", "woff", 300, normal),
|
||||||
@ -66,4 +62,16 @@ $helvetica: (
|
|||||||
}
|
}
|
||||||
.carousel__pagination-button--active:hover::after {
|
.carousel__pagination-button--active:hover::after {
|
||||||
background-color: #d4d4d8;
|
background-color: #d4d4d8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.store-carousel > .carousel__viewport {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: oklch(0.21 0.006 285.885);
|
||||||
|
}
|
||||||
|
|||||||
13
assets/tailwindcss.css
Normal file
13
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,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# This file starts up the Drop server by running migrations and then starting the executable
|
# This file starts up the Drop server by running migrations and then starting the executable
|
||||||
prisma migrate deploy
|
echo "[Drop] performing migrations..."
|
||||||
|
pnpm prisma migrate deploy
|
||||||
|
|
||||||
# Actually start the application
|
# Actually start the application
|
||||||
node /app/app/server/index.mjs
|
node /app/app/server/index.mjs
|
||||||
|
|||||||
390
changelog.md
390
changelog.md
@ -1,8 +1,391 @@
|
|||||||
|
## Release 0.2.0-beta
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- fix recursive dirs util #02d6346
|
||||||
|
- Fix username length requirement #0a5a649
|
||||||
|
- remove dynamic imports #0f10626
|
||||||
|
- fix for missing developers or publishers #25fc957
|
||||||
|
- split prisma schemas #2859005
|
||||||
|
- results are returned alphabetically #33d3770
|
||||||
|
- update prisma schemas #36776cc
|
||||||
|
- removed global flag #43e32b4
|
||||||
|
- properly disconnect websockets from task handler #5358f1f
|
||||||
|
- follow best practices #54c5d55
|
||||||
|
- future lenience #5c78b20
|
||||||
|
- fix width of token breaking things #61d88c3
|
||||||
|
- fixed websocket authentication #62ea9a1
|
||||||
|
- fix delta manifest generation #6df560c
|
||||||
|
- admin invitation w/ system user #8463e35
|
||||||
|
- properly import icons #8945196
|
||||||
|
- prisma create footprint #952ece8
|
||||||
|
- game panel now always shows 3 lines exactly #9c2249e
|
||||||
|
- remove unnecessary import #a361c38
|
||||||
|
- fix disconnect code #a8f2106
|
||||||
|
- fix types #b511b40
|
||||||
|
- add drop-base as git submodule #b75ebd1
|
||||||
|
- Update README.md with discord link #c6bb21d
|
||||||
|
- fix expires requirement in the admin endpoint #c7b675f
|
||||||
|
- fix always being created as admin #c7eb11a
|
||||||
|
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
|
||||||
|
- recurse submodules #db103de
|
||||||
|
- fix FATAL: "root"... message #dbb315a
|
||||||
|
- only show versions that are directories #ef8f3ae
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- update prisma & delete games #089c3e0
|
||||||
|
- manual handshake #12e3125
|
||||||
|
- fetch game endpoint #1f4d075
|
||||||
|
- under the hood organisation and consolidation #26a31f6
|
||||||
|
- 'no images' slide on image carousel #28baabc
|
||||||
|
- improve feedback when metadata fails #2c19e13
|
||||||
|
- introduction of 'system user' #2c21a23
|
||||||
|
- change name, description and icon #2cfe75a
|
||||||
|
- 'manual' metadata provider #2f52a16
|
||||||
|
- add disabled state #38fc6b8
|
||||||
|
- overhauled version importing #39d7ce7
|
||||||
|
- automatically create library folder if it doesn't exist #39fe9d5
|
||||||
|
- smoother bar in admin task ui #4488ae2
|
||||||
|
- add noWrapper option #4f9b949
|
||||||
|
- add version metadata route #5393db3
|
||||||
|
- completed admin UI, with minor changes to backend #599da0e
|
||||||
|
- adjust gradient #5a1f841
|
||||||
|
- keep track of last connected #69e4c25
|
||||||
|
- added notification system w/ interwoven refactoring #6e6f09d
|
||||||
|
- content length header for chunk downloads #76bceb1
|
||||||
|
- add title to tab #7b0756c
|
||||||
|
- add button to open in admin panel #7b3b919
|
||||||
|
- client capability framework + peer API configuration #7d72a86
|
||||||
|
- customisable image carousel and new layout #937954f
|
||||||
|
- support more types #9b12d45
|
||||||
|
- generate a server certificate for mtls APIs #9c4b6f3
|
||||||
|
- new endpoints, ui and beginnings of main store page #9cbdcbc
|
||||||
|
- backend #a309651
|
||||||
|
- more subtle design improvements #a815542
|
||||||
|
- add aden's carousel pagination design #a86045c
|
||||||
|
- add header #a8a152e
|
||||||
|
- client side search #b50e27f
|
||||||
|
- new ws handler #bc0c47c
|
||||||
|
- user widget now redirects to actual page #bfafe02
|
||||||
|
- require lowercase usernames #d7160ab
|
||||||
|
- more ui improvements #e408ac5
|
||||||
|
- add modifying game descriptions #e505e58
|
||||||
|
- mobile nav #e5cf13f
|
||||||
|
- slightly improved game page #e796b46
|
||||||
|
- game carousel #ecc819e
|
||||||
|
- add enum dictionary type #f2e0182
|
||||||
|
- improved ux #f3ed0f6
|
||||||
|
- cleanup and raw accessors #f7d767d
|
||||||
|
- add support for overriding UMU id #fd4a7d1
|
||||||
|
- add .sh for linux #fe9373a
|
||||||
|
|
||||||
|
### Other Changes
|
||||||
|
|
||||||
|
- quexeky <git@quexeky.dev>
|
||||||
|
- fixed manifest generation #03a37f7
|
||||||
|
- manual ci/cd #03b0b0c
|
||||||
|
- ability to fetch client certs for p2p #0a715fe
|
||||||
|
- disable tls in build #0f80fcd
|
||||||
|
- Updated README.md #17971e0
|
||||||
|
- Merge pull request #18 from Drop-OSS/develop
|
||||||
|
- initial work on metadata system #196f87c
|
||||||
|
- more ui #1bd19ad
|
||||||
|
- remove log statements #1d5e1bd
|
||||||
|
- small fixes & SSR disabled #1f575b2
|
||||||
|
- update information and setup guide #2236622
|
||||||
|
- metadata engine #22ac7f6
|
||||||
|
- Update CONTRIBUTING.md #2309407
|
||||||
|
- slight bug fixes and clean up #24a0d11
|
||||||
|
- almst complete admin ui and initial store designs #27070b6
|
||||||
|
- handshakes #2b4382d
|
||||||
|
- user mobile header #2e44ef3
|
||||||
|
- more consistent naming for globals #305de9f
|
||||||
|
- replaced markdown-it with micromark #31e8359
|
||||||
|
- fixes to store page for mobile clients #328b9ba
|
||||||
|
- game version re-ordering #329c74d
|
||||||
|
- verbose yarn install #36568c3
|
||||||
|
- patch for no version check in manifest generation #395219d
|
||||||
|
- migrate bcrypt to bcryptjs #3a51c9c
|
||||||
|
- added download chunk endpoint #3dd6062
|
||||||
|
- Update README.md #425934d
|
||||||
|
- build only ci #4273a20
|
||||||
|
- object storage + full permission system + testing #435551c
|
||||||
|
- rename admin socket session map #44c6028
|
||||||
|
- bump droplet and add vue carousel #46551f9
|
||||||
|
- version importing #46c8f0c
|
||||||
|
- back to yarn, with nuxt telemetry force disabled #46d35ad
|
||||||
|
- finished object endpoints #486bce8
|
||||||
|
- update dependencies and add note about optional dependencies #4fa771a
|
||||||
|
- use configuration from docs for ci/cd #52315d0
|
||||||
|
- slight fixes to register logic #583301f
|
||||||
|
- removed yarn.lock #584bcf1
|
||||||
|
- Version bump #5f29c28
|
||||||
|
- immutable application settings framework #5fe2036
|
||||||
|
- fixed docker daemon location #62a111b
|
||||||
|
- copy autodevops configuration #6328c24
|
||||||
|
- Delete .gitlab-ci.yml #69f341b
|
||||||
|
- admin ui shell #6b5e48d
|
||||||
|
- bump @drop/droplet version for windows developers #6ba5cdd
|
||||||
|
- Add LICENSE #6e2dc89
|
||||||
|
- custom dind #716eac7
|
||||||
|
- task API #718f5ba
|
||||||
|
- use gitlab ci variable declaration #7194d35
|
||||||
|
- move icons into dedicated folder #74fa671
|
||||||
|
- another stage of client authentication #7523e53
|
||||||
|
- refactoring #7869043
|
||||||
|
- moved windows logo into logos dir #789d3ba
|
||||||
|
- updated text colours across app #7a88f4c
|
||||||
|
- starting docs infra #7d2a1c6
|
||||||
|
- more cleaning #7e17626
|
||||||
|
- slight patch to rename query to be more consistent #7f4db0c
|
||||||
|
- move to raw docker #803752e
|
||||||
|
- server side and user client side completed for registration #848a611
|
||||||
|
- beginnings of download implementation #8674ac7
|
||||||
|
- more consistent naming for object handler #87230fb
|
||||||
|
- use autodevops build stage #886beb6
|
||||||
|
- Updated tailwind config #88c95d6
|
||||||
|
- change name of store file #8999303
|
||||||
|
- split prisma schemas #9011cf5
|
||||||
|
- client initiate #909432a
|
||||||
|
- more client routes to support Drop app update #91b7e10
|
||||||
|
- additional polish and QoL features #93bc143
|
||||||
|
- upload images to games #9b7ee4e
|
||||||
|
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
|
||||||
|
- run yarn install in CI/CD non interactively #a208fbe
|
||||||
|
- completed game importing; partial work on version importing #a7c33e7
|
||||||
|
- remove canvas from dependencies #a8f58eb
|
||||||
|
- fix registry authentication #ad25d3e
|
||||||
|
- consolidate type utils #adb4b73
|
||||||
|
- Updated README.md #b0ef675
|
||||||
|
- add proper carousel to store page #b2ab827
|
||||||
|
- move to yarn v2 #b744671
|
||||||
|
- remove client API deadweight #b9ae26c
|
||||||
|
- add expires field #be6c30d
|
||||||
|
- ca groundwork #bfafd2a
|
||||||
|
- cleanup & polish #c355f6f
|
||||||
|
- remove bcrypt (debug) #c3914cc
|
||||||
|
- non rounded bottom #c4391d3
|
||||||
|
- failed gracefully on invalid chunk index #c4a3e4e
|
||||||
|
- update deploy template #c4a419f
|
||||||
|
- migrate to new droplet ca system #c4d8113
|
||||||
|
- docker based deployment #c5d00b4
|
||||||
|
- updated CONTRIBUTING.md #cd0d2bf
|
||||||
|
- update prisma version #ce0a9ab
|
||||||
|
- README update #ceacd84
|
||||||
|
- patch metadata handler #cf578bd
|
||||||
|
- Added SECURITY.md #d3d93b0
|
||||||
|
- finalised client APIs and authentication method #d4e2dc8
|
||||||
|
- Update README.md #db916bf
|
||||||
|
- object storage interface + utility functions #de388a9
|
||||||
|
- initial commit #e1a789f
|
||||||
|
- fixed task system #e1c1d7e
|
||||||
|
- Update file chunk.get.ts #e4339c3
|
||||||
|
- ui groundwork #e52f072
|
||||||
|
- Update changelog #eadcaa1
|
||||||
|
- check for no version in manifest generation #eb3f9f9
|
||||||
|
- break into single column store on lg devices #ecb381e
|
||||||
|
- better server side signin redirects #ef13b68
|
||||||
|
- patch signin #f3672f8
|
||||||
|
|
||||||
|
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
|
||||||
|
|
||||||
|
## Release 0.2.0-beta
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- fix recursive dirs util #02d6346
|
||||||
|
- Fix username length requirement #0a5a649
|
||||||
|
- remove dynamic imports #0f10626
|
||||||
|
- fix for missing developers or publishers #25fc957
|
||||||
|
- split prisma schemas #2859005
|
||||||
|
- results are returned alphabetically #33d3770
|
||||||
|
- update prisma schemas #36776cc
|
||||||
|
- removed global flag #43e32b4
|
||||||
|
- properly disconnect websockets from task handler #5358f1f
|
||||||
|
- follow best practices #54c5d55
|
||||||
|
- future lenience #5c78b20
|
||||||
|
- fix width of token breaking things #61d88c3
|
||||||
|
- fixed websocket authentication #62ea9a1
|
||||||
|
- fix delta manifest generation #6df560c
|
||||||
|
- admin invitation w/ system user #8463e35
|
||||||
|
- properly import icons #8945196
|
||||||
|
- prisma create footprint #952ece8
|
||||||
|
- game panel now always shows 3 lines exactly #9c2249e
|
||||||
|
- remove unnecessary import #a361c38
|
||||||
|
- fix disconnect code #a8f2106
|
||||||
|
- fix types #b511b40
|
||||||
|
- add drop-base as git submodule #b75ebd1
|
||||||
|
- Update README.md with discord link #c6bb21d
|
||||||
|
- fix expires requirement in the admin endpoint #c7b675f
|
||||||
|
- fix always being created as admin #c7eb11a
|
||||||
|
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
|
||||||
|
- recurse submodules #db103de
|
||||||
|
- fix FATAL: "root"... message #dbb315a
|
||||||
|
- only show versions that are directories #ef8f3ae
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- update prisma & delete games #089c3e0
|
||||||
|
- manual handshake #12e3125
|
||||||
|
- fetch game endpoint #1f4d075
|
||||||
|
- under the hood organisation and consolidation #26a31f6
|
||||||
|
- 'no images' slide on image carousel #28baabc
|
||||||
|
- improve feedback when metadata fails #2c19e13
|
||||||
|
- introduction of 'system user' #2c21a23
|
||||||
|
- change name, description and icon #2cfe75a
|
||||||
|
- 'manual' metadata provider #2f52a16
|
||||||
|
- add disabled state #38fc6b8
|
||||||
|
- overhauled version importing #39d7ce7
|
||||||
|
- automatically create library folder if it doesn't exist #39fe9d5
|
||||||
|
- smoother bar in admin task ui #4488ae2
|
||||||
|
- add noWrapper option #4f9b949
|
||||||
|
- add version metadata route #5393db3
|
||||||
|
- completed admin UI, with minor changes to backend #599da0e
|
||||||
|
- adjust gradient #5a1f841
|
||||||
|
- keep track of last connected #69e4c25
|
||||||
|
- added notification system w/ interwoven refactoring #6e6f09d
|
||||||
|
- content length header for chunk downloads #76bceb1
|
||||||
|
- add title to tab #7b0756c
|
||||||
|
- add button to open in admin panel #7b3b919
|
||||||
|
- client capability framework + peer API configuration #7d72a86
|
||||||
|
- customisable image carousel and new layout #937954f
|
||||||
|
- support more types #9b12d45
|
||||||
|
- generate a server certificate for mtls APIs #9c4b6f3
|
||||||
|
- new endpoints, ui and beginnings of main store page #9cbdcbc
|
||||||
|
- backend #a309651
|
||||||
|
- more subtle design improvements #a815542
|
||||||
|
- add aden's carousel pagination design #a86045c
|
||||||
|
- add header #a8a152e
|
||||||
|
- client side search #b50e27f
|
||||||
|
- new ws handler #bc0c47c
|
||||||
|
- user widget now redirects to actual page #bfafe02
|
||||||
|
- require lowercase usernames #d7160ab
|
||||||
|
- more ui improvements #e408ac5
|
||||||
|
- add modifying game descriptions #e505e58
|
||||||
|
- mobile nav #e5cf13f
|
||||||
|
- slightly improved game page #e796b46
|
||||||
|
- game carousel #ecc819e
|
||||||
|
- add enum dictionary type #f2e0182
|
||||||
|
- improved ux #f3ed0f6
|
||||||
|
- cleanup and raw accessors #f7d767d
|
||||||
|
- add support for overriding UMU id #fd4a7d1
|
||||||
|
- add .sh for linux #fe9373a
|
||||||
|
|
||||||
|
### Other Changes
|
||||||
|
|
||||||
|
- quexeky <git@quexeky.dev>
|
||||||
|
- fixed manifest generation #03a37f7
|
||||||
|
- manual ci/cd #03b0b0c
|
||||||
|
- ability to fetch client certs for p2p #0a715fe
|
||||||
|
- disable tls in build #0f80fcd
|
||||||
|
- Updated README.md #17971e0
|
||||||
|
- Merge pull request #18 from Drop-OSS/develop
|
||||||
|
- initial work on metadata system #196f87c
|
||||||
|
- more ui #1bd19ad
|
||||||
|
- remove log statements #1d5e1bd
|
||||||
|
- small fixes & SSR disabled #1f575b2
|
||||||
|
- update information and setup guide #2236622
|
||||||
|
- metadata engine #22ac7f6
|
||||||
|
- Update CONTRIBUTING.md #2309407
|
||||||
|
- slight bug fixes and clean up #24a0d11
|
||||||
|
- almst complete admin ui and initial store designs #27070b6
|
||||||
|
- handshakes #2b4382d
|
||||||
|
- user mobile header #2e44ef3
|
||||||
|
- more consistent naming for globals #305de9f
|
||||||
|
- replaced markdown-it with micromark #31e8359
|
||||||
|
- fixes to store page for mobile clients #328b9ba
|
||||||
|
- game version re-ordering #329c74d
|
||||||
|
- verbose yarn install #36568c3
|
||||||
|
- patch for no version check in manifest generation #395219d
|
||||||
|
- migrate bcrypt to bcryptjs #3a51c9c
|
||||||
|
- added download chunk endpoint #3dd6062
|
||||||
|
- Update README.md #425934d
|
||||||
|
- build only ci #4273a20
|
||||||
|
- object storage + full permission system + testing #435551c
|
||||||
|
- rename admin socket session map #44c6028
|
||||||
|
- bump droplet and add vue carousel #46551f9
|
||||||
|
- version importing #46c8f0c
|
||||||
|
- back to yarn, with nuxt telemetry force disabled #46d35ad
|
||||||
|
- finished object endpoints #486bce8
|
||||||
|
- update dependencies and add note about optional dependencies #4fa771a
|
||||||
|
- use configuration from docs for ci/cd #52315d0
|
||||||
|
- slight fixes to register logic #583301f
|
||||||
|
- removed yarn.lock #584bcf1
|
||||||
|
- Version bump #5f29c28
|
||||||
|
- immutable application settings framework #5fe2036
|
||||||
|
- fixed docker daemon location #62a111b
|
||||||
|
- copy autodevops configuration #6328c24
|
||||||
|
- Delete .gitlab-ci.yml #69f341b
|
||||||
|
- admin ui shell #6b5e48d
|
||||||
|
- bump @drop/droplet version for windows developers #6ba5cdd
|
||||||
|
- Add LICENSE #6e2dc89
|
||||||
|
- custom dind #716eac7
|
||||||
|
- task API #718f5ba
|
||||||
|
- use gitlab ci variable declaration #7194d35
|
||||||
|
- move icons into dedicated folder #74fa671
|
||||||
|
- another stage of client authentication #7523e53
|
||||||
|
- refactoring #7869043
|
||||||
|
- moved windows logo into logos dir #789d3ba
|
||||||
|
- updated text colours across app #7a88f4c
|
||||||
|
- starting docs infra #7d2a1c6
|
||||||
|
- more cleaning #7e17626
|
||||||
|
- slight patch to rename query to be more consistent #7f4db0c
|
||||||
|
- move to raw docker #803752e
|
||||||
|
- server side and user client side completed for registration #848a611
|
||||||
|
- beginnings of download implementation #8674ac7
|
||||||
|
- more consistent naming for object handler #87230fb
|
||||||
|
- use autodevops build stage #886beb6
|
||||||
|
- Updated tailwind config #88c95d6
|
||||||
|
- change name of store file #8999303
|
||||||
|
- split prisma schemas #9011cf5
|
||||||
|
- client initiate #909432a
|
||||||
|
- more client routes to support Drop app update #91b7e10
|
||||||
|
- additional polish and QoL features #93bc143
|
||||||
|
- upload images to games #9b7ee4e
|
||||||
|
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
|
||||||
|
- run yarn install in CI/CD non interactively #a208fbe
|
||||||
|
- completed game importing; partial work on version importing #a7c33e7
|
||||||
|
- remove canvas from dependencies #a8f58eb
|
||||||
|
- fix registry authentication #ad25d3e
|
||||||
|
- consolidate type utils #adb4b73
|
||||||
|
- Updated README.md #b0ef675
|
||||||
|
- add proper carousel to store page #b2ab827
|
||||||
|
- move to yarn v2 #b744671
|
||||||
|
- remove client API deadweight #b9ae26c
|
||||||
|
- add expires field #be6c30d
|
||||||
|
- ca groundwork #bfafd2a
|
||||||
|
- cleanup & polish #c355f6f
|
||||||
|
- remove bcrypt (debug) #c3914cc
|
||||||
|
- non rounded bottom #c4391d3
|
||||||
|
- failed gracefully on invalid chunk index #c4a3e4e
|
||||||
|
- update deploy template #c4a419f
|
||||||
|
- migrate to new droplet ca system #c4d8113
|
||||||
|
- docker based deployment #c5d00b4
|
||||||
|
- updated CONTRIBUTING.md #cd0d2bf
|
||||||
|
- update prisma version #ce0a9ab
|
||||||
|
- README update #ceacd84
|
||||||
|
- patch metadata handler #cf578bd
|
||||||
|
- Added SECURITY.md #d3d93b0
|
||||||
|
- finalised client APIs and authentication method #d4e2dc8
|
||||||
|
- Update README.md #db916bf
|
||||||
|
- object storage interface + utility functions #de388a9
|
||||||
|
- initial commit #e1a789f
|
||||||
|
- fixed task system #e1c1d7e
|
||||||
|
- Update file chunk.get.ts #e4339c3
|
||||||
|
- ui groundwork #e52f072
|
||||||
|
- Update changelog #eadcaa1
|
||||||
|
- check for no version in manifest generation #eb3f9f9
|
||||||
|
- break into single column store on lg devices #ecb381e
|
||||||
|
- better server side signin redirects #ef13b68
|
||||||
|
- patch signin #f3672f8
|
||||||
|
|
||||||
|
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
|
||||||
|
|
||||||
## Release 0.1.0-beta
|
## Release 0.1.0-beta
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- remove dynamic imports #0f10626
|
- remove dynamic imports #0f10626
|
||||||
- fix for missing developers or publishers #25fc957
|
- fix for missing developers or publishers #25fc957
|
||||||
- split prisma schemas #2859005
|
- split prisma schemas #2859005
|
||||||
@ -22,8 +405,8 @@
|
|||||||
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
|
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
|
||||||
- only show versions that are directories #ef8f3ae
|
- only show versions that are directories #ef8f3ae
|
||||||
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- update prisma & delete games #089c3e0
|
- update prisma & delete games #089c3e0
|
||||||
- fetch game endpoint #1f4d075
|
- fetch game endpoint #1f4d075
|
||||||
- under the hood organisation and consolidation #26a31f6
|
- under the hood organisation and consolidation #26a31f6
|
||||||
@ -53,9 +436,9 @@
|
|||||||
- cleanup and raw accessors #f7d767d
|
- cleanup and raw accessors #f7d767d
|
||||||
- add support for overriding UMU id #fd4a7d1
|
- add support for overriding UMU id #fd4a7d1
|
||||||
|
|
||||||
|
|
||||||
### Other Changes
|
### Other Changes
|
||||||
- quexeky <git@quexeky.dev>
|
|
||||||
|
- quexeky <git@quexeky.dev>
|
||||||
- fixed manifest generation #03a37f7
|
- fixed manifest generation #03a37f7
|
||||||
- manual ci/cd #03b0b0c
|
- manual ci/cd #03b0b0c
|
||||||
- ability to fetch client certs for p2p #0a715fe
|
- ability to fetch client certs for p2p #0a715fe
|
||||||
@ -158,5 +541,4 @@
|
|||||||
- better server side signin redirects #ef13b68
|
- better server side signin redirects #ef13b68
|
||||||
- patch signin #f3672f8
|
- patch signin #f3672f8
|
||||||
|
|
||||||
|
|
||||||
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
|
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
|
||||||
|
|||||||
92
components/AccountSidebar.vue
Normal file
92
components/AccountSidebar.vue
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4">
|
||||||
|
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
|
||||||
|
<UserIcon class="size-5" /> {{ $t("account.title") }}
|
||||||
|
</span>
|
||||||
|
<nav class="flex flex-1 flex-col">
|
||||||
|
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||||
|
<li>
|
||||||
|
<ul role="list" class="-mx-2 space-y-1">
|
||||||
|
<li v-for="(item, itemIdx) in navigation" :key="item.route">
|
||||||
|
<NuxtLink
|
||||||
|
:href="item.route"
|
||||||
|
:class="[
|
||||||
|
itemIdx == currentPageIndex
|
||||||
|
? 'bg-zinc-800 text-white'
|
||||||
|
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white',
|
||||||
|
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
class="size-6 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ item.label }}
|
||||||
|
<span
|
||||||
|
v-if="item.count !== undefined"
|
||||||
|
class="ml-auto w-9 min-w-max whitespace-nowrap rounded-full bg-zinc-900 px-2.5 py-0.5 text-center text-xs/5 font-medium text-white ring-1 ring-inset ring-zinc-700"
|
||||||
|
aria-hidden="true"
|
||||||
|
>{{ item.count }}</span
|
||||||
|
>
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
BellIcon,
|
||||||
|
HomeIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
DevicePhoneMobileIcon,
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
CodeBracketIcon,
|
||||||
|
} from "@heroicons/vue/24/outline";
|
||||||
|
import { UserIcon } from "@heroicons/vue/24/solid";
|
||||||
|
import type { Component } from "vue";
|
||||||
|
|
||||||
|
const notifications = useNotifications();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||||
|
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
|
||||||
|
{
|
||||||
|
label: t("security"),
|
||||||
|
route: "/account/security",
|
||||||
|
prefix: "/account/security",
|
||||||
|
icon: LockClosedIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("account.devices.title"),
|
||||||
|
route: "/account/devices",
|
||||||
|
prefix: "/account/devices",
|
||||||
|
icon: DevicePhoneMobileIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("account.notifications.notifications"),
|
||||||
|
route: "/account/notifications",
|
||||||
|
prefix: "/account/notifications",
|
||||||
|
icon: BellIcon,
|
||||||
|
count: notifications.value.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("account.token.title"),
|
||||||
|
route: "/account/tokens",
|
||||||
|
prefix: "/account/tokens",
|
||||||
|
icon: CodeBracketIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("account.settings"),
|
||||||
|
route: "/account/settings",
|
||||||
|
prefix: "/account/settings",
|
||||||
|
icon: WrenchScrewdriverIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentPageIndex = useCurrentNavigationIndex(navigation);
|
||||||
|
</script>
|
||||||
@ -1,24 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="inline-flex divide-x divide-zinc-900">
|
<div
|
||||||
<button
|
class="inline-flex w-full group hover:scale-105 transition-all duration-200"
|
||||||
type="button"
|
>
|
||||||
class="inline-flex justify-center items-center gap-x-2 rounded-l-md aspect-[7/2] px-3 py-2 bg-blue-600 grow 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"
|
<LoadingButton
|
||||||
|
:loading="isLibraryLoading"
|
||||||
|
:style="'none'"
|
||||||
|
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()"
|
||||||
>
|
>
|
||||||
Add to Library
|
{{ inLibrary ? $t("library.inLib") : $t("library.addToLib") }}
|
||||||
<PlusIcon class="-mr-0.5 size-6" aria-hidden="true" />
|
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||||
</button>
|
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||||
<Menu as="div" class="relative inline-block text-left grow">
|
</LoadingButton>
|
||||||
<div class="h-full">
|
|
||||||
<MenuButton
|
|
||||||
class="inline-flex h-full w-full justify-center items-center rounded-r-md bg-blue-600 p-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"
|
|
||||||
>
|
|
||||||
<ChevronDownIcon
|
|
||||||
class="size-5"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</MenuButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Collections dropdown -->
|
||||||
|
<Menu as="div" class="relative">
|
||||||
|
<MenuButton
|
||||||
|
as="div"
|
||||||
|
class="transition cursor-pointer inline-flex items-center rounded-r-md h-full ml-[2px] bg-white/10 hover:bg-white/20 backdrop-blur py-3.5 px-2 justify-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/20"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon class="h-5 w-5 text-white" aria-hidden="true" />
|
||||||
|
</MenuButton>
|
||||||
<transition
|
<transition
|
||||||
enter-active-class="transition ease-out duration-100"
|
enter-active-class="transition ease-out duration-100"
|
||||||
enter-from-class="transform opacity-0 scale-95"
|
enter-from-class="transform opacity-0 scale-95"
|
||||||
@ -28,68 +30,124 @@
|
|||||||
leave-to-class="transform opacity-0 scale-95"
|
leave-to-class="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<MenuItems
|
<MenuItems
|
||||||
class="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-none"
|
class="absolute right-0 z-50 mt-2 w-72 origin-top-right rounded-md bg-zinc-800/90 backdrop-blur shadow-lg focus:outline-none"
|
||||||
>
|
>
|
||||||
<div class="py-1">
|
<div class="p-2">
|
||||||
<MenuItem v-slot="{ active }">
|
<div
|
||||||
<a
|
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
|
||||||
href="#"
|
>
|
||||||
:class="[
|
{{ $t("library.collection.collections") }}
|
||||||
active
|
</div>
|
||||||
? 'bg-gray-100 text-gray-900 outline-none'
|
<div
|
||||||
: 'text-gray-700',
|
class="flex flex-col gap-y-2 py-1 max-h-[150px] overflow-y-auto"
|
||||||
'block px-4 py-2 text-sm',
|
>
|
||||||
]"
|
<div
|
||||||
>Account settings</a
|
v-if="collections.length === 0"
|
||||||
|
class="px-3 py-2 text-sm text-zinc-500"
|
||||||
>
|
>
|
||||||
</MenuItem>
|
{{ $t("library.collection.noCollections") }}
|
||||||
<MenuItem v-slot="{ active }">
|
</div>
|
||||||
<a
|
<MenuItem
|
||||||
href="#"
|
v-for="(collection, collectionIdx) in collections"
|
||||||
:class="[
|
:key="collection.id"
|
||||||
active
|
v-slot="{ active }"
|
||||||
? 'bg-gray-100 text-gray-900 outline-none'
|
|
||||||
: 'text-gray-700',
|
|
||||||
'block px-4 py-2 text-sm',
|
|
||||||
]"
|
|
||||||
>Support</a
|
|
||||||
>
|
>
|
||||||
</MenuItem>
|
|
||||||
<MenuItem v-slot="{ active }">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
:class="[
|
|
||||||
active
|
|
||||||
? 'bg-gray-100 text-gray-900 outline-none'
|
|
||||||
: 'text-gray-700',
|
|
||||||
'block px-4 py-2 text-sm',
|
|
||||||
]"
|
|
||||||
>License</a
|
|
||||||
>
|
|
||||||
</MenuItem>
|
|
||||||
<form method="POST" action="#">
|
|
||||||
<MenuItem v-slot="{ active }">
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
|
||||||
:class="[
|
:class="[
|
||||||
active
|
active ? 'bg-zinc-700/90' : '',
|
||||||
? 'bg-gray-100 text-gray-900 outline-none'
|
'group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-zinc-200',
|
||||||
: 'text-gray-700',
|
|
||||||
'block w-full px-4 py-2 text-left text-sm',
|
|
||||||
]"
|
]"
|
||||||
|
@click="() => toggleCollection(collection.id)"
|
||||||
>
|
>
|
||||||
Sign out
|
<span>{{ collection.name }}</span>
|
||||||
|
<CheckIcon
|
||||||
|
v-if="inCollections[collectionIdx]"
|
||||||
|
class="h-5 w-5 text-blue-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</form>
|
</div>
|
||||||
|
<div class="border-t border-zinc-700 pt-1">
|
||||||
|
<LoadingButton
|
||||||
|
:loading="false"
|
||||||
|
class="w-full"
|
||||||
|
@click="createCollectionModal = true"
|
||||||
|
>
|
||||||
|
<PlusIcon class="mr-2 h-4 w-4" />
|
||||||
|
{{ $t("library.collection.addToNew") }}
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MenuItems>
|
</MenuItems>
|
||||||
</transition>
|
</transition>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ModalCreateCollection
|
||||||
|
v-model="createCollectionModal"
|
||||||
|
:game-id="props.gameId"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
import { PlusIcon, ChevronDownIcon, CheckIcon } from "@heroicons/vue/24/solid";
|
||||||
import { ChevronDownIcon, PlusIcon } from "@heroicons/vue/20/solid";
|
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
gameId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isLibraryLoading = ref(false);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const createCollectionModal = ref(false);
|
||||||
|
const collections = await useCollections();
|
||||||
|
const library = await useLibrary();
|
||||||
|
|
||||||
|
const inLibrary = computed(
|
||||||
|
() => library.value.entries.findIndex((e) => e.gameId == props.gameId) != -1,
|
||||||
|
);
|
||||||
|
const inCollections = computed(() =>
|
||||||
|
collections.value.map(
|
||||||
|
(e) => e.entries.findIndex((e) => e.gameId == props.gameId) != -1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function toggleLibrary() {
|
||||||
|
isLibraryLoading.value = true;
|
||||||
|
try {
|
||||||
|
await $dropFetch("/api/v1/collection/default/entry", {
|
||||||
|
method: inLibrary.value ? "DELETE" : "POST",
|
||||||
|
body: {
|
||||||
|
id: props.gameId,
|
||||||
|
},
|
||||||
|
failTitle: t("errors.library.add.title"),
|
||||||
|
});
|
||||||
|
await refreshLibrary();
|
||||||
|
} finally {
|
||||||
|
isLibraryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCollection(id: string) {
|
||||||
|
try {
|
||||||
|
const collection = collections.value.find((e) => e.id == id);
|
||||||
|
if (!collection) return;
|
||||||
|
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
|
||||||
|
|
||||||
|
await $dropFetch(`/api/v1/collection/:id/entry`, {
|
||||||
|
method: index == -1 ? "POST" : "DELETE",
|
||||||
|
params: { id },
|
||||||
|
body: {
|
||||||
|
id: props.gameId,
|
||||||
|
},
|
||||||
|
failTitle: t("errors.library.add.title"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshCollection(id);
|
||||||
|
} finally {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
18
components/Auth/OpenID.vue
Normal file
18
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>
|
||||||
129
components/Auth/Simple.vue
Normal file
129
components/Auth/Simple.vue
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<form class="space-y-6" @submit.prevent="signin_wrapper">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="username"
|
||||||
|
class="block text-sm font-medium leading-6 text-zinc-300"
|
||||||
|
>{{ $t("auth.username") }}</label
|
||||||
|
>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
v-model="username"
|
||||||
|
name="username"
|
||||||
|
type="username"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset 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>
|
||||||
|
<label
|
||||||
|
for="password"
|
||||||
|
class="block text-sm font-medium leading-6 text-zinc-300"
|
||||||
|
>{{ $t("auth.password") }}</label
|
||||||
|
>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset 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 class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
v-model="rememberMe"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded bg-zinc-800 border-zinc-700 text-blue-600 focus:ring-blue-600"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="remember-me"
|
||||||
|
class="ml-3 block text-sm leading-6 text-zinc-400"
|
||||||
|
>{{ $t("auth.signin.rememberMe") }}</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm leading-6">
|
||||||
|
<NuxtLink
|
||||||
|
to="#"
|
||||||
|
class="font-semibold text-blue-600 hover:text-blue-500"
|
||||||
|
>{{ $t("auth.signin.forgot") }}</NuxtLink
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
<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">
|
||||||
|
{{ error }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||||
|
import type { UserModel } from "~/prisma/client/models";
|
||||||
|
|
||||||
|
const username = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const rememberMe = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const error = ref<string | undefined>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
function signin_wrapper() {
|
||||||
|
loading.value = true;
|
||||||
|
signin()
|
||||||
|
.then(() => {
|
||||||
|
router.push(route.query.redirect?.toString() ?? "/");
|
||||||
|
})
|
||||||
|
.catch((response) => {
|
||||||
|
const message = response.statusMessage || t("errors.unknown");
|
||||||
|
error.value = message;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signin() {
|
||||||
|
await $dropFetch("/api/v1/auth/signin/simple", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
username: username.value,
|
||||||
|
password: password.value,
|
||||||
|
rememberMe: rememberMe.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const user = useUser();
|
||||||
|
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -2,26 +2,25 @@
|
|||||||
<div class="flex flex-row flex-wrap gap-2 justify-center">
|
<div class="flex flex-row flex-wrap gap-2 justify-center">
|
||||||
<button
|
<button
|
||||||
v-for="(_, i) in amount"
|
v-for="(_, i) in amount"
|
||||||
@click="() => slideTo(i)"
|
:key="i"
|
||||||
|
@click="slideTo(i)"
|
||||||
:class="[
|
:class="[
|
||||||
currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
|
carousel.currentSlide === i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
|
||||||
'transition-all cursor-pointer h-2 rounded-full',
|
'transition-all cursor-pointer h-2 rounded-full'
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const maxSlide = inject("maxSlide", ref(1));
|
import { injectCarousel } from "vue3-carousel";
|
||||||
const minSlide = inject("minSlide", ref(1));
|
|
||||||
const currentSlide = inject("currentSlide", ref(1));
|
|
||||||
const nav: { slideTo?: (index: number) => any } = inject("nav", {});
|
|
||||||
|
|
||||||
const amount = computed(() => maxSlide.value - minSlide.value + 1);
|
const carousel = inject(injectCarousel)!;
|
||||||
|
|
||||||
|
const amount = carousel.maxSlide - carousel.minSlide + 1;
|
||||||
|
|
||||||
function slideTo(index: number) {
|
function slideTo(index: number) {
|
||||||
if (!nav.slideTo) return console.warn(`error moving slide: nav not defined`);
|
const offsetIndex = index + carousel.minSlide;
|
||||||
const offsetIndex = index + minSlide.value;
|
carousel.nav.slideTo(offsetIndex);
|
||||||
nav.slideTo(offsetIndex);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
75
components/Directory/Library.vue
Normal file
75
components/Directory/Library.vue
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
|
||||||
|
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
|
||||||
|
<Bars3Icon class="size-6" /> {{ $t("userHeader.links.library") }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Search bar -->
|
||||||
|
<div class="mt-5 relative">
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
autocomplete="off"
|
||||||
|
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
|
||||||
|
:placeholder="$t('library.search')"
|
||||||
|
/>
|
||||||
|
<MagnifyingGlassIcon
|
||||||
|
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransitionGroup
|
||||||
|
v-if="filteredLibrary.length > 0"
|
||||||
|
name="list"
|
||||||
|
tag="ul"
|
||||||
|
role="list"
|
||||||
|
class="mt-2 space-y-0.5"
|
||||||
|
>
|
||||||
|
<li v-for="game in filteredLibrary" :key="game.id" class="flex">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/library/game/${game.id}`"
|
||||||
|
class="flex flex-row items-center w-full p-2 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="useObject(game.mIconObjectId)"
|
||||||
|
class="h-5 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1 pl-2.5">
|
||||||
|
<p
|
||||||
|
class="text-sm font-semibold text-display text-zinc-200 truncate text-left"
|
||||||
|
>
|
||||||
|
{{ game.mName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
|
||||||
|
>
|
||||||
|
{{ !!searchQuery ? $t("common.noResults") : $t("library.noGames") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Bars3Icon, MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
|
const library = await useLibrary();
|
||||||
|
|
||||||
|
const searchQuery = ref("");
|
||||||
|
|
||||||
|
const filteredLibrary = computed(() =>
|
||||||
|
library.value.entries
|
||||||
|
.map((e) => e.game)
|
||||||
|
.filter((e) =>
|
||||||
|
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
212
components/Directory/News.vue
Normal file
212
components/Directory/News.vue
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-900 px-6 py-6 ring-1 ring-white/10"
|
||||||
|
>
|
||||||
|
<!-- Search and filters -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<MagnifyingGlassIcon
|
||||||
|
class="h-5 w-5 text-zinc-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||||
|
:placeholder="$t('news.searchPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-2">
|
||||||
|
<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">{{ $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">
|
||||||
|
{{ $t("common.tags") }}
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="tag in availableTags"
|
||||||
|
:key="tag"
|
||||||
|
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors duration-200"
|
||||||
|
:class="[
|
||||||
|
selectedTags.includes(tag)
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700',
|
||||||
|
]"
|
||||||
|
@click="toggleTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex-1 space-y-2">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="article in filteredArticles"
|
||||||
|
:key="article.id"
|
||||||
|
:to="`/news/${article.id}`"
|
||||||
|
class="group block rounded-lg hover-lift"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col gap-y-2 rounded-lg p-3 transition-all duration-200"
|
||||||
|
:class="[
|
||||||
|
route.params.id === article.id
|
||||||
|
? 'bg-zinc-800'
|
||||||
|
: 'hover:bg-zinc-800/50',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="article.imageObjectId"
|
||||||
|
class="absolute inset-0 rounded-lg transition-all duration-200 overflow-hidden"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="useObject(article.imageObjectId)"
|
||||||
|
class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-zinc-900/50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="relative text-sm font-medium text-zinc-100">
|
||||||
|
{{ article.title }}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="relative mt-1 text-xs text-zinc-400 line-clamp-2"
|
||||||
|
v-html="formatExcerpt(article.description)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"
|
||||||
|
>
|
||||||
|
<time :datetime="article.publishedAt">
|
||||||
|
{{ $d(new Date(article.publishedAt), "short") }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
|
||||||
|
import { micromark } from "micromark";
|
||||||
|
|
||||||
|
const news = useNews();
|
||||||
|
if (!news.value) {
|
||||||
|
news.value = await fetchNews();
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const dateFilter = ref("all");
|
||||||
|
const selectedTags = ref<string[]>([]);
|
||||||
|
|
||||||
|
// Get unique tags from all articles
|
||||||
|
const availableTags = computed(() => {
|
||||||
|
if (!news.value) return [];
|
||||||
|
const tags = new Set<string>();
|
||||||
|
news.value.forEach((article) => {
|
||||||
|
article.tags.forEach((tag) => tags.add(tag.name));
|
||||||
|
});
|
||||||
|
return Array.from(tags);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
const index = selectedTags.value.indexOf(tag);
|
||||||
|
if (index === -1) {
|
||||||
|
selectedTags.value.push(tag);
|
||||||
|
} else {
|
||||||
|
selectedTags.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatExcerpt = (excerpt: string) => {
|
||||||
|
// Convert markdown to HTML, micromark is safe
|
||||||
|
return micromark(excerpt);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredArticles = computed(() => {
|
||||||
|
if (!news.value) return [];
|
||||||
|
|
||||||
|
// filter articles based on search, date, and tags
|
||||||
|
return news.value.filter((article) => {
|
||||||
|
const matchesSearch =
|
||||||
|
article.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||||
|
article.description
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.value.toLowerCase());
|
||||||
|
|
||||||
|
const articleDate = new Date(article.publishedAt);
|
||||||
|
const now = new Date();
|
||||||
|
let matchesDate = true;
|
||||||
|
|
||||||
|
switch (dateFilter.value.toLowerCase()) {
|
||||||
|
case "today": {
|
||||||
|
matchesDate = articleDate.toDateString() === now.toDateString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "week": {
|
||||||
|
const weekAgo = new Date(now.setDate(now.getDate() - 7));
|
||||||
|
matchesDate = articleDate >= weekAgo;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "month": {
|
||||||
|
matchesDate =
|
||||||
|
articleDate.getMonth() === now.getMonth() &&
|
||||||
|
articleDate.getFullYear() === now.getFullYear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "year": {
|
||||||
|
matchesDate = articleDate.getFullYear() === now.getFullYear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesTags =
|
||||||
|
selectedTags.value.length === 0 ||
|
||||||
|
selectedTags.value.every((tag) =>
|
||||||
|
article.tags.find((e) => e.name == tag),
|
||||||
|
);
|
||||||
|
|
||||||
|
return matchesSearch && matchesDate && matchesTags;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hover-lift {
|
||||||
|
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="user"
|
|
||||||
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-900 px-6 pb-4 ring-1 ring-white/10"
|
|
||||||
>
|
|
||||||
<div class="flex h-16 shrink-0 items-center">
|
|
||||||
<Wordmark />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="flex flex-1 flex-col">
|
|
||||||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
|
||||||
<li>
|
|
||||||
<ul role="list" class="-mx-2 space-y-1">
|
|
||||||
<DocsSidebarNavItem
|
|
||||||
v-for="item in unwrappedNavigation ?? navigation"
|
|
||||||
:key="item.name"
|
|
||||||
:nav="item"
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li class="mt-auto flex items-center">
|
|
||||||
<div class="inline-flex items-center w-full text-zinc-300">
|
|
||||||
<img
|
|
||||||
:src="useObject(user.profilePicture)"
|
|
||||||
class="w-5 h-5 rounded-sm"
|
|
||||||
/>
|
|
||||||
<span class="ml-3 text-sm font-bold">{{
|
|
||||||
user.displayName
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<NuxtLink
|
|
||||||
href="/"
|
|
||||||
class="ml-auto rounded bg-blue-600 px-2 py-1 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"
|
|
||||||
>
|
|
||||||
← Home
|
|
||||||
</NuxtLink>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { fetchContentNavigation, useObject, useUser } from "#imports";
|
|
||||||
|
|
||||||
const user = useUser();
|
|
||||||
|
|
||||||
const navigation = await fetchContentNavigation();
|
|
||||||
const unwrappedNavigation = navigation[0]?.children;
|
|
||||||
</script>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NuxtLink
|
|
||||||
:href="props.nav._path"
|
|
||||||
:class="[
|
|
||||||
current
|
|
||||||
? 'text-zinc-100'
|
|
||||||
: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-900',
|
|
||||||
' group flex gap-x-3 rounded-md px-2 text-sm font-semibold leading-6',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ props.nav.title }}
|
|
||||||
</NuxtLink>
|
|
||||||
<ul class="pl-3 flex flex-col" v-if="children">
|
|
||||||
<li v-for="child in children" class="inline-flex items-center">
|
|
||||||
<ChevronDownIcon class="w-4 h-4 text-zinc-600 rotate-45" />
|
|
||||||
<DocsSidebarNavItem :nav="child" :key="child._path" />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ChevronDownIcon } from "@heroicons/vue/24/solid";
|
|
||||||
|
|
||||||
type NavItem = { title: string; _path: string; children?: NavItem[] };
|
|
||||||
const props = defineProps<{ nav: NavItem }>();
|
|
||||||
const children = props.nav.children?.filter((e) => e._path != props.nav._path);
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const current = computed(() => route.path.trim() == props.nav._path.trim());
|
|
||||||
</script>
|
|
||||||
25
components/DropLogo.vue
Normal file
25
components/DropLogo.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
aria-label="Drop Logo"
|
||||||
|
class="text-blue-400"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
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>
|
||||||
18
components/DropWordmark.vue
Normal file
18
components/DropWordmark.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div class="inline-flex justify-center items-center gap-x-1 -mb-1 relative">
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 418 42"
|
||||||
|
class="absolute inset-0 h-full w-full fill-blue-300/30 scale-75"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<DropLogo aria-hidden="true" class="h-6" />
|
||||||
|
<span class="text-blue-400 font-display font-bold text-xl uppercase">
|
||||||
|
{{ $t("drop.drop") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
components/EmojiText.vue
Normal file
15
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>
|
||||||
@ -1,41 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<div ref="currentComponent">
|
||||||
<VueCarousel :itemsToShow="moveAmount" :itemsToScroll="moveAmount / 2">
|
<ClientOnly fallback-tag="span">
|
||||||
<VueSlide
|
<VueCarousel :items-to-show="singlePage" :items-to-scroll="singlePage">
|
||||||
class="justify-start"
|
<VueSlide
|
||||||
v-for="(game, gameIdx) in games"
|
v-for="(game, gameIdx) in games"
|
||||||
:key="gameIdx"
|
:key="gameIdx"
|
||||||
>
|
class="justify-start"
|
||||||
<GamePanel :game="game" />
|
>
|
||||||
</VueSlide>
|
<GamePanel
|
||||||
|
:game="game"
|
||||||
|
:href="game ? `/store/${game.id}` : undefined"
|
||||||
|
:show-title-description="showGamePanelTextDecoration"
|
||||||
|
/>
|
||||||
|
</VueSlide>
|
||||||
|
|
||||||
<template #addons>
|
<template #addons>
|
||||||
<VueNavigation />
|
<VueNavigation />
|
||||||
|
</template>
|
||||||
|
</VueCarousel>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="flex flex-nowrap flex-row overflow-hidden whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<SkeletonCard
|
||||||
|
v-for="index in 10"
|
||||||
|
:key="index"
|
||||||
|
:loading="true"
|
||||||
|
class="mr-3 flex-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VueCarousel>
|
</ClientOnly>
|
||||||
</ClientOnly>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Game } from "@prisma/client";
|
import type { GameModel } from "~/prisma/client/models";
|
||||||
import type { SerializeObject } from "nitropack";
|
import type { SerializeObject } from "nitropack";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: Array<SerializeObject<Game>>;
|
items: Array<SerializeObject<GameModel>>;
|
||||||
min?: number;
|
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 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)
|
Array(min.value)
|
||||||
.fill(0)
|
.fill(0)
|
||||||
.map((_, i) => props.items[i])
|
.map((_, i) => props.items[i]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const moveAmount = ref(1);
|
const singlePage = ref(2);
|
||||||
const moveFactor = 1.8 / 400;
|
const sizeOfCard = 192 + 10;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
singlePage.value =
|
||||||
|
(props.width ??
|
||||||
|
currentComponent.value?.parentElement?.clientWidth ??
|
||||||
|
window.innerWidth) / sizeOfCard;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
moveAmount.value = moveFactor * window.innerWidth;
|
handleResize();
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
772
components/GameEditor/Metadata.vue
Normal file
772
components/GameEditor/Metadata.vue
Normal file
@ -0,0 +1,772 @@
|
|||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<template>
|
||||||
|
<div v-if="game!">
|
||||||
|
<div class="grow flex flex-row gap-y-8">
|
||||||
|
<div class="grow w-full h-full px-6 py-4 flex flex-col">
|
||||||
|
<div
|
||||||
|
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
|
||||||
|
>
|
||||||
|
<div class="inline-flex items-center gap-4">
|
||||||
|
<!-- icon image -->
|
||||||
|
<img :src="coreMetadataIconUrl" class="size-20" />
|
||||||
|
<div>
|
||||||
|
<h1 class="text-5xl font-bold font-display text-zinc-100">
|
||||||
|
{{ game.mName }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-lg text-zinc-400">
|
||||||
|
{{ game.mShortDescription }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
@click="() => (showEditCoreMetadata = true)"
|
||||||
|
>
|
||||||
|
{{ $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">
|
||||||
|
<div
|
||||||
|
class="-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap"
|
||||||
|
>
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<h3 class="text-base font-semibold text-zinc-100">
|
||||||
|
{{ $t("library.admin.game.imageCarousel") }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
|
||||||
|
{{ $t("library.admin.game.imageCarouselDescription") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 mt-4 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
@click="() => (showAddCarouselModal = true)"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.game.addImageCarousel") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="game.mImageCarouselObjectIds.length == 0"
|
||||||
|
class="text-zinc-400 text-center py-8"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.game.imageCarouselEmpty") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<draggable
|
||||||
|
v-else
|
||||||
|
:list="game.mImageCarouselObjectIds"
|
||||||
|
class="w-full flex flex-row gap-x-4 overflow-x-auto my-2 py-4"
|
||||||
|
@update="() => updateImageCarousel()"
|
||||||
|
>
|
||||||
|
<template #item="{ element }: { element: string }">
|
||||||
|
<div class="relative group min-w-fit">
|
||||||
|
<img :src="useObject(element)" class="h-48 w-auto" />
|
||||||
|
<div
|
||||||
|
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
@click="() => removeImageFromCarousel(element)"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.game.removeImageCarousel") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- description editor -->
|
||||||
|
<div
|
||||||
|
class="mt-4 grow flex flex-col w-full space-y-4 border border-zinc-800 rounded overflow-hidden p-2"
|
||||||
|
>
|
||||||
|
<!-- toolbar -->
|
||||||
|
<div
|
||||||
|
class="h-8 bg-zinc-800 rounded inline-flex gap-x-4 items-center justify-start p-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<CheckIcon
|
||||||
|
v-if="descriptionSaving == DescriptionSavingState.NotLoading"
|
||||||
|
class="size-5 text-zinc-100"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="descriptionSaving == DescriptionSavingState.Waiting"
|
||||||
|
>
|
||||||
|
<PencilIcon class="animate-pulse size-5 text-zinc-100" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="descriptionSaving == DescriptionSavingState.Loading"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="w-5 h-5 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>
|
||||||
|
<span class="sr-only">{{ $t("common.srLoading") }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="() => (showAddImageDescriptionModal = true)">
|
||||||
|
<PhotoIcon
|
||||||
|
class="transition size-5 text-zinc-100 hover:text-zinc-300"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="block lg:hidden"
|
||||||
|
@click="
|
||||||
|
() => (mobileShowFinalDescription = !mobileShowFinalDescription)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<DocumentIcon
|
||||||
|
v-if="!mobileShowFinalDescription"
|
||||||
|
class="transition size-5 text-zinc-100 hover:text-zinc-300"
|
||||||
|
/>
|
||||||
|
<PencilIcon
|
||||||
|
v-else
|
||||||
|
class="transition size-5 text-zinc-100 hover:text-zinc-300"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- edit area -->
|
||||||
|
<div class="grid lg:grid-cols-2 lg:gap-x-8 grow">
|
||||||
|
<!-- editing box -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
mobileShowFinalDescription ? 'hidden' : 'block',
|
||||||
|
'lg:block',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref="descriptionEditor"
|
||||||
|
v-model="game.mDescription"
|
||||||
|
class="grow h-full w-full bg-zinc-950/30 text-zinc-100 border-zinc-900 rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- result box -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
mobileShowFinalDescription ? 'block' : 'hidden',
|
||||||
|
'lg:block prose prose-invert prose-blue bg-zinc-950/30 rounded px-4 py-3',
|
||||||
|
]"
|
||||||
|
v-html="descriptionHTML"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
|
||||||
|
>
|
||||||
|
<!-- image library -->
|
||||||
|
<div>
|
||||||
|
<div class="border-b border-zinc-800 pb-3">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between sm:flex-nowrap gap-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
class="text-base font-semibold font-display leading-6 text-zinc-100"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.game.imageLibrary") }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
|
||||||
|
{{ $t("library.admin.game.imageLibraryDescription") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
@click="() => (showUploadModal = true)"
|
||||||
|
>
|
||||||
|
{{ $t("upload") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid grid-cols-2 grid-flow-dense gap-8">
|
||||||
|
<div
|
||||||
|
v-for="(image, imageIdx) in game.mImageLibraryObjectIds"
|
||||||
|
:key="imageIdx"
|
||||||
|
class="group relative flex items-center bg-zinc-950/30"
|
||||||
|
>
|
||||||
|
<img :src="useObject(image)" class="w-full h-auto" />
|
||||||
|
<div
|
||||||
|
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="image !== game.mBannerObjectId"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
@click="() => updateBannerImage(image)"
|
||||||
|
>
|
||||||
|
{{ $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 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)"
|
||||||
|
>
|
||||||
|
{{ $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 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)"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.game.deleteImage") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
image === game.mBannerObjectId ||
|
||||||
|
image === game.mCoverObjectId
|
||||||
|
"
|
||||||
|
class="absolute bottom-0 left-0 flex flex-row gap-x-1 p-1"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<ModalUploadFile
|
||||||
|
v-model="showUploadModal"
|
||||||
|
:options="{ id: game.id }"
|
||||||
|
accept="image/*"
|
||||||
|
endpoint="/api/v1/admin/game/image"
|
||||||
|
:multiple="true"
|
||||||
|
@upload="(result: GameModel) => uploadAfterImageUpload(result)"
|
||||||
|
/>
|
||||||
|
<ModalTemplate v-model="showAddCarouselModal">
|
||||||
|
<template #default>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 grid-flow-dense gap-4 max-h-[70vh] overflow-y-auto p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(image, imageIdx) in validAddCarouselImages"
|
||||||
|
:key="imageIdx"
|
||||||
|
class="group relative flex items-center bg-zinc-950/30"
|
||||||
|
>
|
||||||
|
<img :src="useObject(image)" class="w-full h-auto" />
|
||||||
|
<div
|
||||||
|
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
@click="() => addImageToCarousel(image)"
|
||||||
|
>
|
||||||
|
{{ $t("add") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="validAddCarouselImages.length == 0"
|
||||||
|
class="text-zinc-400 col-span-2"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.game.addCarouselNoImages") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #buttons>
|
||||||
|
<button
|
||||||
|
ref="cancelButtonRef"
|
||||||
|
type="button"
|
||||||
|
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
|
||||||
|
@click="showAddCarouselModal = false"
|
||||||
|
>
|
||||||
|
{{ $t("common.close") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
|
<ModalTemplate v-model="showAddImageDescriptionModal">
|
||||||
|
<template #default>
|
||||||
|
<div class="grid grid-cols-2 grid-flow-dense gap-4">
|
||||||
|
<div
|
||||||
|
v-for="(image, imageIdx) in game.mImageLibraryObjectIds"
|
||||||
|
:key="imageIdx"
|
||||||
|
class="group relative flex items-center bg-zinc-950/30"
|
||||||
|
>
|
||||||
|
<img :src="useObject(image)" class="w-full h-auto" />
|
||||||
|
<div
|
||||||
|
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
@click="() => insertImageAtCursor(image)"
|
||||||
|
>
|
||||||
|
{{ $t("common.insert") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="game.mImageLibraryObjectIds.length == 0"
|
||||||
|
class="text-zinc-400 col-span-2"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.game.addDescriptionNoImages") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #buttons>
|
||||||
|
<button
|
||||||
|
ref="cancelButtonRef"
|
||||||
|
type="button"
|
||||||
|
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
|
||||||
|
@click="showAddImageDescriptionModal = false"
|
||||||
|
>
|
||||||
|
{{ $t("cancel") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
|
<ModalTemplate v-model="showEditCoreMetadata">
|
||||||
|
<template #default>
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6">
|
||||||
|
<!-- icon upload div -->
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<img :src="coreMetadataIconUrl" class="size-24 aspect-square" />
|
||||||
|
<label for="file-upload">
|
||||||
|
<span
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
|
>
|
||||||
|
{{ $t("upload") }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
@change="(e: Event) => coreMetadataUploadFiles(e as any)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<!-- edit title -->
|
||||||
|
<div class="flex flex-col gap-y-4 grow">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="name"
|
||||||
|
class="block text-sm/6 font-medium text-zinc-100"
|
||||||
|
>{{ $t("library.admin.game.editGameName") }}</label
|
||||||
|
>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
v-model="coreMetadataName"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
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.game.editGameDescription") }}</label
|
||||||
|
>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="description"
|
||||||
|
v-model="coreMetadataDescription"
|
||||||
|
type="text"
|
||||||
|
name="description"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #buttons>
|
||||||
|
<LoadingButton
|
||||||
|
type="button"
|
||||||
|
:loading="coreMetadataLoading"
|
||||||
|
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
|
||||||
|
@click="() => coreMetadataUpdate_wrapper()"
|
||||||
|
>
|
||||||
|
{{ $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 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
|
||||||
|
@click="showEditCoreMetadata = false"
|
||||||
|
>
|
||||||
|
{{ $t("cancel") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { GameModel, GameTagModel } from "~/prisma/client/models";
|
||||||
|
import { micromark } from "micromark";
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
DocumentIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PhotoIcon,
|
||||||
|
} from "@heroicons/vue/24/solid";
|
||||||
|
import type { SerializeObject } from "nitropack";
|
||||||
|
import type { H3Error } from "h3";
|
||||||
|
|
||||||
|
const showUploadModal = ref(false);
|
||||||
|
const showAddCarouselModal = ref(false);
|
||||||
|
const showAddImageDescriptionModal = ref(false);
|
||||||
|
const showEditCoreMetadata = ref(false);
|
||||||
|
const mobileShowFinalDescription = ref(true);
|
||||||
|
|
||||||
|
type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
|
||||||
|
const game = defineModel<ModelType>() as Ref<ModelType>;
|
||||||
|
if (!game.value)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Game not provided to editor component",
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentTags = ref<{ [key: string]: boolean }>(
|
||||||
|
Object.fromEntries(game.value.tags.map((e) => [e.id, true])),
|
||||||
|
);
|
||||||
|
const tags = (await $dropFetch("/api/v1/admin/tags")).map(
|
||||||
|
(e) => ({ name: e.name, param: e.id }) satisfies StoreSortOption,
|
||||||
|
);
|
||||||
|
|
||||||
|
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));
|
||||||
|
const coreMetadataIconFileUpload = ref<FileList | undefined>();
|
||||||
|
const coreMetadataLoading = ref(false);
|
||||||
|
|
||||||
|
function coreMetadataUploadFiles(e: InputEvent) {
|
||||||
|
if (coreMetadataIconUrl.value.startsWith("blob")) {
|
||||||
|
URL.revokeObjectURL(coreMetadataIconUrl.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
coreMetadataIconFileUpload.value = (e.target as any)?.files;
|
||||||
|
const file = coreMetadataIconFileUpload.value?.item(0);
|
||||||
|
if (!file) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.upload.title"),
|
||||||
|
description: t("errors.upload.description", [t("errors.unknown")]),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(e, c) => c(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
coreMetadataIconUrl.value = objectUrl;
|
||||||
|
}
|
||||||
|
async function coreMetadataUpdate() {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
const newIcon = coreMetadataIconFileUpload.value?.item(0);
|
||||||
|
if (newIcon) {
|
||||||
|
formData.append("icon", newIcon);
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.append("name", coreMetadataName.value);
|
||||||
|
formData.append("description", coreMetadataDescription.value);
|
||||||
|
|
||||||
|
const result = await $dropFetch(
|
||||||
|
`/api/v1/admin/game/${game.value.id}/metadata`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coreMetadataUpdate_wrapper() {
|
||||||
|
coreMetadataLoading.value = true;
|
||||||
|
coreMetadataUpdate()
|
||||||
|
.catch((e) => {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.game.metadata.title"),
|
||||||
|
description: t("errors.game.metadata.description", [
|
||||||
|
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(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;
|
||||||
|
showEditCoreMetadata.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptionHTML = computed(() =>
|
||||||
|
micromark(game.value?.mDescription ?? ""),
|
||||||
|
);
|
||||||
|
const descriptionEditor = ref<HTMLTextAreaElement | undefined>();
|
||||||
|
// 0 is not loading
|
||||||
|
// 1 is waiting for stop
|
||||||
|
// 2 is loading
|
||||||
|
enum DescriptionSavingState {
|
||||||
|
NotLoading,
|
||||||
|
Waiting,
|
||||||
|
Loading,
|
||||||
|
}
|
||||||
|
const descriptionSaving = ref<DescriptionSavingState>(
|
||||||
|
DescriptionSavingState.NotLoading,
|
||||||
|
);
|
||||||
|
|
||||||
|
let savingTimeout: undefined | NodeJS.Timeout;
|
||||||
|
|
||||||
|
type PatchGameBody = Partial<GameModel>;
|
||||||
|
|
||||||
|
watch(descriptionHTML, (_v) => {
|
||||||
|
descriptionSaving.value = DescriptionSavingState.Waiting;
|
||||||
|
if (savingTimeout) clearTimeout(savingTimeout);
|
||||||
|
savingTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
descriptionSaving.value = DescriptionSavingState.Loading;
|
||||||
|
await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||||
|
method: "PATCH",
|
||||||
|
params: {
|
||||||
|
id: game.value.id,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
mDescription: game.value.mDescription,
|
||||||
|
} satisfies PatchGameBody,
|
||||||
|
});
|
||||||
|
descriptionSaving.value = DescriptionSavingState.NotLoading;
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.game.description.title"),
|
||||||
|
description: t("errors.game.description.description", [
|
||||||
|
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(e, c) => c(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
const validAddCarouselImages = computed(() =>
|
||||||
|
game.value.mImageLibraryObjectIds.filter(
|
||||||
|
(e) => !game.value.mImageCarouselObjectIds.includes(e),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function insertImageAtCursor(id: string) {
|
||||||
|
showAddImageDescriptionModal.value = false;
|
||||||
|
if (!descriptionEditor.value || !game.value) return;
|
||||||
|
const insertPosition = descriptionEditor.value.selectionStart;
|
||||||
|
const text = ``;
|
||||||
|
game.value.mDescription = `${game.value.mDescription.slice(
|
||||||
|
0,
|
||||||
|
insertPosition,
|
||||||
|
)}${text}${game.value.mDescription.slice(insertPosition)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateBannerImage(id: string) {
|
||||||
|
try {
|
||||||
|
if (game.value.mBannerObjectId == id) return;
|
||||||
|
const { mBannerObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||||
|
method: "PATCH",
|
||||||
|
params: {
|
||||||
|
id: game.value.id,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
mBannerObjectId: id,
|
||||||
|
} satisfies PatchGameBody,
|
||||||
|
});
|
||||||
|
game.value.mBannerObjectId = mBannerObjectId;
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.game.banner.title"),
|
||||||
|
description: t("errors.game.banner.description", [
|
||||||
|
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(e, c) => c(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCoverImage(id: string) {
|
||||||
|
try {
|
||||||
|
if (game.value.mCoverObjectId == id) return;
|
||||||
|
const { mCoverObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||||
|
method: "PATCH",
|
||||||
|
params: {
|
||||||
|
id: game.value.id,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
mCoverObjectId: id,
|
||||||
|
} satisfies PatchGameBody,
|
||||||
|
});
|
||||||
|
game.value.mCoverObjectId = mCoverObjectId;
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.game.cover.title"),
|
||||||
|
description: t("errors.game.cover.description", [
|
||||||
|
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(e, c) => c(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteImage(id: string) {
|
||||||
|
try {
|
||||||
|
const { mBannerObjectId, mImageLibraryObjectIds } = await $dropFetch(
|
||||||
|
"/api/v1/admin/game/image",
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
body: {
|
||||||
|
gameId: game.value.id,
|
||||||
|
imageId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
game.value.mImageLibraryObjectIds = mImageLibraryObjectIds;
|
||||||
|
game.value.mBannerObjectId = mBannerObjectId;
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.game.deleteImage.title"),
|
||||||
|
description: t("errors.game.deleteImage.description", [
|
||||||
|
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(e, c) => c(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAfterImageUpload(result: GameModel) {
|
||||||
|
if (!game.value) return;
|
||||||
|
game.value.mImageLibraryObjectIds = result.mImageLibraryObjectIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImageToCarousel(id: string) {
|
||||||
|
game.value.mImageCarouselObjectIds.push(id);
|
||||||
|
updateImageCarousel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImageFromCarousel(id: string) {
|
||||||
|
const imageIndex = game.value.mImageCarouselObjectIds.findIndex(
|
||||||
|
(e) => e == id,
|
||||||
|
);
|
||||||
|
game.value.mImageCarouselObjectIds.splice(imageIndex, 1);
|
||||||
|
updateImageCarousel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateImageCarousel() {
|
||||||
|
try {
|
||||||
|
await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||||
|
method: "PATCH",
|
||||||
|
params: {
|
||||||
|
id: game.value.id,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
mImageCarouselObjectIds: game.value.mImageCarouselObjectIds,
|
||||||
|
} satisfies PatchGameBody,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.game.carousel.title"),
|
||||||
|
description: t("errors.game.carousel.description", [
|
||||||
|
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(e, c) => c(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
196
components/GameEditor/Version.vue
Normal file
196
components/GameEditor/Version.vue
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<template>
|
||||||
|
<div v-if="game && unimportedVersions">
|
||||||
|
<div class="grow flex flex-row gap-y-8">
|
||||||
|
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
|
||||||
|
<div
|
||||||
|
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
|
||||||
|
>
|
||||||
|
<!-- version manager -->
|
||||||
|
<div>
|
||||||
|
<!-- version priority -->
|
||||||
|
<div>
|
||||||
|
<div class="border-b border-zinc-800 pb-3">
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
class="text-base font-semibold font-display leading-6 text-zinc-100"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.versionPriority") }}
|
||||||
|
|
||||||
|
<!-- import games button -->
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
:href="canImport ? `/admin/library/${game.id}/import` : ''"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
canImport
|
||||||
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
: 'bg-blue-800/50',
|
||||||
|
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
canImport
|
||||||
|
? $t("library.admin.import.version.import")
|
||||||
|
: $t("library.admin.import.version.noVersions")
|
||||||
|
}}
|
||||||
|
</NuxtLink>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center w-full text-sm text-zinc-600">
|
||||||
|
{{ $t("lowest") }}
|
||||||
|
</div>
|
||||||
|
<draggable
|
||||||
|
:list="game.versions"
|
||||||
|
handle=".handle"
|
||||||
|
class="mt-2 space-y-4"
|
||||||
|
@update="() => updateVersionOrder()"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
#item="{ element: item }: { element: GameVersionModel }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
|
||||||
|
>
|
||||||
|
<div class="text-zinc-100 font-semibold">
|
||||||
|
{{ item.versionName }}
|
||||||
|
</div>
|
||||||
|
<div class="text-zinc-400">
|
||||||
|
{{ item.delta ? $t("library.admin.version.delta") : "" }}
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-x-2">
|
||||||
|
<component
|
||||||
|
:is="PLATFORM_ICONS[item.platform]"
|
||||||
|
class="size-6 text-blue-600"
|
||||||
|
/>
|
||||||
|
<Bars3Icon
|
||||||
|
class="cursor-move w-6 h-6 text-zinc-400 handle"
|
||||||
|
/>
|
||||||
|
<button @click="() => deleteVersion(item.versionName)">
|
||||||
|
<TrashIcon class="w-5 h-5 text-red-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<div
|
||||||
|
v-if="game.versions.length == 0"
|
||||||
|
class="text-center font-bold text-zinc-400 my-3"
|
||||||
|
>
|
||||||
|
{{ $t("library.admin.version.noVersionsAdded") }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-center w-full text-sm text-zinc-600">
|
||||||
|
{{ $t("highest") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="grow w-full flex items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<ExclamationCircleIcon
|
||||||
|
class="h-12 w-12 text-red-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div class="mt-3 text-center sm:mt-5">
|
||||||
|
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
||||||
|
{{ $t("library.admin.offlineTitle") }}
|
||||||
|
</h1>
|
||||||
|
<div class="mt-4">
|
||||||
|
<p class="text-sm text-zinc-400 max-w-md">
|
||||||
|
{{ $t("library.admin.offline") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { GameModel, GameVersionModel } from "~/prisma/client/models";
|
||||||
|
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
|
||||||
|
import type { SerializeObject } from "nitropack";
|
||||||
|
import type { H3Error } from "h3";
|
||||||
|
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
|
// TODO implement UI for this page
|
||||||
|
|
||||||
|
const props = defineProps<{ unimportedVersions: string[] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const hasDeleted = ref(false);
|
||||||
|
|
||||||
|
const canImport = computed(
|
||||||
|
() => hasDeleted.value || props.unimportedVersions.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
type GameAndVersions = GameModel & { versions: GameVersionModel[] };
|
||||||
|
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
|
||||||
|
SerializeObject<GameAndVersions>
|
||||||
|
>;
|
||||||
|
if (!game.value)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Game not provided to editor component",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateVersionOrder() {
|
||||||
|
try {
|
||||||
|
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: {
|
||||||
|
id: game.value.id,
|
||||||
|
versions: game.value.versions.map((e) => e.versionName),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
game.value.versions = newVersions;
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.version.order.title"),
|
||||||
|
description: t("errors.version.order.desc", {
|
||||||
|
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||||
|
}),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(e, c) => c(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVersion(versionName: string) {
|
||||||
|
try {
|
||||||
|
await $dropFetch("/api/v1/admin/game/version", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: {
|
||||||
|
id: game.value.id,
|
||||||
|
versionName: versionName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
game.value.versions.splice(
|
||||||
|
game.value.versions.findIndex((e) => e.versionName === versionName),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
hasDeleted.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.version.delete.title"),
|
||||||
|
description: t("errors.version.delete.desc", {
|
||||||
|
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||||
|
}),
|
||||||
|
buttonText: t("common.close"),
|
||||||
|
},
|
||||||
|
(e, c) => c(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -1,41 +1,104 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="game"
|
v-if="game || defaultPlaceholder"
|
||||||
:href="`/store/${game.id}`"
|
:href="href"
|
||||||
class="rounded overflow-hidden w-48 h-64 group relative transition-all duration-300 text-left"
|
: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"
|
||||||
>
|
>
|
||||||
<img :src="useObject(game.mCoverId)" class="w-full h-full object-cover" />
|
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-gradient-to-b from-transparent to-[100%] to-zinc-950/50"
|
:class="{
|
||||||
/>
|
'transition-all duration-300 group-hover:scale-110': animate,
|
||||||
<div class="absolute bottom-0 left-0 px-2 py-1.5">
|
}"
|
||||||
<h1 class="text-zinc-100 text-sm font-bold font-display">
|
class="absolute inset-0"
|
||||||
{{ game.mName }}
|
>
|
||||||
|
<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>
|
</h1>
|
||||||
<p class="text-zinc-400 text-xs line-clamp-2">
|
<p
|
||||||
{{ game.mShortDescription }}
|
: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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div
|
<SkeletonCard
|
||||||
v-else
|
v-else-if="defaultPlaceholder === false"
|
||||||
class="rounded w-48 h-64 bg-zinc-800 flex items-center justify-center"
|
:message="$t('store.noGame')"
|
||||||
>
|
/>
|
||||||
<p class="text-zinc-700 text-sm font-semibold font-display uppercase">
|
|
||||||
no game
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SerializeObject } from "nitropack";
|
import type { SerializeObject } from "nitropack";
|
||||||
|
|
||||||
const props = defineProps<{
|
const { t } = useI18n();
|
||||||
game?: SerializeObject<{
|
const {
|
||||||
id: string;
|
game,
|
||||||
mCoverId: string;
|
href = undefined,
|
||||||
mName: string;
|
showTitleDescription = true,
|
||||||
mShortDescription: string;
|
animate = true,
|
||||||
}>;
|
defaultPlaceholder = false,
|
||||||
|
} = defineProps<{
|
||||||
|
game:
|
||||||
|
| SerializeObject<{
|
||||||
|
id: string;
|
||||||
|
mCoverObjectId: string;
|
||||||
|
mName: string;
|
||||||
|
mShortDescription: string;
|
||||||
|
}>
|
||||||
|
| undefined
|
||||||
|
| null;
|
||||||
|
href?: string;
|
||||||
|
showTitleDescription?: boolean;
|
||||||
|
animate?: boolean;
|
||||||
|
defaultPlaceholder?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const imageProps = {
|
||||||
|
src: "",
|
||||||
|
alt: t("settings.admin.store.dropGameAltPlaceholder"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (game) {
|
||||||
|
imageProps.src = useObject(game.mCoverObjectId);
|
||||||
|
imageProps.alt = game.mName;
|
||||||
|
} else if (defaultPlaceholder) {
|
||||||
|
imageProps.src = "/game-panel-placeholder.png";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
img.active {
|
||||||
|
view-transition-name: selected-game;
|
||||||
|
contain: layout;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||||
|
|
||||||
const props = defineProps<{
|
const { game } = defineProps<{
|
||||||
game: GameMetadataSearchResult & { sourceName?: string };
|
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
d="M20.992 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.050 0.005 0.109 0.005 0.168 0 1.523-1.191 2.768-2.693 2.854l-0.008 0zM11.026 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.048 0.005 0.104 0.005 0.161 0 1.525-1.19 2.771-2.692 2.862l-0.008 0zM26.393 6.465c-1.763-0.832-3.811-1.49-5.955-1.871l-0.149-0.022c-0.005-0.001-0.011-0.002-0.017-0.002-0.035 0-0.065 0.019-0.081 0.047l-0 0c-0.234 0.411-0.488 0.924-0.717 1.45l-0.043 0.111c-1.030-0.165-2.218-0.259-3.428-0.259s-2.398 0.094-3.557 0.275l0.129-0.017c-0.27-0.63-0.528-1.142-0.813-1.638l0.041 0.077c-0.017-0.029-0.048-0.047-0.083-0.047-0.005 0-0.011 0-0.016 0.001l0.001-0c-2.293 0.403-4.342 1.060-6.256 1.957l0.151-0.064c-0.017 0.007-0.031 0.019-0.040 0.034l-0 0c-2.854 4.041-4.562 9.069-4.562 14.496 0 0.907 0.048 1.802 0.141 2.684l-0.009-0.11c0.003 0.029 0.018 0.053 0.039 0.070l0 0c2.14 1.601 4.628 2.891 7.313 3.738l0.176 0.048c0.008 0.003 0.018 0.004 0.028 0.004 0.032 0 0.060-0.015 0.077-0.038l0-0c0.535-0.72 1.044-1.536 1.485-2.392l0.047-0.1c0.006-0.012 0.010-0.027 0.010-0.043 0-0.041-0.026-0.075-0.062-0.089l-0.001-0c-0.912-0.352-1.683-0.727-2.417-1.157l0.077 0.042c-0.029-0.017-0.048-0.048-0.048-0.083 0-0.031 0.015-0.059 0.038-0.076l0-0c0.157-0.118 0.315-0.24 0.465-0.364 0.016-0.013 0.037-0.021 0.059-0.021 0.014 0 0.027 0.003 0.038 0.008l-0.001-0c2.208 1.061 4.8 1.681 7.536 1.681s5.329-0.62 7.643-1.727l-0.107 0.046c0.012-0.006 0.025-0.009 0.040-0.009 0.022 0 0.043 0.008 0.059 0.021l-0-0c0.15 0.124 0.307 0.248 0.466 0.365 0.023 0.018 0.038 0.046 0.038 0.077 0 0.035-0.019 0.065-0.046 0.082l-0 0c-0.661 0.395-1.432 0.769-2.235 1.078l-0.105 0.036c-0.036 0.014-0.062 0.049-0.062 0.089 0 0.016 0.004 0.031 0.011 0.044l-0-0.001c0.501 0.96 1.009 1.775 1.571 2.548l-0.040-0.057c0.017 0.024 0.046 0.040 0.077 0.040 0.010 0 0.020-0.002 0.029-0.004l-0.001 0c2.865-0.892 5.358-2.182 7.566-3.832l-0.065 0.047c0.022-0.016 0.036-0.041 0.039-0.069l0-0c0.087-0.784 0.136-1.694 0.136-2.615 0-5.415-1.712-10.43-4.623-14.534l0.052 0.078c-0.008-0.016-0.022-0.029-0.038-0.036l-0-0z">
|
d="M20.992 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.050 0.005 0.109 0.005 0.168 0 1.523-1.191 2.768-2.693 2.854l-0.008 0zM11.026 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.048 0.005 0.104 0.005 0.161 0 1.525-1.19 2.771-2.692 2.862l-0.008 0zM26.393 6.465c-1.763-0.832-3.811-1.49-5.955-1.871l-0.149-0.022c-0.005-0.001-0.011-0.002-0.017-0.002-0.035 0-0.065 0.019-0.081 0.047l-0 0c-0.234 0.411-0.488 0.924-0.717 1.45l-0.043 0.111c-1.030-0.165-2.218-0.259-3.428-0.259s-2.398 0.094-3.557 0.275l0.129-0.017c-0.27-0.63-0.528-1.142-0.813-1.638l0.041 0.077c-0.017-0.029-0.048-0.047-0.083-0.047-0.005 0-0.011 0-0.016 0.001l0.001-0c-2.293 0.403-4.342 1.060-6.256 1.957l0.151-0.064c-0.017 0.007-0.031 0.019-0.040 0.034l-0 0c-2.854 4.041-4.562 9.069-4.562 14.496 0 0.907 0.048 1.802 0.141 2.684l-0.009-0.11c0.003 0.029 0.018 0.053 0.039 0.070l0 0c2.14 1.601 4.628 2.891 7.313 3.738l0.176 0.048c0.008 0.003 0.018 0.004 0.028 0.004 0.032 0 0.060-0.015 0.077-0.038l0-0c0.535-0.72 1.044-1.536 1.485-2.392l0.047-0.1c0.006-0.012 0.010-0.027 0.010-0.043 0-0.041-0.026-0.075-0.062-0.089l-0.001-0c-0.912-0.352-1.683-0.727-2.417-1.157l0.077 0.042c-0.029-0.017-0.048-0.048-0.048-0.083 0-0.031 0.015-0.059 0.038-0.076l0-0c0.157-0.118 0.315-0.24 0.465-0.364 0.016-0.013 0.037-0.021 0.059-0.021 0.014 0 0.027 0.003 0.038 0.008l-0.001-0c2.208 1.061 4.8 1.681 7.536 1.681s5.329-0.62 7.643-1.727l-0.107 0.046c0.012-0.006 0.025-0.009 0.040-0.009 0.022 0 0.043 0.008 0.059 0.021l-0-0c0.15 0.124 0.307 0.248 0.466 0.365 0.023 0.018 0.038 0.046 0.038 0.077 0 0.035-0.019 0.065-0.046 0.082l-0 0c-0.661 0.395-1.432 0.769-2.235 1.078l-0.105 0.036c-0.036 0.014-0.062 0.049-0.062 0.089 0 0.016 0.004 0.031 0.011 0.044l-0-0.001c0.501 0.96 1.009 1.775 1.571 2.548l-0.040-0.057c0.017 0.024 0.046 0.040 0.077 0.040 0.010 0 0.020-0.002 0.029-0.004l-0.001 0c2.865-0.892 5.358-2.182 7.566-3.832l-0.065 0.047c0.022-0.016 0.036-0.041 0.039-0.069l0-0c0.087-0.784 0.136-1.694 0.136-2.615 0-5.415-1.712-10.43-4.623-14.534l0.052 0.078c-0.008-0.016-0.022-0.029-0.038-0.036l-0-0z"
|
||||||
</path>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,16 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
<svg
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
viewBox="0 0 20 20"
|
||||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
version="1.1"
|
||||||
<g id="Dribbble-Light-Preview" transform="translate(-140.000000, -7559.000000)" fill="currentColor">
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<g id="icons" transform="translate(56.000000, 160.000000)">
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
<path
|
>
|
||||||
d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399"
|
<g
|
||||||
id="github-[#142]">
|
id="Page-1"
|
||||||
|
stroke="none"
|
||||||
</path>
|
stroke-width="1"
|
||||||
</g>
|
fill="none"
|
||||||
</g>
|
fill-rule="evenodd"
|
||||||
</g>
|
>
|
||||||
</svg>
|
<g
|
||||||
</template>
|
id="Dribbble-Light-Preview"
|
||||||
|
transform="translate(-140.000000, -7559.000000)"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<g id="icons" transform="translate(56.000000, 160.000000)">
|
||||||
|
<path
|
||||||
|
id="github-[#142]"
|
||||||
|
d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
d="M4.53918 2.40715C4.82145 1.0075 6.06066 0 7.49996 0C8.93926 0 10.1785 1.0075 10.4607 2.40715L10.798 4.07944C10.9743 4.9539 11.3217 5.78562 11.8205 6.52763L12.4009 7.39103C12.7631 7.92978 12.9999 8.5385 13.0979 9.17323C13.6747 9.22167 14.1803 9.58851 14.398 10.1283L14.8897 11.3474C15.1376 11.962 14.9583 12.665 14.4455 13.0887L12.5614 14.6458C12.0128 15.0992 11.2219 15.1193 10.6506 14.6944L9.89192 14.1301C9.88189 14.1227 9.87197 14.1151 9.86216 14.1074C9.48973 14.2075 9.09793 14.261 8.69355 14.261H6.30637C5.90201 14.261 5.51023 14.2076 5.13782 14.1074C5.12802 14.1151 5.11811 14.1227 5.10808 14.1301L4.34942 14.6944C3.77811 15.1193 2.98725 15.0992 2.43863 14.6458L0.55446 13.0887C0.0417175 12.665 -0.1376 11.962 0.110281 11.3474L0.602025 10.1283C0.819715 9.58854 1.32527 9.2217 1.90198 9.17324C2 8.5385 2.2368 7.92978 2.59897 7.39103L3.17938 6.52763C3.67818 5.78562 4.02557 4.9539 4.20193 4.07944L4.53918 2.40715ZM10.8445 9.47585C10.6345 9.63293 10.4642 9.84382 10.3561 10.0938L9.58799 11.8713C9.20026 12.0979 8.75209 12.2237 8.28465 12.2237H6.7153C6.24789 12.2237 5.79975 12.0979 5.41203 11.8714L4.64386 10.0938C4.53581 9.8438 4.36552 9.6329 4.15546 9.47582C4.18121 9.15355 4.2689 8.83503 4.41853 8.53826L5.67678 6.04259L5.68433 6.05007C6.68715 7.04458 8.31304 7.04458 9.31585 6.05007L9.32324 6.04274L10.5814 8.53825C10.7311 8.83504 10.8187 9.15357 10.8445 9.47585ZM9.04068 4.26906V3.05592H8.01353V3.85713C8.23151 3.90123 8.44506 3.97371 8.64848 4.07458L9.04068 4.26906ZM6.98638 3.85718V3.05592H5.95923V4.26919L6.3517 4.07458C6.55504 3.97375 6.7685 3.90129 6.98638 3.85718ZM2.03255 10.1864C1.82255 10.1864 1.6337 10.3132 1.55571 10.5066L1.06397 11.7257C0.981339 11.9306 1.04111 12.1649 1.21203 12.3062L3.0962 13.8633C3.27907 14.0144 3.54269 14.0211 3.73313 13.8795L4.49179 13.3152C4.6813 13.1743 4.74901 12.923 4.6557 12.7071L3.69976 10.4951C3.61884 10.3078 3.43316 10.1864 3.22771 10.1864H2.03255ZM13.4443 10.5066C13.3663 10.3132 13.1775 10.1864 12.9674 10.1864H11.7723C11.5668 10.1864 11.3812 10.3078 11.3002 10.4951L10.3443 12.7071C10.251 12.923 10.3187 13.1743 10.5082 13.3152L11.2669 13.8795C11.4573 14.0211 11.7209 14.0144 11.9038 13.8633L13.788 12.3062C13.9589 12.1649 14.0187 11.9306 13.936 11.7257L13.4443 10.5066ZM6.81106 4.98568C7.24481 4.7706 7.75537 4.7706 8.18912 4.98568L8.68739 5.23275L8.58955 5.32978C7.98786 5.92649 7.01232 5.92649 6.41063 5.32978L6.31279 5.23275L6.81106 4.98568Z"
|
d="M4.53918 2.40715C4.82145 1.0075 6.06066 0 7.49996 0C8.93926 0 10.1785 1.0075 10.4607 2.40715L10.798 4.07944C10.9743 4.9539 11.3217 5.78562 11.8205 6.52763L12.4009 7.39103C12.7631 7.92978 12.9999 8.5385 13.0979 9.17323C13.6747 9.22167 14.1803 9.58851 14.398 10.1283L14.8897 11.3474C15.1376 11.962 14.9583 12.665 14.4455 13.0887L12.5614 14.6458C12.0128 15.0992 11.2219 15.1193 10.6506 14.6944L9.89192 14.1301C9.88189 14.1227 9.87197 14.1151 9.86216 14.1074C9.48973 14.2075 9.09793 14.261 8.69355 14.261H6.30637C5.90201 14.261 5.51023 14.2076 5.13782 14.1074C5.12802 14.1151 5.11811 14.1227 5.10808 14.1301L4.34942 14.6944C3.77811 15.1193 2.98725 15.0992 2.43863 14.6458L0.55446 13.0887C0.0417175 12.665 -0.1376 11.962 0.110281 11.3474L0.602025 10.1283C0.819715 9.58854 1.32527 9.2217 1.90198 9.17324C2 8.5385 2.2368 7.92978 2.59897 7.39103L3.17938 6.52763C3.67818 5.78562 4.02557 4.9539 4.20193 4.07944L4.53918 2.40715ZM10.8445 9.47585C10.6345 9.63293 10.4642 9.84382 10.3561 10.0938L9.58799 11.8713C9.20026 12.0979 8.75209 12.2237 8.28465 12.2237H6.7153C6.24789 12.2237 5.79975 12.0979 5.41203 11.8714L4.64386 10.0938C4.53581 9.8438 4.36552 9.6329 4.15546 9.47582C4.18121 9.15355 4.2689 8.83503 4.41853 8.53826L5.67678 6.04259L5.68433 6.05007C6.68715 7.04458 8.31304 7.04458 9.31585 6.05007L9.32324 6.04274L10.5814 8.53825C10.7311 8.83504 10.8187 9.15357 10.8445 9.47585ZM9.04068 4.26906V3.05592H8.01353V3.85713C8.23151 3.90123 8.44506 3.97371 8.64848 4.07458L9.04068 4.26906ZM6.98638 3.85718V3.05592H5.95923V4.26919L6.3517 4.07458C6.55504 3.97375 6.7685 3.90129 6.98638 3.85718ZM2.03255 10.1864C1.82255 10.1864 1.6337 10.3132 1.55571 10.5066L1.06397 11.7257C0.981339 11.9306 1.04111 12.1649 1.21203 12.3062L3.0962 13.8633C3.27907 14.0144 3.54269 14.0211 3.73313 13.8795L4.49179 13.3152C4.6813 13.1743 4.74901 12.923 4.6557 12.7071L3.69976 10.4951C3.61884 10.3078 3.43316 10.1864 3.22771 10.1864H2.03255ZM13.4443 10.5066C13.3663 10.3132 13.1775 10.1864 12.9674 10.1864H11.7723C11.5668 10.1864 11.3812 10.3078 11.3002 10.4951L10.3443 12.7071C10.251 12.923 10.3187 13.1743 10.5082 13.3152L11.2669 13.8795C11.4573 14.0211 11.7209 14.0144 11.9038 13.8633L13.788 12.3062C13.9589 12.1649 14.0187 11.9306 13.936 11.7257L13.4443 10.5066ZM6.81106 4.98568C7.24481 4.7706 7.75537 4.7706 8.18912 4.98568L8.68739 5.23275L8.58955 5.32978C7.98786 5.92649 7.01232 5.92649 6.41063 5.32978L6.31279 5.23275L6.81106 4.98568Z"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
13
components/Icons/MacLogo.vue
Normal file
13
components/Icons/MacLogo.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
viewBox="0 0 814 1000"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
8
components/Icons/SSOLogo.vue
Normal file
8
components/Icons/SSOLogo.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M9.41 20H6.5c-1.5 0-2.82-.5-3.89-1.57C1.54 17.38 1 16.09 1 14.58q0-1.95 1.17-3.48a5.25 5.25 0 0 1 3.08-1.95c.42-1.53 1.25-2.77 2.5-3.72C9 4.5 10.42 4 12 4c1.95 0 3.61.68 4.96 2.04C18.32 7.39 19 9.05 19 11c1.15.13 2.11.63 2.86 1.5c.64.73 1 1.56 1.1 2.5H18a5.01 5.01 0 0 0-4-2c-2.8 0-5 2.2-5 5c0 .72.15 1.39.41 2M23 17v2h-2v2h-2v-2h-2.2c-.4 1.2-1.5 2-2.8 2c-1.7 0-3-1.3-3-3s1.3-3 3-3c1.3 0 2.4.8 2.8 2zm-8 1c0-.5-.4-1-1-1s-1 .5-1 1s.4 1 1 1s1-.5 1-1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M15 9H15.01M15 15C18.3137 15 21 12.3137 21 9C21 5.68629 18.3137 3 15 3C11.6863 3 9 5.68629 9 9C9 9.27368 9.01832 9.54308 9.05381 9.80704C9.11218 10.2412 9.14136 10.4583 9.12172 10.5956C9.10125 10.7387 9.0752 10.8157 9.00469 10.9419C8.937 11.063 8.81771 11.1823 8.57913 11.4209L3.46863 16.5314C3.29568 16.7043 3.2092 16.7908 3.14736 16.8917C3.09253 16.9812 3.05213 17.0787 3.02763 17.1808C3 17.2959 3 17.4182 3 17.6627V19.4C3 19.9601 3 20.2401 3.10899 20.454C3.20487 20.6422 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H6.33726C6.58185 21 6.70414 21 6.81923 20.9724C6.92127 20.9479 7.01881 20.9075 7.10828 20.8526C7.2092 20.7908 7.29568 20.7043 7.46863 20.5314L12.5791 15.4209C12.8177 15.1823 12.937 15.063 13.0581 14.9953C13.1843 14.9248 13.2613 14.8987 13.4044 14.8783C13.5417 14.8586 13.7588 14.8878 14.193 14.9462C14.4569 14.9817 14.7263 15 15 15Z"
|
d="M15 9H15.01M15 15C18.3137 15 21 12.3137 21 9C21 5.68629 18.3137 3 15 3C11.6863 3 9 5.68629 9 9C9 9.27368 9.01832 9.54308 9.05381 9.80704C9.11218 10.2412 9.14136 10.4583 9.12172 10.5956C9.10125 10.7387 9.0752 10.8157 9.00469 10.9419C8.937 11.063 8.81771 11.1823 8.57913 11.4209L3.46863 16.5314C3.29568 16.7043 3.2092 16.7908 3.14736 16.8917C3.09253 16.9812 3.05213 17.0787 3.02763 17.1808C3 17.2959 3 17.4182 3 17.6627V19.4C3 19.9601 3 20.2401 3.10899 20.454C3.20487 20.6422 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H6.33726C6.58185 21 6.70414 21 6.81923 20.9724C6.92127 20.9479 7.01881 20.9075 7.10828 20.8526C7.2092 20.7908 7.29568 20.7043 7.46863 20.5314L12.5791 15.4209C12.8177 15.1823 12.937 15.063 13.0581 14.9953C13.1843 14.9248 13.2613 14.8987 13.4044 14.8783C13.5417 14.8586 13.7588 14.8878 14.193 14.9462C14.4569 14.9817 14.7263 15 15 15Z"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg
|
<svg
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 1920 1920"
|
viewBox="0 0 1920 1920"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M1863.53 1016.437c31.171 0 56.47 25.299 56.47 56.47v790.589c0 16.376-7.115 31.849-19.313 42.465-10.39 9.149-23.605 14.005-37.158 14.005-2.484 0-5.082-.113-7.567-.452l-903.53-123.331c-28.008-3.84-48.903-27.784-48.903-56.02v-667.256c0-31.171 25.3-56.47 56.471-56.47Zm-1129.412 0c31.171 0 56.47 25.299 56.47 56.47v634.504c0 16.376-7.115 31.85-19.426 42.579-10.39 9.035-23.491 13.891-37.044 13.891-2.485 0-5.196-.113-7.68-.564L48.79 1669.35C20.78 1665.51 0 1641.68 0 1613.444v-540.537c0-31.171 25.299-56.47 56.47-56.47Zm-7.726-859.855c16.151-2.372 32.415 2.597 44.725 13.327 12.424 10.73 19.426 26.315 19.426 42.579V846.99c0 31.285-25.186 56.47-56.47 56.47H56.424c-31.171 0-56.47-25.185-56.47-56.47V306.455c0-28.123 20.781-52.066 48.79-55.906ZM1855.974.474c16.15-2.033 32.414 2.71 44.724 13.44 12.198 10.73 19.313 26.203 19.313 42.466v790.588c0 31.285-25.299 56.471-56.47 56.471H960.01c-31.171 0-56.47-25.186-56.47-56.47V179.711c0-28.235 20.78-52.066 48.903-55.906Z"
|
d="M1863.53 1016.437c31.171 0 56.47 25.299 56.47 56.47v790.589c0 16.376-7.115 31.849-19.313 42.465-10.39 9.149-23.605 14.005-37.158 14.005-2.484 0-5.082-.113-7.567-.452l-903.53-123.331c-28.008-3.84-48.903-27.784-48.903-56.02v-667.256c0-31.171 25.3-56.47 56.471-56.47Zm-1129.412 0c31.171 0 56.47 25.299 56.47 56.47v634.504c0 16.376-7.115 31.85-19.426 42.579-10.39 9.035-23.491 13.891-37.044 13.891-2.485 0-5.196-.113-7.68-.564L48.79 1669.35C20.78 1665.51 0 1641.68 0 1613.444v-540.537c0-31.171 25.299-56.47 56.47-56.47Zm-7.726-859.855c16.151-2.372 32.415 2.597 44.725 13.327 12.424 10.73 19.426 26.315 19.426 42.579V846.99c0 31.285-25.186 56.47-56.47 56.47H56.424c-31.171 0-56.47-25.185-56.47-56.47V306.455c0-28.123 20.781-52.066 48.79-55.906ZM1855.974.474c16.15-2.033 32.414 2.71 44.724 13.44 12.198 10.73 19.313 26.203 19.313 42.466v790.588c0 31.285-25.299 56.471-56.47 56.471H960.01c-31.171 0-56.47-25.186-56.47-56.47V179.711c0-28.235 20.78-52.066 48.903-55.906Z"
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
29
components/LanguageSelector.vue
Normal file
29
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
components/LanguageSelectorListbox.vue
Normal file
155
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 { locales, locale: currLocale, setLocale } = useI18n();
|
||||||
|
|
||||||
|
function changeLocale(locale: Locale) {
|
||||||
|
setLocale(locale);
|
||||||
|
|
||||||
|
// dynamically update the HTML attributes for language and direction
|
||||||
|
// this is necessary for proper rendering of the page in the new language
|
||||||
|
useHead({
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: locale,
|
||||||
|
dir: locales.value.find((l) => l.code === locale)?.dir || "ltr",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeToEmoji(local: string): string {
|
||||||
|
switch (local) {
|
||||||
|
// Default locale
|
||||||
|
case "en":
|
||||||
|
case "en-us":
|
||||||
|
return "🇺🇸";
|
||||||
|
|
||||||
|
case "en-gb":
|
||||||
|
return "🇬🇧";
|
||||||
|
case "en-ca":
|
||||||
|
return "🇨🇦";
|
||||||
|
case "en-au":
|
||||||
|
return "🇦🇺";
|
||||||
|
case "en-pirate":
|
||||||
|
return "🏴☠️";
|
||||||
|
case "fr":
|
||||||
|
return "🇫🇷";
|
||||||
|
case "de":
|
||||||
|
return "🇩🇪";
|
||||||
|
case "es":
|
||||||
|
return "🇪🇸";
|
||||||
|
case "it":
|
||||||
|
return "🇮🇹";
|
||||||
|
case "zh":
|
||||||
|
return "🇨🇳";
|
||||||
|
case "zh-tw":
|
||||||
|
return "🇹🇼";
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return "❓";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wiredLocale = computed({
|
||||||
|
get() {
|
||||||
|
return currLocale.value;
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
changeLocale(v);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const currentLocaleInformation = computed(() =>
|
||||||
|
locales.value.find((e) => e.code == wiredLocale.value),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
27
components/LogLine.vue
Normal file
27
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>
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg class="text-blue-400" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
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" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
246
components/Modal/AddCompanyGame.vue
Normal file
246
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<(typeof metadataGames.value)[number]>();
|
||||||
|
const developed = ref(false);
|
||||||
|
const published = ref(false);
|
||||||
|
const addGameLoading = ref(false);
|
||||||
|
const addError = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
|
async function addGame() {
|
||||||
|
if (!currentGame.value) return;
|
||||||
|
addGameLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const game = await $dropFetch("/api/v1/admin/company/:id/game", {
|
||||||
|
method: "POST",
|
||||||
|
params: { id: props.companyId },
|
||||||
|
body: {
|
||||||
|
id: currentGame.value.id,
|
||||||
|
developed: developed.value,
|
||||||
|
published: published.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit("created", game, published.value, developed.value);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof FetchError) {
|
||||||
|
addError.value = e.statusMessage ?? e.message ?? t("errors.unknown");
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
currentGame.value = undefined;
|
||||||
|
developed.value = false;
|
||||||
|
published.value = false;
|
||||||
|
addGameLoading.value = false;
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
115
components/Modal/CreateCollection.vue
Normal file
115
components/Modal/CreateCollection.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<ModalTemplate v-model="open">
|
||||||
|
<template #default>
|
||||||
|
<div>
|
||||||
|
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||||
|
{{ $t("library.collection.create") }}
|
||||||
|
</DialogTitle>
|
||||||
|
<p class="mt-1 text-zinc-400 text-sm">
|
||||||
|
{{ $t("library.collection.createDesc") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<form @submit.prevent="() => createCollection()">
|
||||||
|
<input
|
||||||
|
v-model="collectionName"
|
||||||
|
type="text"
|
||||||
|
: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" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #buttons="{ close }">
|
||||||
|
<LoadingButton
|
||||||
|
:loading="createCollectionLoading"
|
||||||
|
:disabled="!collectionName"
|
||||||
|
class="w-full sm:w-fit"
|
||||||
|
@click="() => createCollection()"
|
||||||
|
>
|
||||||
|
{{ $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 { CollectionEntryModel, GameModel } from "~/prisma/client/models";
|
||||||
|
import type { SerializeObject } from "nitropack";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
gameId?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: [collectionId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const open = defineModel<boolean>({ required: true });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const collectionName = ref("");
|
||||||
|
const createCollectionLoading = ref(false);
|
||||||
|
const collections = await useCollections();
|
||||||
|
|
||||||
|
async function createCollection() {
|
||||||
|
if (!collectionName.value || createCollectionLoading.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
createCollectionLoading.value = true;
|
||||||
|
|
||||||
|
// Create the collection
|
||||||
|
const response = await $dropFetch("/api/v1/collection", {
|
||||||
|
method: "POST",
|
||||||
|
body: { name: collectionName.value },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the game if provided
|
||||||
|
if (props.gameId) {
|
||||||
|
const entry = await $dropFetch<
|
||||||
|
CollectionEntryModel & { game: SerializeObject<GameModel> }
|
||||||
|
>(`/api/v1/collection/${response.id}/entry`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { id: props.gameId },
|
||||||
|
});
|
||||||
|
response.entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
collections.value.push(response);
|
||||||
|
|
||||||
|
// Reset and emit
|
||||||
|
collectionName.value = "";
|
||||||
|
open.value = false;
|
||||||
|
|
||||||
|
emit("created", response.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create collection:", error);
|
||||||
|
|
||||||
|
const err = error as { statusMessage?: string };
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.library.collection.create.title"),
|
||||||
|
description: t("errors.library.collection.create.desc", [
|
||||||
|
err?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
(_, c) => c(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
createCollectionLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
148
components/Modal/CreateCompany.vue
Normal file
148
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
components/Modal/CreateTag.vue
Normal file
78
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
components/Modal/CreateToken.vue
Normal file
267
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>
|
||||||
80
components/Modal/DeleteCollection.vue
Normal file
80
components/Modal/DeleteCollection.vue
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<ModalTemplate :model-value="!!collection">
|
||||||
|
<template #default>
|
||||||
|
<div>
|
||||||
|
<DialogTitle
|
||||||
|
as="h3"
|
||||||
|
class="text-lg font-bold font-display text-zinc-100"
|
||||||
|
>
|
||||||
|
{{ $t("library.collection.delete") }}
|
||||||
|
</DialogTitle>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
|
{{ $t("common.deleteConfirm", [collection?.name]) }}
|
||||||
|
</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="() => deleteCollection()"
|
||||||
|
>
|
||||||
|
{{ $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)"
|
||||||
|
>
|
||||||
|
{{ $t("cancel") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CollectionModel } from "~/prisma/client/models";
|
||||||
|
import { DialogTitle } from "@headlessui/vue";
|
||||||
|
|
||||||
|
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/:id`, {
|
||||||
|
method: "DELETE",
|
||||||
|
params: {
|
||||||
|
id: collection.value.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const index = collections.value.findIndex(
|
||||||
|
(e) => e.id == collection.value?.id,
|
||||||
|
);
|
||||||
|
collections.value.splice(index, 1);
|
||||||
|
|
||||||
|
collection.value = undefined;
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.library.add.title"),
|
||||||
|
description: t("errors.library.add.desc", [
|
||||||
|
// @ts-expect-error attempt to display statusMessage on error
|
||||||
|
e?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
(_, c) => c(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
deleteLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
84
components/Modal/DeleteNews.vue
Normal file
84
components/Modal/DeleteNews.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<ModalTemplate :model-value="!!article">
|
||||||
|
<template #default>
|
||||||
|
<div>
|
||||||
|
<DialogTitle
|
||||||
|
as="h3"
|
||||||
|
class="text-lg font-bold font-display text-zinc-100"
|
||||||
|
>
|
||||||
|
{{ $t("news.delete") }}
|
||||||
|
</DialogTitle>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
|
{{ $t("common.deleteConfirm", [article?.title]) }}
|
||||||
|
</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="() => deleteArticle()"
|
||||||
|
>
|
||||||
|
{{ $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)"
|
||||||
|
>
|
||||||
|
{{ $t("cancel") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogTitle } from "@headlessui/vue";
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteArticle() {
|
||||||
|
try {
|
||||||
|
if (!article.value || !news.value) return;
|
||||||
|
|
||||||
|
deleteLoading.value = true;
|
||||||
|
await $dropFetch(`/api/v1/admin/news/${article.value.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
const index = news.value.findIndex((e) => e.id == article.value?.id);
|
||||||
|
news.value.splice(index, 1);
|
||||||
|
|
||||||
|
article.value = undefined;
|
||||||
|
router.push("/news");
|
||||||
|
} catch (e) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: t("errors.news.article.delete.title"),
|
||||||
|
description: t("errors.news.article.delete.desc", [
|
||||||
|
// @ts-expect-error attempt to display statusMessage on error
|
||||||
|
e?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
(_, c) => c(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
deleteLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
75
components/Modal/DeleteUser.vue
Normal file
75
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 statusMessage on error
|
||||||
|
e?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
(_, c) => c(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
deleteLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -49,41 +49,46 @@
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
||||||
>Upload file</span
|
>{{ $t("uploadFile") }}</span
|
||||||
>
|
>
|
||||||
<p class="mt-1 text-xs text-zinc-400" v-if="currentFile">
|
<div v-if="currentFileList">
|
||||||
{{ currentFile.name }}
|
<p
|
||||||
</p>
|
v-for="currentFile in currentFileList"
|
||||||
|
:key="currentFile"
|
||||||
|
class="mt-1 text-[10px] text-zinc-500 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ currentFile }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="file-upload"
|
||||||
:accept="props.accept"
|
:accept="props.accept"
|
||||||
@change="(e) => file = (e.target as any)?.files"
|
|
||||||
class="hidden"
|
class="hidden"
|
||||||
type="file"
|
type="file"
|
||||||
id="file-upload"
|
:multiple="props.multiple"
|
||||||
|
@change="(e: Event) => (file = (e.target as any)?.files)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
:disabled="currentFile == undefined"
|
:disabled="currentFiles == undefined"
|
||||||
type="button"
|
type="button"
|
||||||
:loading="uploadLoading"
|
:loading="uploadLoading"
|
||||||
|
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
|
||||||
@click="() => uploadFile_wrapper()"
|
@click="() => uploadFile_wrapper()"
|
||||||
:class="[
|
|
||||||
'inline-flex w-full shadow-sm sm:ml-3 sm:w-auto',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
Upload
|
{{ $t("upload") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
|
ref="cancelButtonRef"
|
||||||
type="button"
|
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"
|
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"
|
@click="open = false"
|
||||||
ref="cancelButtonRef"
|
|
||||||
>
|
>
|
||||||
Cancel
|
{{ $t("cancel") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="uploadError" class="mt-3 rounded-md bg-red-600/10 p-4">
|
<div v-if="uploadError" class="mt-3 rounded-md bg-red-600/10 p-4">
|
||||||
@ -114,20 +119,31 @@ import { ref } from "vue";
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPanel,
|
DialogPanel,
|
||||||
DialogTitle,
|
|
||||||
TransitionChild,
|
TransitionChild,
|
||||||
TransitionRoot,
|
TransitionRoot,
|
||||||
} from "@headlessui/vue";
|
} from "@headlessui/vue";
|
||||||
import { ArrowUpTrayIcon } from "@heroicons/vue/20/solid";
|
import { ArrowUpTrayIcon } from "@heroicons/vue/20/solid";
|
||||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
const open = defineModel<boolean>();
|
const open = defineModel<boolean>({
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const file = ref<FileList | undefined>();
|
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<{
|
const props = defineProps<{
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
accept: string;
|
accept: string;
|
||||||
|
multiple?: boolean;
|
||||||
options?: { [key: string]: string };
|
options?: { [key: string]: string };
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits(["upload"]);
|
const emit = defineEmits(["upload"]);
|
||||||
@ -135,10 +151,12 @@ const emit = defineEmits(["upload"]);
|
|||||||
const uploadLoading = ref(false);
|
const uploadLoading = ref(false);
|
||||||
const uploadError = ref<string | undefined>();
|
const uploadError = ref<string | undefined>();
|
||||||
async function uploadFile() {
|
async function uploadFile() {
|
||||||
if (!currentFile.value) return;
|
if (!currentFiles.value) return;
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", currentFile.value);
|
for (const file of currentFiles.value) {
|
||||||
|
form.append(file.name, file);
|
||||||
|
}
|
||||||
|
|
||||||
if (props.options) {
|
if (props.options) {
|
||||||
for (const [key, value] of Object.entries(props.options)) {
|
for (const [key, value] of Object.entries(props.options)) {
|
||||||
@ -146,7 +164,10 @@ async function uploadFile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await $fetch(props.endpoint, { method: "POST", body: form });
|
const result = await $dropFetch(props.endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
open.value = false;
|
open.value = false;
|
||||||
file.value = undefined;
|
file.value = undefined;
|
||||||
emit("upload", result);
|
emit("upload", result);
|
||||||
@ -156,7 +177,7 @@ function uploadFile_wrapper() {
|
|||||||
uploadLoading.value = true;
|
uploadLoading.value = true;
|
||||||
uploadFile()
|
uploadFile()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
uploadError.value = error.statusMessage ?? "An unknown error occurred.";
|
uploadError.value = error.statusMessage ?? t("errors.unknown");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
uploadLoading.value = false;
|
uploadLoading.value = false;
|
||||||
115
components/MultiItemSelector.vue
Normal file
115
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>
|
||||||
455
components/NewsArticleCreateButton.vue
Normal file
455
components/NewsArticleCreateButton.vue
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<!-- Create article button - only show for admin users -->
|
||||||
|
<button
|
||||||
|
v-if="user?.admin"
|
||||||
|
class="transition inline-flex w-full items-center px-4 gap-x-2 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 font-semibold text-sm shadow-sm"
|
||||||
|
@click="modalOpen = !modalOpen"
|
||||||
|
>
|
||||||
|
<PlusIcon
|
||||||
|
class="h-5 w-5 transition-transform duration-200"
|
||||||
|
:class="{ 'rotate-90': modalOpen }"
|
||||||
|
/>
|
||||||
|
<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">
|
||||||
|
{{ $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">{{
|
||||||
|
$t("news.article.titles")
|
||||||
|
}}</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
v-model="newArticle.title"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
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"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="excerpt"
|
||||||
|
class="block text-sm font-medium text-zinc-400"
|
||||||
|
>{{ $t("news.article.shortDesc") }}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="excerpt"
|
||||||
|
v-model="newArticle.description"
|
||||||
|
type="text"
|
||||||
|
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"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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 -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="shortcut in markdownShortcuts"
|
||||||
|
:key="shortcut.label"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-1 text-sm rounded bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors"
|
||||||
|
@click="applyMarkdown(shortcut)"
|
||||||
|
>
|
||||||
|
{{ shortcut.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-rows-2 sm:grid-cols-2 sm:grid-rows-1 gap-4 h-[400px]"
|
||||||
|
>
|
||||||
|
<!-- Editor -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm text-zinc-500 mb-2">{{
|
||||||
|
$t("news.article.editor")
|
||||||
|
}}</span>
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
ref="contentEditor"
|
||||||
|
v-model="newArticle.content"
|
||||||
|
class="flex-1 rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500 font-mono resize-none"
|
||||||
|
required
|
||||||
|
@keydown="handleContentKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="prose prose-invert prose-sm h-full overflow-y-auto"
|
||||||
|
v-html="markdownPreview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-zinc-500">
|
||||||
|
{{ $t("news.article.editorGuide") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="file-upload"
|
||||||
|
class="group cursor-pointer transition relative block w-full rounded-lg border-2 border-dashed border-zinc-600 p-12 text-center hover:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<ArrowUpTrayIcon
|
||||||
|
class="transition mx-auto h-6 w-6 text-zinc-600 group-hover:text-zinc-700"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
||||||
|
>{{ $t("news.article.uploadCover") }}</span
|
||||||
|
>
|
||||||
|
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
|
||||||
|
{{ currentFile.name }}
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
@change="(e: Event) => (file = (e.target as any)?.files)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
:key="tag"
|
||||||
|
class="inline-flex items-center gap-x-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-600/80 text-white"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-white hover:text-white/80"
|
||||||
|
@click="removeTag(tag)"
|
||||||
|
>
|
||||||
|
<XMarkIcon class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-x-2">
|
||||||
|
<input
|
||||||
|
v-model="newTagInput"
|
||||||
|
type="text"
|
||||||
|
: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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||||
|
@click="addTag"
|
||||||
|
>
|
||||||
|
{{ $t("news.article.add") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="hidden" />
|
||||||
|
|
||||||
|
<div v-if="error" 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">
|
||||||
|
{{ error }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<template #buttons>
|
||||||
|
<LoadingButton
|
||||||
|
:loading="loading"
|
||||||
|
class="bg-blue-600 text-white hover:bg-blue-500"
|
||||||
|
:disabled="!isValidArticle"
|
||||||
|
@click="() => createArticle()"
|
||||||
|
>
|
||||||
|
{{ $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)"
|
||||||
|
>
|
||||||
|
{{ $t("cancel") }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ArrowUpTrayIcon,
|
||||||
|
PlusIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from "@heroicons/vue/24/solid";
|
||||||
|
import { micromark } from "micromark";
|
||||||
|
|
||||||
|
const news = useNews();
|
||||||
|
if (!news.value) {
|
||||||
|
news.value = await fetchNews();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
const modalOpen = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const newTagInput = ref("");
|
||||||
|
|
||||||
|
const newArticle = ref({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
content: "",
|
||||||
|
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
|
||||||
|
return micromark(newArticle.value.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = ref<FileList | undefined>();
|
||||||
|
const currentFile = computed(() => file.value?.item(0));
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const error = ref<string | undefined>();
|
||||||
|
|
||||||
|
const contentEditor = ref<HTMLTextAreaElement>();
|
||||||
|
|
||||||
|
const markdownShortcuts = [
|
||||||
|
{
|
||||||
|
label: t("editor.bold"),
|
||||||
|
prefix: "**",
|
||||||
|
suffix: "**",
|
||||||
|
placeholder: t("editor.boldPlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.italic"),
|
||||||
|
prefix: "_",
|
||||||
|
suffix: "_",
|
||||||
|
placeholder: t("editor.italicPlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.link"),
|
||||||
|
prefix: "[",
|
||||||
|
suffix: "](url)",
|
||||||
|
placeholder: t("editor.linkPlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.code"),
|
||||||
|
prefix: "`",
|
||||||
|
suffix: "`",
|
||||||
|
placeholder: t("editor.codePlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.listItem"),
|
||||||
|
prefix: "- ",
|
||||||
|
suffix: "",
|
||||||
|
placeholder: t("editor.listItemPlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.heading"),
|
||||||
|
prefix: "## ",
|
||||||
|
suffix: "",
|
||||||
|
placeholder: t("editor.headingPlaceholder"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleContentKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const textarea = contentEditor.value;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const text = textarea.value;
|
||||||
|
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
|
||||||
|
const currentLine = text.slice(lineStart, start);
|
||||||
|
|
||||||
|
// Check if the current line starts with a list marker
|
||||||
|
const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/);
|
||||||
|
let insertion = "\n";
|
||||||
|
|
||||||
|
if (listMatch) {
|
||||||
|
// If the line is empty except for the list marker, end the list
|
||||||
|
if (currentLine.trim() === listMatch[0].trim()) {
|
||||||
|
const removeLength = currentLine.length;
|
||||||
|
newArticle.value.content =
|
||||||
|
text.slice(0, lineStart) + text.slice(lineStart + removeLength);
|
||||||
|
|
||||||
|
// Move cursor to new position after removing the list marker
|
||||||
|
nextTick(() => {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = lineStart;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise, continue the list
|
||||||
|
insertion = "\n" + listMatch[1] + listMatch[2] + " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
newArticle.value.content =
|
||||||
|
text.slice(0, start) + insertion + text.slice(start);
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd =
|
||||||
|
start + insertion.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag() {
|
||||||
|
const tag = newTagInput.value.trim();
|
||||||
|
if (tag && !newArticle.value.tags.includes(tag)) {
|
||||||
|
newArticle.value.tags.push(tag);
|
||||||
|
newTagInput.value = ""; // Clear the input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tagToRemove: string) {
|
||||||
|
newArticle.value.tags = newArticle.value.tags.filter(
|
||||||
|
(tag) => tag !== tagToRemove,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMarkdown(shortcut: (typeof markdownShortcuts)[0]) {
|
||||||
|
const textarea = contentEditor.value;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = textarea.value;
|
||||||
|
|
||||||
|
const selectedText = text.substring(start, end);
|
||||||
|
const replacement = selectedText || shortcut.placeholder;
|
||||||
|
|
||||||
|
const newText =
|
||||||
|
text.substring(0, start) +
|
||||||
|
shortcut.prefix +
|
||||||
|
replacement +
|
||||||
|
shortcut.suffix +
|
||||||
|
text.substring(end);
|
||||||
|
|
||||||
|
newArticle.value.content = newText;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
textarea.focus();
|
||||||
|
const newStart = start + shortcut.prefix.length;
|
||||||
|
const newEnd = newStart + replacement.length;
|
||||||
|
textarea.setSelectionRange(newStart, newEnd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createArticle() {
|
||||||
|
if (!user.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
if (currentFile.value) {
|
||||||
|
formData.append("image", currentFile.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.append("title", newArticle.value.title);
|
||||||
|
formData.append("description", newArticle.value.description);
|
||||||
|
formData.append("content", newArticle.value.content);
|
||||||
|
formData.append("tags", JSON.stringify(newArticle.value.tags));
|
||||||
|
|
||||||
|
const createdArticle = await $dropFetch("/api/v1/admin/news", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
news.value?.push(createdArticle);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
newArticle.value = {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
content: "",
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
modalOpen.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
|
error.value = e?.statusMessage ?? t("errors.unknown");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prose {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
background: #27272a;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
background: #18181b;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -13,22 +13,27 @@
|
|||||||
v-if="notification.actions.length > 0"
|
v-if="notification.actions.length > 0"
|
||||||
class="mt-3 flex space-x-7"
|
class="mt-3 flex space-x-7"
|
||||||
>
|
>
|
||||||
<button
|
<NuxtLink
|
||||||
|
v-for="[name, link] in notification.actions.map((e) =>
|
||||||
|
e.split('|'),
|
||||||
|
)"
|
||||||
|
:key="name"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-white text-sm font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
:href="link"
|
||||||
|
class="rounded-md text-sm font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Undo
|
{{ name }}
|
||||||
</button>
|
</NuxtLink>
|
||||||
<!-- todo -->
|
<!-- todo -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 flex shrink-0">
|
<div class="ml-4 flex shrink-0">
|
||||||
<button
|
<button
|
||||||
@click="() => deleteMe()"
|
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
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" />
|
<XMarkIcon class="size-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -39,17 +44,20 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
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() {
|
async function deleteMe() {
|
||||||
await $fetch(`/api/v1/notifications/${props.notification.id}`, {
|
await $dropFetch(`/api/v1/notifications/:id`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
params: {
|
||||||
|
id: props.notification.id,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
const indexOfMe = notifications.value.findIndex(
|
const indexOfMe = notifications.value.findIndex(
|
||||||
(e) => e.id === props.notification.id
|
(e) => e.id === props.notification.id,
|
||||||
);
|
);
|
||||||
// Delete me
|
// Delete me
|
||||||
notifications.value.splice(indexOfMe, 1);
|
notifications.value.splice(indexOfMe, 1);
|
||||||
22
components/OptionWrapper.vue
Normal file
22
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,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex rounded px-2 py-2 bg-zinc-900 text-zinc-600">
|
<div class="flex rounded px-2 py-2 bg-zinc-900 text-zinc-600">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Listbox as="div" v-model="model">
|
<Listbox v-model="typedModel" as="div">
|
||||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
><slot
|
><slot
|
||||||
/></ListboxLabel>
|
/></ListboxLabel>
|
||||||
@ -7,15 +7,15 @@
|
|||||||
<ListboxButton
|
<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"
|
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 && values[model]" class="flex items-center">
|
<span v-if="model" class="flex items-center">
|
||||||
<component
|
<component
|
||||||
:is="values[model].icon"
|
:is="PLATFORM_ICONS[model]"
|
||||||
alt=""
|
alt=""
|
||||||
class="h-5 w-5 flex-shrink-0 text-blue-600"
|
class="h-5 w-5 flex-shrink-0 text-blue-600"
|
||||||
/>
|
/>
|
||||||
<span class="ml-3 block truncate">{{ values[model].name }}</span>
|
<span class="ml-3 block truncate">{{ model }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>Please select a platform...</span>
|
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2"
|
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"
|
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
|
<ListboxOption
|
||||||
as="template"
|
v-for="[name, value] in Object.entries(values)"
|
||||||
v-for="[value, options] in Object.entries(values)"
|
|
||||||
:key="value"
|
:key="value"
|
||||||
:value="value"
|
|
||||||
v-slot="{ active, selected }"
|
v-slot="{ active, selected }"
|
||||||
|
as="template"
|
||||||
|
:value="value"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
:class="[
|
:class="[
|
||||||
@ -46,14 +46,14 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<component
|
<component
|
||||||
:is="options.icon"
|
:is="PLATFORM_ICONS[value]"
|
||||||
alt=""
|
alt=""
|
||||||
:class="[
|
:class="[
|
||||||
active ? 'text-zinc-100' : 'text-blue-600',
|
active ? 'text-zinc-100' : 'text-blue-600',
|
||||||
'h-5 w-5 flex-shrink-0',
|
'h-5 w-5 flex-shrink-0',
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<span class="ml-3 block truncate">{{ options.name }}</span>
|
<span class="ml-3 block truncate">{{ name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@ -74,7 +74,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { IconsLinuxLogo, IconsWindowsLogo } from "#components";
|
|
||||||
import {
|
import {
|
||||||
Listbox,
|
Listbox,
|
||||||
ListboxButton,
|
ListboxButton,
|
||||||
@ -83,18 +82,18 @@ import {
|
|||||||
ListboxOptions,
|
ListboxOptions,
|
||||||
} from "@headlessui/vue";
|
} from "@headlessui/vue";
|
||||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||||
import type { Component } from "vue";
|
|
||||||
|
|
||||||
const model = defineModel<string>();
|
const model = defineModel<PlatformClient | undefined>();
|
||||||
|
|
||||||
const values: { [key: string]: { name: string; icon: Component } } = {
|
const typedModel = computed<PlatformClient | null>({
|
||||||
Linux: {
|
get() {
|
||||||
name: "Linux",
|
return model.value || null;
|
||||||
icon: IconsLinuxLogo,
|
|
||||||
},
|
},
|
||||||
Windows: {
|
set(v) {
|
||||||
name: "Windows",
|
if (v === null) return (model.value = undefined);
|
||||||
icon: IconsWindowsLogo,
|
model.value = v;
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const values = Object.fromEntries(Object.entries(PlatformClient));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
32
components/RelativeTime.vue
Normal file
32
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
components/Setup/Account.vue
Normal file
159
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
components/Setup/Library.vue
Normal file
15
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>
|
||||||
19
components/SkeletonCard.vue
Normal file
19
components/SkeletonCard.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-lg w-48 h-64 bg-zinc-800/50 flex items-center justify-center transition-all duration-300 hover:bg-zinc-800',
|
||||||
|
props.loading && 'animate-pulse',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<p class="text-zinc-700 text-sm font-semibold font-display uppercase">
|
||||||
|
{{ props.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
message?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
27
components/SourceOptions/Filesystem.vue
Normal file
27
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
components/SourceOptions/FlatFilesystem.vue
Normal file
27
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>
|
||||||
510
components/StoreView.vue
Normal file
510
components/StoreView.vue
Normal file
@ -0,0 +1,510 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<!-- Mobile filter dialog -->
|
||||||
|
<TransitionRoot as="template" :show="mobileFiltersOpen">
|
||||||
|
<Dialog
|
||||||
|
class="relative z-100 lg:hidden"
|
||||||
|
@close="mobileFiltersOpen = false"
|
||||||
|
>
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="transition-opacity ease-linear duration-300"
|
||||||
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
|
leave="transition-opacity ease-linear duration-300"
|
||||||
|
leave-from="opacity-100"
|
||||||
|
leave-to="opacity-0"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-black/25" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div class="fixed inset-0 z-40 flex">
|
||||||
|
<TransitionChild
|
||||||
|
as="template"
|
||||||
|
enter="transition ease-in-out duration-300 transform"
|
||||||
|
enter-from="translate-x-full"
|
||||||
|
enter-to="translate-x-0"
|
||||||
|
leave="transition ease-in-out duration-300 transform"
|
||||||
|
leave-from="translate-x-0"
|
||||||
|
leave-to="translate-x-full"
|
||||||
|
>
|
||||||
|
<DialogPanel
|
||||||
|
class="relative ml-auto flex size-full max-w-sm flex-col overflow-y-auto bg-zinc-900 pt-4 pb-6 shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between px-4">
|
||||||
|
<h2 class="text-lg font-medium text-zinc-100">
|
||||||
|
{{ $t("store.view.srFilters") }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="relative -mr-2 flex size-10 items-center justify-center rounded-md bg-zinc-900 p-2 text-zinc-500 hover:bg-zinc-800 focus:ring-2 focus:ring-blue-500 focus:outline-hidden"
|
||||||
|
@click="mobileFiltersOpen = false"
|
||||||
|
>
|
||||||
|
<span class="absolute -inset-0.5" />
|
||||||
|
<span class="sr-only">{{ $t("common.close") }}</span>
|
||||||
|
<XMarkIcon class="size-6" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form class="mt-4 border-t border-zinc-700">
|
||||||
|
<Disclosure
|
||||||
|
v-for="section in options"
|
||||||
|
v-slot="{ open }"
|
||||||
|
:key="section.param"
|
||||||
|
as="div"
|
||||||
|
class="border-t border-zinc-700 px-4 py-6"
|
||||||
|
>
|
||||||
|
<h3 class="-mx-2 -my-3 flow-root">
|
||||||
|
<DisclosureButton
|
||||||
|
class="flex w-full items-center justify-between bg-zinc-900 px-2 py-3 text-zinc-500 hover:text-zinc-400"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-zinc-100">{{
|
||||||
|
section.name
|
||||||
|
}}</span>
|
||||||
|
<span class="ml-6 flex items-center">
|
||||||
|
<PlusIcon
|
||||||
|
v-if="!open"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<MinusIcon v-else class="size-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</DisclosureButton>
|
||||||
|
</h3>
|
||||||
|
<DisclosurePanel class="pt-6">
|
||||||
|
<div
|
||||||
|
v-if="section.options.length <= 10"
|
||||||
|
class="gap-3 grid grid-cols-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(option, optionIdx) in section.options"
|
||||||
|
:key="option.param"
|
||||||
|
class="flex gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex h-5 shrink-0 items-center">
|
||||||
|
<div class="group grid size-4 grid-cols-1">
|
||||||
|
<input
|
||||||
|
v-if="section.multiple"
|
||||||
|
:id="`filter-${section.param}-${option}`"
|
||||||
|
v-model="
|
||||||
|
(optionValues[section.param] as any)[
|
||||||
|
option.param
|
||||||
|
]
|
||||||
|
"
|
||||||
|
:name="`${section.param}[]`"
|
||||||
|
type="checkbox"
|
||||||
|
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-900 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
:id="`filter-${section.param}`"
|
||||||
|
:value="optionValues[section.param]"
|
||||||
|
:name="`${section.param}[]`"
|
||||||
|
type="checkbox"
|
||||||
|
class="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||||
|
@update:value="
|
||||||
|
() =>
|
||||||
|
(optionValues[section.param] = option.param)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
:for="`filter-mobile-${section.param}-${optionIdx}`"
|
||||||
|
class="min-w-0 flex-1 text-zinc-400"
|
||||||
|
>{{ option.name }}</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MultiItemSelector
|
||||||
|
v-else
|
||||||
|
v-model="[optionValues[section.param] as any][0]"
|
||||||
|
:items="section.options"
|
||||||
|
/>
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
</form>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
|
||||||
|
<main class="mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div
|
||||||
|
class="flex items-baseline justify-between border-b border-zinc-700 py-6"
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Menu as="div" class="relative inline-block text-left">
|
||||||
|
<div>
|
||||||
|
<MenuButton
|
||||||
|
class="group inline-flex justify-center text-sm font-medium text-zinc-400 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{{ $t("store.view.sort") }}
|
||||||
|
<ChevronDownIcon
|
||||||
|
class="-mr-1 ml-1 size-5 shrink-0 text-gray-400 group-hover:text-zinc-100"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</MenuButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-100"
|
||||||
|
enter-from-class="transform opacity-0 scale-95"
|
||||||
|
enter-to-class="transform opacity-100 scale-100"
|
||||||
|
leave-active-class="transition ease-in duration-75"
|
||||||
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
|
leave-to-class="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<MenuItems
|
||||||
|
class="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-zinc-950 shadow-2xl ring-1 ring-white/5 focus:outline-hidden"
|
||||||
|
>
|
||||||
|
<div class="py-1">
|
||||||
|
<MenuItem
|
||||||
|
v-for="option in sorts"
|
||||||
|
:key="option.param"
|
||||||
|
v-slot="{ active }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
currentSort == option.param
|
||||||
|
? 'font-medium text-zinc-100'
|
||||||
|
: 'text-zinc-400',
|
||||||
|
active ? 'bg-zinc-900 outline-hidden' : '',
|
||||||
|
'w-full text-left block px-4 py-2 text-sm',
|
||||||
|
]"
|
||||||
|
@click.prevent="handleSortClick(option, $event)"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
<span v-if="currentSort === option.param">
|
||||||
|
{{ sortOrder === 'asc' ? '↑' : '↓' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</MenuItems>
|
||||||
|
</transition>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="false"
|
||||||
|
type="button"
|
||||||
|
class="-m-2 ml-5 p-2 text-zinc-500 hover:text-zinc-400 sm:ml-7"
|
||||||
|
>
|
||||||
|
<span class="sr-only">{{ $t("store.view.srViewGrid") }}</span>
|
||||||
|
<Squares2X2Icon class="size-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'-m-2 ml-4 p-2 sm:ml-6 lg:hidden',
|
||||||
|
filterQuery
|
||||||
|
? 'text-zinc-100 hover:text-zinc-200'
|
||||||
|
: 'text-zinc-500 hover:text-zinc-400',
|
||||||
|
]"
|
||||||
|
@click="mobileFiltersOpen = true"
|
||||||
|
>
|
||||||
|
<span class="sr-only"> {{ $t("store.view.srFilters") }} </span>
|
||||||
|
<FunnelIcon class="size-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section aria-labelledby="games-heading" class="pt-6 pb-24">
|
||||||
|
<h2 id="games-heading" class="sr-only">
|
||||||
|
{{ $t("store.view.srGames") }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-x-8 gap-y-10 lg:grid-cols-5">
|
||||||
|
<!-- Filters -->
|
||||||
|
<form class="hidden lg:block">
|
||||||
|
<Disclosure
|
||||||
|
v-for="section in options"
|
||||||
|
:key="section.param"
|
||||||
|
v-slot="{ open }"
|
||||||
|
as="div"
|
||||||
|
class="border-b border-zinc-700 py-6"
|
||||||
|
>
|
||||||
|
<h3 class="-my-3 flow-root">
|
||||||
|
<DisclosureButton
|
||||||
|
class="flex w-full items-center justify-between bg-zinc-900 py-3 text-sm text-zinc-500 hover:text-zinc-400"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-zinc-100">{{
|
||||||
|
section.name
|
||||||
|
}}</span>
|
||||||
|
<span class="ml-6 flex items-center">
|
||||||
|
<PlusIcon
|
||||||
|
v-if="!open"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<MinusIcon v-else class="size-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</DisclosureButton>
|
||||||
|
</h3>
|
||||||
|
<DisclosurePanel class="pt-6">
|
||||||
|
<div v-if="section.options.length <= 10" class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="(option, optionIdx) in section.options"
|
||||||
|
:key="option.param"
|
||||||
|
class="flex gap-3"
|
||||||
|
>
|
||||||
|
<div class="flex h-5 shrink-0 items-center">
|
||||||
|
<div class="group grid size-4 grid-cols-1">
|
||||||
|
<input
|
||||||
|
v-if="section.multiple"
|
||||||
|
:id="`filter-${section.param}-${optionIdx}`"
|
||||||
|
v-model="
|
||||||
|
(optionValues[section.param] as any)[option.param]
|
||||||
|
"
|
||||||
|
:name="`${section.param}[]`"
|
||||||
|
type="checkbox"
|
||||||
|
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
:id="`filter-${section.param}-${optionIdx}`"
|
||||||
|
:value="optionValues[section.param]"
|
||||||
|
:name="`${section.param}[]`"
|
||||||
|
type="radio"
|
||||||
|
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||||
|
@input="optionValues[section.param] = option.param"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
:for="`filter-${section.param}-${optionIdx}`"
|
||||||
|
class="text-sm text-zinc-400"
|
||||||
|
>{{ option.name }}</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MultiItemSelector
|
||||||
|
v-else
|
||||||
|
v-model="[optionValues[section.param] as any][0]"
|
||||||
|
:items="section.options"
|
||||||
|
/>
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Product grid -->
|
||||||
|
<div
|
||||||
|
v-if="games?.length ?? 0 > 0"
|
||||||
|
ref="product-grid"
|
||||||
|
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4"
|
||||||
|
>
|
||||||
|
<!-- Your content -->
|
||||||
|
<GamePanel
|
||||||
|
v-for="game in games"
|
||||||
|
:key="game.id"
|
||||||
|
:game="game"
|
||||||
|
:href="`/store/${game.id}`"
|
||||||
|
:show-title-description="showGamePanelTextDecoration"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute inset-0 bg-zinc-900/40 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
|
||||||
|
viewBox="0 0 100 101"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||||
|
fill="currentFill"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex lg:col-span-4 items-start justify-center">
|
||||||
|
<span class="uppercase text-zinc-700 font-display font-bold">{{
|
||||||
|
$t("common.noResults")
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogPanel,
|
||||||
|
Disclosure,
|
||||||
|
DisclosureButton,
|
||||||
|
DisclosurePanel,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuItems,
|
||||||
|
TransitionChild,
|
||||||
|
TransitionRoot,
|
||||||
|
} from "@headlessui/vue";
|
||||||
|
import { XMarkIcon } from "@heroicons/vue/24/outline";
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
FunnelIcon,
|
||||||
|
MinusIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Squares2X2Icon,
|
||||||
|
} from "@heroicons/vue/20/solid";
|
||||||
|
import type { SerializeObject } from "nitropack";
|
||||||
|
import type { GameModel, GameTagModel } from "~/prisma/client/models";
|
||||||
|
import MultiItemSelector from "./MultiItemSelector.vue";
|
||||||
|
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
|
||||||
|
|
||||||
|
const mobileFiltersOpen = ref(false);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
params?: { [key: string]: string };
|
||||||
|
extraOptions?: Array<StoreFilterOption>;
|
||||||
|
prefilled?: {
|
||||||
|
[key: string]: { [key: string]: string | { [key: string]: boolean } };
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const tags =
|
||||||
|
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
|
||||||
|
|
||||||
|
const sorts: Array<StoreSortOption> = [
|
||||||
|
{
|
||||||
|
name: "Default",
|
||||||
|
param: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Newest",
|
||||||
|
param: "newest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Recently Added",
|
||||||
|
param: "recent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Name",
|
||||||
|
param: "name",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const currentSort = ref(sorts[0].param);
|
||||||
|
const sortOrder = ref<"asc" | "desc">("desc");
|
||||||
|
|
||||||
|
const options: Array<StoreFilterOption> = [
|
||||||
|
...(tags.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "Tags",
|
||||||
|
param: "tags",
|
||||||
|
multiple: true,
|
||||||
|
options: tags.map((e) => ({ name: e.name, param: e.id })),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
name: "Platform",
|
||||||
|
param: "platform",
|
||||||
|
multiple: true,
|
||||||
|
options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })),
|
||||||
|
},
|
||||||
|
...(props.extraOptions ?? []),
|
||||||
|
];
|
||||||
|
const optionValues = ref<{
|
||||||
|
[key: string]: string | undefined | { [key: string]: boolean | undefined };
|
||||||
|
}>(
|
||||||
|
Object.fromEntries(
|
||||||
|
options.map((v) => [v.param, v.multiple ? {} : undefined]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Object.assign(optionValues.value, props.prefilled);
|
||||||
|
|
||||||
|
const filterQuery = computed(() => {
|
||||||
|
const query = Object.entries(optionValues.value)
|
||||||
|
.filter(
|
||||||
|
([_, v]) =>
|
||||||
|
v &&
|
||||||
|
(typeof v !== "object" || Object.values(v).filter((e) => e).length > 0),
|
||||||
|
)
|
||||||
|
.map(([n, v]) => {
|
||||||
|
if (typeof v === "string") return [`${n}=${v}`];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const enabledOptions = Object.entries(v as any).filter(([_, e]) => e);
|
||||||
|
return `${n}=${enabledOptions.map(([k, _]) => k).join(",")}`;
|
||||||
|
})
|
||||||
|
.join("&");
|
||||||
|
const extraFilters = props.params
|
||||||
|
? Object.entries(props.params)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("&")
|
||||||
|
: props.params;
|
||||||
|
return `${query}${extraFilters ? (query ? "&" : "") + extraFilters : ""}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const games = ref<Array<SerializeObject<GameModel>>>();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const productGrid = useTemplateRef<HTMLElement>("product-grid");
|
||||||
|
|
||||||
|
const { reset } = useInfiniteScroll(
|
||||||
|
productGrid,
|
||||||
|
async () => await updateGames(filterQuery.value, false),
|
||||||
|
{
|
||||||
|
distance: 10,
|
||||||
|
canLoadMore: () => {
|
||||||
|
return canLoadMore.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const canLoadMore = ref(true);
|
||||||
|
async function updateGames(query: string, resetGames: boolean) {
|
||||||
|
loading.value = true;
|
||||||
|
games.value ??= [];
|
||||||
|
const newValues = await $dropFetch<{
|
||||||
|
results: Array<SerializeObject<GameModel>>;
|
||||||
|
count: number;
|
||||||
|
}>(
|
||||||
|
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}&order=${sortOrder.value}${query ? "&" + query : ""}`,
|
||||||
|
);
|
||||||
|
if (resetGames) {
|
||||||
|
games.value = newValues.results;
|
||||||
|
if (import.meta.client) await reset();
|
||||||
|
} else {
|
||||||
|
games.value.push(...newValues.results);
|
||||||
|
}
|
||||||
|
canLoadMore.value = games.value.length < newValues.count;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
watch(filterQuery, (newUrl) => {
|
||||||
|
updateGames(newUrl, true);
|
||||||
|
});
|
||||||
|
watch(currentSort, (_) => {
|
||||||
|
updateGames(filterQuery.value, true);
|
||||||
|
});
|
||||||
|
watch(sortOrder, (_) => {
|
||||||
|
updateGames(filterQuery.value, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateGames(filterQuery.value, true);
|
||||||
|
|
||||||
|
function handleSortClick(option: StoreSortOption, event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (currentSort.value === option.param) {
|
||||||
|
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.value = option.param;
|
||||||
|
sortOrder.value = option.param === 'name' ? 'asc' : 'desc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
55
components/TaskWidget.vue
Normal file
55
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,103 +1,161 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
|
<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 ">
|
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
|
||||||
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
|
<!-- Drop Info -->
|
||||||
<div class="space-y-8">
|
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||||
<Wordmark class="h-10" />
|
<div class="space-y-8">
|
||||||
<p class="text-sm leading-6 text-zinc-300">An open-source game distribution platform built for
|
<DropWordmark class="h-10" />
|
||||||
speed, flexibility and beauty.</p>
|
<p class="text-sm leading-6 text-zinc-300">
|
||||||
<div class="flex space-x-6">
|
{{ $t("drop.desc") }}
|
||||||
<a v-for="item in navigation.social" :key="item.name" :href="item.href" target="_blank"
|
</p>
|
||||||
class="text-zinc-400 hover:text-zinc-400">
|
|
||||||
<span class="sr-only">{{ item.name }}</span>
|
<LanguageSelector />
|
||||||
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
|
|
||||||
</a>
|
<div class="flex space-x-6">
|
||||||
</div>
|
<NuxtLink
|
||||||
</div>
|
v-for="item in navigation.social"
|
||||||
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
:key="item.name"
|
||||||
<div class="md:grid md:grid-cols-2 md:gap-8">
|
:to="item.href"
|
||||||
<div>
|
target="_blank"
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
|
class="text-zinc-400 hover:text-zinc-400"
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
>
|
||||||
<li v-for="item in navigation.games" :key="item.name">
|
<span class="sr-only">{{ item.name }}</span>
|
||||||
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
|
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
|
||||||
item.name }}</a>
|
</NuxtLink>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="mt-10 md:mt-0">
|
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">Community</h3>
|
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
|
||||||
<li v-for="item in navigation.community" :key="item.name">
|
|
||||||
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
|
|
||||||
item.name }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="md:grid md:grid-cols-2 md:gap-8">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">Documentation</h3>
|
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
|
||||||
<li v-for="item in navigation.documentation" :key="item.name">
|
|
||||||
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
|
|
||||||
item.name }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="mt-10 md:mt-0">
|
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
|
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
|
||||||
<li v-for="item in navigation.about" :key="item.name">
|
|
||||||
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
|
|
||||||
item.name }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
|
||||||
|
<!-- Foot links -->
|
||||||
|
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
||||||
|
<div class="md:grid md:grid-cols-2 md:gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
|
{{ $t("footer.games") }}
|
||||||
|
</h3>
|
||||||
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
|
<li v-for="item in navigation.games" :key="item.name">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.href"
|
||||||
|
class="text-sm leading-6 text-zinc-300 hover:text-white"
|
||||||
|
>{{ item.name }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mt-10 md:mt-0">
|
||||||
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
|
{{ $t("userHeader.links.community") }}
|
||||||
|
</h3>
|
||||||
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
|
<li v-for="item in navigation.community" :key="item.name">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.href"
|
||||||
|
class="text-sm leading-6 text-zinc-300 hover:text-white"
|
||||||
|
>{{ item.name }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:grid md:grid-cols-2 md:gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
|
{{ $t("footer.documentation") }}
|
||||||
|
</h3>
|
||||||
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
|
<li v-for="item in navigation.documentation" :key="item.name">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.href"
|
||||||
|
class="text-sm leading-6 text-zinc-300 hover:text-white"
|
||||||
|
>{{ item.name }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mt-10 md:mt-0">
|
||||||
|
<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
|
||||||
|
:to="item.href"
|
||||||
|
class="text-sm leading-6 text-zinc-300 hover:text-white"
|
||||||
|
>{{ item.name }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { IconsDiscordLogo, IconsGithubLogo } from '#components';
|
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
|
||||||
|
|
||||||
const navigation = {
|
const { t } = useI18n();
|
||||||
games: [
|
|
||||||
{ name: 'Newly Added', href: '#' },
|
const versionInfo = await $dropFetch("/api/v1");
|
||||||
{ name: 'New Releases', href: '#' },
|
|
||||||
{ name: 'Top Sellers', href: '#' },
|
const navigation = computed(() => ({
|
||||||
{ name: 'Find a Game', href: '#' },
|
games: [
|
||||||
],
|
{ name: t("store.recentlyAdded"), href: "#" },
|
||||||
community: [
|
{ name: t("store.recentlyReleased"), href: "#" },
|
||||||
{ name: 'Friends', href: '#' },
|
{ name: t("footer.topSellers"), href: "#" },
|
||||||
{ name: 'Groups', href: '#' },
|
{ name: t("footer.findGame"), href: "#" },
|
||||||
{ name: 'Servers', href: '#' },
|
],
|
||||||
],
|
community: [
|
||||||
documentation: [
|
{ name: t("common.friends"), href: "#" },
|
||||||
{ name: 'API', href: '#' },
|
{ name: t("common.groups"), href: "#" },
|
||||||
{ name: 'Server Docs', href: '#' },
|
{ name: t("common.servers"), href: "#" },
|
||||||
{ name: 'Client Docs', href: '#' },
|
],
|
||||||
],
|
documentation: [
|
||||||
about: [
|
// TODO: public API docs
|
||||||
{ name: 'About Drop', href: '#' },
|
// { name: t("footer.api"), href: "https://api.droposs.org/" },
|
||||||
{ name: 'Features', href: '#' },
|
{
|
||||||
{ name: 'FAQ', href: '#' },
|
name: t("footer.docs.server"),
|
||||||
],
|
href: "https://docs.droposs.org/docs/guides/quickstart",
|
||||||
social: [
|
},
|
||||||
{
|
{
|
||||||
name: 'GitHub',
|
name: t("footer.docs.client"),
|
||||||
href: 'https://github.com/Drop-OSS',
|
href: "https://docs.droposs.org/docs/guides/client",
|
||||||
icon: IconsGithubLogo,
|
},
|
||||||
},
|
],
|
||||||
{
|
about: [
|
||||||
name: "Discord",
|
{ name: t("footer.aboutDrop"), href: "https://droposs.org/" },
|
||||||
href: "https://discord.gg/NHx46XKJWA",
|
{ name: t("footer.comparison"), href: "https://droposs.org/comparison" },
|
||||||
icon: IconsDiscordLogo
|
],
|
||||||
}
|
social: [
|
||||||
],
|
{
|
||||||
}
|
name: t("footer.social.github"),
|
||||||
</script>
|
href: "https://github.com/Drop-OSS",
|
||||||
|
icon: IconsGithubLogo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("footer.social.discord"),
|
||||||
|
href: "https://discord.gg/NHx46XKJWA",
|
||||||
|
icon: IconsDiscordLogo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="hidden lg:flex bg-zinc-950 flex-row px-12 xl:px-48 py-5">
|
<div class="hidden lg:flex bg-zinc-950 flex-row px-12 xl:px-48 py-5">
|
||||||
<div class="grow inline-flex items-center gap-x-20">
|
<div class="grow inline-flex items-center gap-x-20">
|
||||||
<NuxtLink to="/">
|
<NuxtLink :to="homepageURL">
|
||||||
<Wordmark class="h-8" />
|
<DropWordmark class="h-8" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<nav class="inline-flex items-center">
|
<nav class="inline-flex items-center">
|
||||||
<ol class="inline-flex items-center gap-x-12">
|
<ol class="inline-flex items-center gap-x-12">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="(nav, navIdx) in navigation"
|
v-for="(nav, navIdx) in navigation"
|
||||||
|
:key="navIdx"
|
||||||
:href="nav.route"
|
:href="nav.route"
|
||||||
:class="[
|
:class="[
|
||||||
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
||||||
@ -61,7 +62,10 @@
|
|||||||
<div
|
<div
|
||||||
class="sticky lg:hidden top-0 z-40 flex h-16 justify-between items-center gap-x-4 border-b border-zinc-700 bg-zinc-950 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
|
class="sticky lg:hidden top-0 z-40 flex h-16 justify-between items-center gap-x-4 border-b border-zinc-700 bg-zinc-950 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
|
||||||
>
|
>
|
||||||
<Wordmark class="mb-0.5" />
|
<NuxtLink :to="homepageURL">
|
||||||
|
<DropWordmark class="mb-0.5" />
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<div class="flex gap-x-4 lg:gap-x-6">
|
<div class="flex gap-x-4 lg:gap-x-6">
|
||||||
<div class="flex items-center gap-x-3">
|
<div class="flex items-center gap-x-3">
|
||||||
<!-- Profile dropdown -->
|
<!-- Profile dropdown -->
|
||||||
@ -72,7 +76,7 @@
|
|||||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||||
@click="sidebarOpen = true"
|
@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" />
|
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -121,7 +125,9 @@
|
|||||||
class="-m-2.5 p-2.5"
|
class="-m-2.5 p-2.5"
|
||||||
@click="sidebarOpen = false"
|
@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" />
|
<XMarkIcon class="h-6 w-6 text-zinc-400" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -131,8 +137,8 @@
|
|||||||
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-950 px-6 pb-4"
|
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-950 px-6 pb-4"
|
||||||
>
|
>
|
||||||
<div class="flex shrink-0 h-16 items-center justify-between">
|
<div class="flex shrink-0 h-16 items-center justify-between">
|
||||||
<NuxtLink to="/">
|
<NuxtLink :to="homepageURL">
|
||||||
<Logo class="h-8 w-auto" />
|
<DropLogo class="h-8 w-auto" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<UserHeaderUserWidget />
|
<UserHeaderUserWidget />
|
||||||
@ -141,6 +147,7 @@
|
|||||||
<ol class="flex flex-col gap-y-3">
|
<ol class="flex flex-col gap-y-3">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="(nav, navIdx) in navigation"
|
v-for="(nav, navIdx) in navigation"
|
||||||
|
:key="navIdx"
|
||||||
:href="nav.route"
|
:href="nav.route"
|
||||||
:class="[
|
:class="[
|
||||||
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
||||||
@ -167,6 +174,9 @@
|
|||||||
<BellIcon class="h-5" />
|
<BellIcon class="h-5" />
|
||||||
</UserHeaderWidget>
|
</UserHeaderWidget>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="w-full">
|
||||||
|
<UserHeaderWidget class="w-full" />
|
||||||
|
</li>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -193,35 +203,37 @@ import { Bars3Icon } from "@heroicons/vue/24/outline";
|
|||||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const navigation: Array<NavigationItem> = [
|
const homepageURL = "/store";
|
||||||
|
const navigation: Ref<Array<NavigationItem>> = computed(() => [
|
||||||
{
|
{
|
||||||
prefix: "/store",
|
prefix: "/store",
|
||||||
route: "/store",
|
route: "/store",
|
||||||
label: "Store",
|
label: t("store.title"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prefix: "/library",
|
prefix: "/library",
|
||||||
route: "/library",
|
route: "/library",
|
||||||
label: "Library",
|
label: t("userHeader.links.library"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prefix: "/community",
|
prefix: "/community",
|
||||||
route: "/community",
|
route: "/community",
|
||||||
label: "Community",
|
label: t("userHeader.links.community"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prefix: "/news",
|
prefix: "/news",
|
||||||
route: "/news",
|
route: "/news",
|
||||||
label: "News",
|
label: t("userHeader.links.news"),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
const currentPageIndex = useCurrentNavigationIndex(navigation);
|
const currentPageIndex = useCurrentNavigationIndex(navigation.value);
|
||||||
|
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
const unreadNotifications = computed(() =>
|
const unreadNotifications = computed(() =>
|
||||||
notifications.value.filter((e) => !e.read)
|
notifications.value.filter((e) => !e.read),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
>
|
>
|
||||||
<div class="ml-4 mt-2">
|
<div class="ml-4 mt-2">
|
||||||
<h3 class="text-base font-semibold text-zinc-100 text-sm">
|
<h3 class="text-base font-semibold text-zinc-100 text-sm">
|
||||||
Unread notifications
|
{{ $t("account.notifications.unread") }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 mt-2 shrink-0">
|
<div class="ml-4 mt-2 shrink-0">
|
||||||
@ -15,15 +15,24 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="text-sm text-zinc-400"
|
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>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-y-2 max-h-[300px] overflow-y-scroll">
|
<div class="flex flex-col gap-y-2 max-h-[300px] overflow-y-scroll">
|
||||||
<Notification
|
<NotificationItem
|
||||||
v-for="notification in props.notifications"
|
v-for="notification in props.notifications"
|
||||||
|
:key="notification.id"
|
||||||
:notification="notification"
|
:notification="notification"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -31,13 +40,13 @@
|
|||||||
v-if="props.notifications.length == 0"
|
v-if="props.notifications.length == 0"
|
||||||
class="text-sm text-zinc-400 p-3 text-center w-full"
|
class="text-sm text-zinc-400 p-3 text-center w-full"
|
||||||
>
|
>
|
||||||
No notifications
|
{{ $t("account.notifications.none") }}
|
||||||
</div>
|
</div>
|
||||||
</PanelWidget>
|
</PanelWidget>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<UserHeaderWidget>
|
<UserHeaderWidget>
|
||||||
<div class="inline-flex items-center text-zinc-300 hover:text-white">
|
<div class="inline-flex items-center text-zinc-300 hover:text-white">
|
||||||
<img
|
<img
|
||||||
:src="useObject(user.profilePicture)"
|
:src="useObject(user.profilePictureObjectId)"
|
||||||
class="w-5 h-5 rounded-sm"
|
class="w-5 h-5 rounded-sm"
|
||||||
/>
|
/>
|
||||||
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
||||||
@ -31,7 +31,7 @@
|
|||||||
>
|
>
|
||||||
<div class="inline-flex items-center text-zinc-300">
|
<div class="inline-flex items-center text-zinc-300">
|
||||||
<img
|
<img
|
||||||
:src="useObject(user.profilePicture)"
|
:src="useObject(user.profilePictureObjectId)"
|
||||||
class="w-5 h-5 rounded-sm"
|
class="w-5 h-5 rounded-sm"
|
||||||
/>
|
/>
|
||||||
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
|
||||||
@ -39,16 +39,37 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
|
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<MenuItem v-for="(nav, navIdx) in navigation" v-slot="{ active }">
|
<MenuItem
|
||||||
|
v-for="(nav, navIdx) in navigation"
|
||||||
|
:key="navIdx"
|
||||||
|
v-slot="{ active, close }"
|
||||||
|
hydrate-on-visible
|
||||||
|
as="div"
|
||||||
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:href="nav.route"
|
:to="nav.route"
|
||||||
:class="[
|
:class="[
|
||||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
||||||
'transition block px-4 py-2 text-sm',
|
'w-full text-left transition block px-4 py-2 text-sm',
|
||||||
]"
|
]"
|
||||||
|
@click="close"
|
||||||
>
|
>
|
||||||
{{ nav.label }}</NuxtLink
|
{{ nav.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</MenuItem>
|
||||||
|
<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',
|
||||||
|
]"
|
||||||
|
:data-comment="'external=true is required because we implemented the signout as a route on the server for performance'"
|
||||||
|
:external="true"
|
||||||
|
@click="close"
|
||||||
>
|
>
|
||||||
|
{{ $t("auth.signout") }}
|
||||||
|
</NuxtLink>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
</PanelWidget>
|
</PanelWidget>
|
||||||
@ -65,23 +86,25 @@ import type { NavigationItem } from "~/composables/types";
|
|||||||
|
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
|
|
||||||
const navigation: NavigationItem[] = [
|
const navigation = computed<NavigationItem[]>(() =>
|
||||||
user.value?.admin
|
[
|
||||||
? {
|
user.value?.admin
|
||||||
label: "Admin Dashboard",
|
? {
|
||||||
route: "/admin",
|
label: $t("userHeader.profile.admin"),
|
||||||
prefix: "",
|
route: "/admin",
|
||||||
}
|
prefix: "",
|
||||||
: undefined,
|
}
|
||||||
{
|
: undefined,
|
||||||
label: "Account settings",
|
{
|
||||||
route: "/account",
|
label: $t("userHeader.profile.settings"),
|
||||||
prefix: "",
|
route: "/account",
|
||||||
},
|
prefix: "",
|
||||||
{
|
},
|
||||||
label: "Sign out",
|
{
|
||||||
route: "/signout",
|
label: "Authorize client",
|
||||||
prefix: "",
|
route: "/client/code",
|
||||||
},
|
prefix: "",
|
||||||
].filter((e) => e !== undefined);
|
},
|
||||||
|
].filter((e) => e !== undefined),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="inline-flex justify-center items-center gap-x-1 -mb-1 relative">
|
|
||||||
<svg aria-hidden="true" viewBox="0 0 418 42" class="absolute inset-0 h-full w-full fill-blue-300/30 scale-75"
|
|
||||||
preserveAspectRatio="none">
|
|
||||||
<path
|
|
||||||
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>
|
|
||||||
<Logo class="h-6" />
|
|
||||||
<span class="text-blue-400 font-display font-bold text-xl uppercase">Drop</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import type {
|
||||||
|
CollectionModel,
|
||||||
|
CollectionEntryModel,
|
||||||
|
GameModel,
|
||||||
|
} from "~/prisma/client/models";
|
||||||
|
import type { SerializeObject } from "nitropack";
|
||||||
|
|
||||||
|
type FullCollection = CollectionModel & {
|
||||||
|
entries: Array<CollectionEntryModel & { game: SerializeObject<GameModel> }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCollections = async () => {
|
||||||
|
// @ts-expect-error undefined is used to tell if value has been fetched or not
|
||||||
|
const state = useState<FullCollection[]>("collections", () => undefined);
|
||||||
|
if (state.value === undefined) {
|
||||||
|
state.value = await $dropFetch<FullCollection[]>("/api/v1/collection");
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function refreshCollection(id: string) {
|
||||||
|
const state = useState<FullCollection[]>("collections");
|
||||||
|
const collection = await $dropFetch<FullCollection>(
|
||||||
|
`/api/v1/collection/${id}`,
|
||||||
|
);
|
||||||
|
const index = state.value.findIndex((e) => e.id == id);
|
||||||
|
if (index == -1) {
|
||||||
|
state.value.push(collection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.value[index] = collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLibrary = async () => {
|
||||||
|
// @ts-expect-error undefined is used to tell if value has been fetched or not
|
||||||
|
const state = useState<FullCollection>("library", () => undefined);
|
||||||
|
if (state.value === undefined) {
|
||||||
|
await refreshLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function refreshLibrary() {
|
||||||
|
const state = useState<FullCollection>("library");
|
||||||
|
state.value = await $dropFetch<FullCollection>("/api/v1/collection/default");
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import type { RouteLocationNormalized } from "vue-router";
|
import type { RouteLocationNormalized } from "vue-router";
|
||||||
import type { NavigationItem } from "./types";
|
import type { NavigationItem } from "./types";
|
||||||
|
|
||||||
export const useCurrentNavigationIndex = (navigation: Array<NavigationItem>) => {
|
export const useCurrentNavigationIndex = (
|
||||||
|
navigation: Array<NavigationItem>,
|
||||||
|
) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { IconsLinuxLogo, IconsWindowsLogo } from "#components";
|
import { IconsLinuxLogo, IconsWindowsLogo, IconsMacLogo } from "#components";
|
||||||
|
import { PlatformClient } from "./types";
|
||||||
|
|
||||||
export const PLATFORM_ICONS = {
|
export const PLATFORM_ICONS = {
|
||||||
[PlatformClient.Linux]: IconsLinuxLogo,
|
[PlatformClient.Linux]: IconsLinuxLogo,
|
||||||
[PlatformClient.Windows]: IconsWindowsLogo,
|
[PlatformClient.Windows]: IconsWindowsLogo,
|
||||||
|
[PlatformClient.macOS]: IconsMacLogo,
|
||||||
};
|
};
|
||||||
|
|||||||
41
composables/news.ts
Normal file
41
composables/news.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { ArticleModel } from "~/prisma/client/models";
|
||||||
|
import type { SerializeObject } from "nitropack";
|
||||||
|
|
||||||
|
export const useNews = () =>
|
||||||
|
useState<
|
||||||
|
| Array<
|
||||||
|
SerializeObject<
|
||||||
|
ArticleModel & {
|
||||||
|
tags: Array<{ id: string; name: string }>;
|
||||||
|
author: { displayName: string; id: string } | null;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
| undefined
|
||||||
|
>("news", () => undefined);
|
||||||
|
|
||||||
|
export const fetchNews = async (options?: {
|
||||||
|
limit?: number;
|
||||||
|
skip?: number;
|
||||||
|
orderBy?: "asc" | "desc";
|
||||||
|
tags?: string[];
|
||||||
|
search?: string;
|
||||||
|
}) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
if (options?.limit) query.set("limit", options.limit.toString());
|
||||||
|
if (options?.skip) query.set("skip", options.skip.toString());
|
||||||
|
if (options?.orderBy) query.set("order", options.orderBy);
|
||||||
|
if (options?.tags?.length) query.set("tags", options.tags.join(","));
|
||||||
|
if (options?.search) query.set("search", options.search);
|
||||||
|
|
||||||
|
const news = useNews();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore forget why this ignor exists
|
||||||
|
const newValue = await $dropFetch(`/api/v1/news?${query.toString()}`);
|
||||||
|
|
||||||
|
news.value = newValue;
|
||||||
|
|
||||||
|
return newValue;
|
||||||
|
};
|
||||||
@ -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");
|
const ws = new WebSocketHandler("/api/v1/notifications/ws");
|
||||||
|
|
||||||
export const useNotifications = () =>
|
export const useNotifications = () =>
|
||||||
useState<Array<Notification>>("notifications", () => []);
|
useState<Array<NotificationModel>>("notifications", () => []);
|
||||||
|
|
||||||
ws.listen((e) => {
|
ws.listen((e) => {
|
||||||
const notification = JSON.parse(e) as Notification;
|
const notification = JSON.parse(e) as NotificationModel;
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
notifications.value.push(notification);
|
notifications.value.push(notification);
|
||||||
});
|
});
|
||||||
|
|||||||
91
composables/request.ts
Normal file
91
composables/request.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import type {
|
||||||
|
ExtractedRouteMethod,
|
||||||
|
NitroFetchOptions,
|
||||||
|
NitroFetchRequest,
|
||||||
|
TypedInternalResponse,
|
||||||
|
} from "nitropack/types";
|
||||||
|
import type { 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)?.statusMessage ?? (e as string).toString(),
|
||||||
|
//buttonText: $t("common.close"),
|
||||||
|
},
|
||||||
|
(_, c) => c(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
};
|
||||||
11
composables/store.ts
Normal file
11
composables/store.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export type StoreFilterOption = {
|
||||||
|
name: string;
|
||||||
|
param: string;
|
||||||
|
options: Array<StoreSortOption>;
|
||||||
|
multiple?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoreSortOption = {
|
||||||
|
name: string;
|
||||||
|
param: string;
|
||||||
|
};
|
||||||
@ -2,11 +2,12 @@ import type { TaskMessage } from "~/server/internal/tasks";
|
|||||||
import { WebSocketHandler } from "./ws";
|
import { WebSocketHandler } from "./ws";
|
||||||
|
|
||||||
const websocketHandler = new WebSocketHandler("/api/v1/task");
|
const websocketHandler = new WebSocketHandler("/api/v1/task");
|
||||||
const taskStates: { [key: string]: Ref<TaskMessage | undefined> } = {};
|
// const taskStates: { [key: string]: } = {};
|
||||||
|
const taskStates = new Map<string, Ref<TaskMessage | undefined>>();
|
||||||
|
|
||||||
function handleUpdateMessage(msg: TaskMessage) {
|
function handleUpdateMessage(msg: TaskMessage) {
|
||||||
const taskStates = useTaskStates();
|
const taskStates = useTaskStates();
|
||||||
const state = taskStates[msg.id];
|
const state = taskStates.get(msg.id);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
if (!state.value || msg.reset) {
|
if (!state.value || msg.reset) {
|
||||||
state.value = msg;
|
state.value = msg;
|
||||||
@ -29,18 +30,22 @@ websocketHandler.listen((message) => {
|
|||||||
const [action, ...data] = message.split("/");
|
const [action, ...data] = message.split("/");
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "connect":
|
case "connect": {
|
||||||
const taskReady = useTaskReady();
|
const taskReady = useTaskReady();
|
||||||
taskReady.value = true;
|
taskReady.value = true;
|
||||||
break;
|
break;
|
||||||
case "disconnect":
|
}
|
||||||
|
case "disconnect": {
|
||||||
const disconnectTaskId = data[0];
|
const disconnectTaskId = data[0];
|
||||||
delete taskStates[disconnectTaskId];
|
taskStates.delete(disconnectTaskId);
|
||||||
console.log(`disconnected from ${disconnectTaskId}`);
|
console.log(`disconnected from ${disconnectTaskId}`);
|
||||||
break;
|
break;
|
||||||
case "error":
|
}
|
||||||
|
case "error": {
|
||||||
const [taskId, title, description] = data;
|
const [taskId, title, description] = data;
|
||||||
taskStates[taskId].value ??= {
|
const state = taskStates.get(taskId);
|
||||||
|
if (!state) break;
|
||||||
|
state.value ??= {
|
||||||
id: taskId,
|
id: taskId,
|
||||||
name: "Unknown task",
|
name: "Unknown task",
|
||||||
success: false,
|
success: false,
|
||||||
@ -48,8 +53,9 @@ websocketHandler.listen((message) => {
|
|||||||
error: undefined,
|
error: undefined,
|
||||||
log: [],
|
log: [],
|
||||||
};
|
};
|
||||||
taskStates[taskId].value.error = { title, description };
|
state.value.error = { title, description };
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -61,15 +67,12 @@ export const useTaskReady = () => useState("taskready", () => false);
|
|||||||
export const useTask = (taskId: string): Ref<TaskMessage | undefined> => {
|
export const useTask = (taskId: string): Ref<TaskMessage | undefined> => {
|
||||||
if (import.meta.server) return ref(undefined);
|
if (import.meta.server) return ref(undefined);
|
||||||
const taskStates = useTaskStates();
|
const taskStates = useTaskStates();
|
||||||
if (
|
const task = taskStates.get(taskId);
|
||||||
taskStates[taskId] &&
|
if (task && task.value && !task.value.error) return task;
|
||||||
taskStates[taskId].value &&
|
|
||||||
!taskStates[taskId].value.error
|
|
||||||
)
|
|
||||||
return taskStates[taskId];
|
|
||||||
|
|
||||||
taskStates[taskId] = ref(undefined);
|
taskStates.set(taskId, ref(undefined));
|
||||||
console.log("connecting to " + taskId);
|
console.log("connecting to " + taskId);
|
||||||
websocketHandler.send(`connect/${taskId}`);
|
websocketHandler.send(`connect/${taskId}`);
|
||||||
return taskStates[taskId];
|
// TODO: this may have changed behavior
|
||||||
|
return taskStates.get(taskId) ?? ref(undefined);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,4 +15,5 @@ export type QuickActionNav = {
|
|||||||
export enum PlatformClient {
|
export enum PlatformClient {
|
||||||
Windows = "Windows",
|
Windows = "Windows",
|
||||||
Linux = "Linux",
|
Linux = "Linux",
|
||||||
|
macOS = "macOS",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,13 @@
|
|||||||
import type { User } from "@prisma/client";
|
import type { UserModel } from "~/prisma/client/models";
|
||||||
|
|
||||||
// undefined = haven't check
|
// undefined = haven't check
|
||||||
// null = check, no user
|
// null = check, no user
|
||||||
// {} = check, user
|
// {} = check, user
|
||||||
|
|
||||||
export const useUser = () => useState<User | undefined | null>(undefined);
|
export const useUser = () => useState<UserModel | undefined | null>(undefined);
|
||||||
export const updateUser = async () => {
|
export const updateUser = async () => {
|
||||||
const headers = useRequestHeaders(["cookie"]);
|
|
||||||
|
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
if (user.value === null) return;
|
if (user.value === null) return;
|
||||||
|
|
||||||
// SSR calls have to be after uses
|
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
|
||||||
user.value = await $fetch<User | null>("/api/v1/user", { headers });
|
|
||||||
};
|
};
|
||||||
|
|||||||
25
composables/users.ts
Normal file
25
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;
|
||||||
|
};
|
||||||
@ -30,7 +30,7 @@ export class WebSocketHandler {
|
|||||||
this.ws.onmessage = (e) => {
|
this.ws.onmessage = (e) => {
|
||||||
const message = e.data;
|
const message = e.data;
|
||||||
switch (message) {
|
switch (message) {
|
||||||
case "unauthenticated":
|
case "unauthenticated": {
|
||||||
const error = createError({
|
const error = createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: "Unable to connect to websocket - unauthenticated",
|
statusMessage: "Unable to connect to websocket - unauthenticated",
|
||||||
@ -40,6 +40,7 @@ export class WebSocketHandler {
|
|||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.listeners.length == 0) {
|
if (this.listeners.length == 0) {
|
||||||
this.inQueue.push(message);
|
this.inQueue.push(message);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:14-alpine
|
# using alpine image to reduce image size
|
||||||
|
image: postgres:alpine
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -16,7 +17,10 @@ services:
|
|||||||
- POSTGRES_USER=drop
|
- POSTGRES_USER=drop
|
||||||
- POSTGRES_DB=drop
|
- POSTGRES_DB=drop
|
||||||
drop:
|
drop:
|
||||||
image: registry.deepcore.dev/drop-oss/drop/main:latest
|
image: ghcr.io/drop-oss/drop:nightly
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
init: true
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@ -24,11 +28,6 @@ services:
|
|||||||
- 3000:3000
|
- 3000:3000
|
||||||
volumes:
|
volumes:
|
||||||
- ./library:/library
|
- ./library:/library
|
||||||
- ./certs:/certs
|
- ./data:/data
|
||||||
- ./objects:/objects
|
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://drop:drop@postgres:5432/drop
|
- DATABASE_URL=postgres://drop:drop@postgres:5432/drop
|
||||||
- FS_BACKEND_PATH=/objects
|
|
||||||
- CLIENT_CERTIFICATES=/certs
|
|
||||||
- LIBRARY=/library
|
|
||||||
- GIANT_BOMB_API_KEY=REPLACE_WITH_YOUR_KEY
|
|
||||||
|
|||||||
@ -9,4 +9,4 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- POSTGRES_PASSWORD=drop
|
- POSTGRES_PASSWORD=drop
|
||||||
- POSTGRES_USER=drop
|
- POSTGRES_USER=drop
|
||||||
- POSTGRES_DB=drop
|
- POSTGRES_DB=drop
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
These docs are automatically compiled in the Drop UI and are designed for admins and users to understand how Drop works.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
# Home
|
|
||||||
|
|
||||||
This page is intentionally left blank, as it should be replaced with a custom home page.
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# API
|
|
||||||
|
|
||||||
All Drop components communicate through HTTP-based APIs. However, due to the different use-cases they differ in how they are used.
|
|
||||||
|
|
||||||
## Frontend APIs
|
|
||||||
|
|
||||||
Frontend APIs run on the server, and are found under `/api/v1/`. They are used to render the web frontend, and are focused around user-based control of Drop systems.
|
|
||||||
|
|
||||||
For example, frontend APIs are responsible for uploading profile pictures, customizing your profile and adding friends.
|
|
||||||
|
|
||||||
The frontend, however, does not have access to some Drop features, namely downloading content. That feature is reserved for the client APIs, where it is actually used.
|
|
||||||
|
|
||||||
## Client APIs
|
|
||||||
|
|
||||||
Client APIs run on the server, and are found under `/api/v1/client/`. They are used by Drop clients (namely the desktop client) to manage, download and communicate with other Drop clients. They have a very specific feature set, and are limited in how they can change user profiles.
|
|
||||||
|
|
||||||
For example, client APIs have the ability to download content, setup P2P connections and report game activity. However, they do not have access to user profile management or administrator controls.
|
|
||||||
|
|
||||||
## P2P APIs
|
|
||||||
|
|
||||||
P2P APIs run on Drop clients, and are found at the root of the HTTP server. They are used by other Drop clients to download content and negotiate P2P features. They use mTLS authentication as a lightweight and efficient way to do peer to peer authentication.
|
|
||||||
|
|
||||||
For example, P2P APIs would be used to negotiate a Wireguard tunnel to do Remote LAN play.
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
# Clients
|
|
||||||
|
|
||||||
Drop clients connected to a given Drop server can access:
|
|
||||||
|
|
||||||
- Game content and files (to download)
|
|
||||||
- User data
|
|
||||||
- Game metadata and images
|
|
||||||
- Information about other clients connected to the same Drop server
|
|
||||||
|
|
||||||
**It is important that you trust the client that you grant access to your Drop account.**
|
|
||||||
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
# About
|
|
||||||
|
|
||||||
This section is about what Drop does, and how it works. For users interested in the inner workings of Drop, this will go through many of the design decisions, why they were made and more!
|
|
||||||
|
|
||||||
For users that don't care how Drop works, and want help using Drop, look under other sections. This section is purely technical and theoretical.
|
|
||||||
Submodule drop-base updated: 01fd41c65a...06bea06363
66
error.vue
66
error.vue
@ -2,29 +2,38 @@
|
|||||||
import type { NuxtError } from "#app";
|
import type { NuxtError } from "#app";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
error: Object as () => NuxtError,
|
error: {
|
||||||
|
type: Object as () => NuxtError,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const statusCode = props.error?.statusCode;
|
const statusCode = props.error?.statusCode;
|
||||||
const message =
|
const message =
|
||||||
props.error?.statusMessage ||
|
props.error?.message || props.error?.statusMessage || t("errors.unknown");
|
||||||
props.error?.message ||
|
|
||||||
"An unknown error occurred.";
|
|
||||||
const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
|
const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn() {
|
||||||
clearError({
|
clearError({
|
||||||
redirect: `/signin?redirect=${encodeURIComponent(route.fullPath)}`,
|
redirect: `/auth/signin?redirect=${encodeURIComponent(route.fullPath)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
switch (statusCode) {
|
||||||
|
case 401:
|
||||||
|
case 403:
|
||||||
|
await signIn();
|
||||||
|
}
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: `${statusCode ?? message} | Drop`,
|
title: t("errors.pageTitle", [statusCode ?? message]),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(props.error);
|
if (import.meta.client) {
|
||||||
|
console.warn(props.error);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -34,7 +43,7 @@ console.log(props.error);
|
|||||||
<header
|
<header
|
||||||
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
|
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
|
||||||
>
|
>
|
||||||
<Logo class="h-10 w-auto sm:h-12" />
|
<DropLogo class="h-10 w-auto sm:h-12" />
|
||||||
</header>
|
</header>
|
||||||
<main
|
<main
|
||||||
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
|
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
|
||||||
@ -46,30 +55,41 @@ console.log(props.error);
|
|||||||
<h1
|
<h1
|
||||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||||
>
|
>
|
||||||
Oh no!
|
{{ $t("errors.ohNo") }}
|
||||||
</h1>
|
</h1>
|
||||||
<p v-if="message" class="mt-3 font-bold text-base leading-7 text-red-500">
|
<p
|
||||||
|
v-if="message"
|
||||||
|
class="mt-3 font-bold text-base leading-7 text-red-500"
|
||||||
|
>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||||
An error occurred while responding to your request. If you believe
|
{{ $t("errors.occurred") }}
|
||||||
this to be a bug, please report it. Try signing in and see if it
|
|
||||||
resolves the issue.
|
|
||||||
</p>
|
</p>
|
||||||
|
<!-- <p>{{ error. }}</p> -->
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<!-- full app reload to fix errors -->
|
<!-- full app reload to fix errors -->
|
||||||
<a
|
<NuxtLink
|
||||||
v-if="user && !showSignIn"
|
v-if="user && !showSignIn"
|
||||||
href="/"
|
to="/"
|
||||||
class="text-sm font-semibold leading-7 text-blue-600"
|
class="text-sm font-semibold leading-7 text-blue-600"
|
||||||
><span aria-hidden="true">←</span> Back to home</a
|
|
||||||
>
|
>
|
||||||
|
<i18n-t keypath="errors.backHome" tag="span" scope="global">
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@click="signIn"
|
|
||||||
class="text-sm font-semibold leading-7 text-blue-600"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -79,7 +99,7 @@ console.log(props.error);
|
|||||||
<nav
|
<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"
|
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
|
<svg
|
||||||
viewBox="0 0 2 2"
|
viewBox="0 0 2 2"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -87,9 +107,9 @@ console.log(props.error);
|
|||||||
>
|
>
|
||||||
<circle cx="1" cy="1" r="1" />
|
<circle cx="1" cy="1" r="1" />
|
||||||
</svg>
|
</svg>
|
||||||
<a href="https://discord.gg/NHx46XKJWA" target="_blank"
|
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
|
||||||
>Support Discord</a
|
{{ $t("errors.support") }}
|
||||||
>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
@ -98,8 +118,8 @@ console.log(props.error);
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/wallpapers/error-wallpaper.jpg"
|
src="/wallpapers/error-wallpaper.jpg"
|
||||||
alt=""
|
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
33
eslint.config.mjs
Normal file
33
eslint.config.mjs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// @ts-check
|
||||||
|
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||||
|
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||||
|
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
|
||||||
|
|
||||||
|
export default withNuxt([
|
||||||
|
eslintConfigPrettier,
|
||||||
|
|
||||||
|
// vue-i18n plugin
|
||||||
|
...vueI18n.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Optional.
|
||||||
|
"@intlify/vue-i18n/no-dynamic-keys": "error",
|
||||||
|
"@intlify/vue-i18n/no-unused-keys": [
|
||||||
|
"off",
|
||||||
|
{
|
||||||
|
extensions: [".js", ".vue", ".ts"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"@intlify/vue-i18n/no-missing-keys": "error",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
"vue-i18n": {
|
||||||
|
localeDir: "./i18n/locales/*.{json,json5,ts,js}", // extension is glob formatting!
|
||||||
|
|
||||||
|
// Specify the version of `vue-i18n` you are using.
|
||||||
|
// If not specified, the message will be parsed twice.
|
||||||
|
messageSyntaxVersion: "^11.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
35
i18n/i18n.config.ts
Normal file
35
i18n/i18n.config.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export default defineI18nConfig(() => {
|
||||||
|
const defaultDateTimeFormat = {
|
||||||
|
short: {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
long: {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
weekday: "short",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// https://i18n.nuxtjs.org/docs/guide/locale-fallback
|
||||||
|
fallbackLocale: "en-us",
|
||||||
|
// https://vue-i18n.intlify.dev/guide/essentials/datetime.html
|
||||||
|
datetimeFormats: {
|
||||||
|
"en-us": defaultDateTimeFormat,
|
||||||
|
"en-gb": defaultDateTimeFormat,
|
||||||
|
"en-au": defaultDateTimeFormat,
|
||||||
|
"en-pirate": defaultDateTimeFormat,
|
||||||
|
fr: defaultDateTimeFormat,
|
||||||
|
de: defaultDateTimeFormat,
|
||||||
|
it: defaultDateTimeFormat,
|
||||||
|
es: defaultDateTimeFormat,
|
||||||
|
zh: defaultDateTimeFormat,
|
||||||
|
"zh-tw": defaultDateTimeFormat,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
25
i18n/localeDetector.ts
Normal file
25
i18n/localeDetector.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// https://i18n.nuxtjs.org/docs/guide/server-side-translations
|
||||||
|
|
||||||
|
// Detect based on query, cookie, header
|
||||||
|
export default defineI18nLocaleDetector((event, config) => {
|
||||||
|
// try to get locale from query
|
||||||
|
const query = tryQueryLocale(event, { lang: "" }); // disable locale default value with `lang` option
|
||||||
|
if (query) {
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to get locale from cookie
|
||||||
|
const cookie = tryCookieLocale(event, { lang: "", name: "i18n_redirected" }); // disable locale default value with `lang` option
|
||||||
|
if (cookie) {
|
||||||
|
return cookie.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to get locale from header (`accept-header`)
|
||||||
|
const header = tryHeaderLocale(event, { lang: "" }); // disable locale default value with `lang` option
|
||||||
|
if (header) {
|
||||||
|
return header.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the locale cannot be resolved up to this point, it is resolved with the value `defaultLocale` of the locale config passed to the function
|
||||||
|
return config.defaultLocale;
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user