mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
Compare commits
11 Commits
v0.3.0
...
65-cloud-s
| Author | SHA1 | Date | |
|---|---|---|---|
| 95d6089453 | |||
| 5b27430ece | |||
| 885e6c8c83 | |||
| c3e7f3192e | |||
| 361da30a93 | |||
| 2e98251035 | |||
| 37766d94b9 | |||
| c76b9894c6 | |||
| 9fda9c5cb9 | |||
| 816355be0c | |||
| 951a741f3e |
@ -1,9 +1,3 @@
|
||||
Dockerfile
|
||||
.github
|
||||
.vscode
|
||||
*.md
|
||||
|
||||
#### gitignore below
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
@ -30,14 +24,8 @@ logs
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.data
|
||||
|
||||
|
||||
# deploy template
|
||||
deploy-template/*
|
||||
|
||||
!deploy-template/compose.yml
|
||||
deploy-template/
|
||||
|
||||
# generated prisma client
|
||||
/prisma/client
|
||||
/prisma/validate
|
||||
|
||||
@ -2,4 +2,4 @@ DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
|
||||
|
||||
GIANT_BOMB_API_KEY=""
|
||||
|
||||
EXTERNAL_URL="http://localhost:3000"
|
||||
EXTERNAL_URL="localhost:3000"
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -8,9 +8,6 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
|
||||
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
@ -8,9 +8,6 @@ on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # run at 2 AM UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
web:
|
||||
name: Push website Docker image to registry
|
||||
@ -23,9 +20,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 3 # fix for when this gets triggered by tag
|
||||
fetch-tags: true
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine final version
|
||||
@ -80,21 +75,6 @@ jobs:
|
||||
# set latest tag for stable releases
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }}
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v4
|
||||
id: cache
|
||||
with:
|
||||
path: cache-mount
|
||||
key: cache-mount-${{ hashFiles('Dockerfile') }}
|
||||
|
||||
- name: Restore Docker cache mounts
|
||||
uses: reproducible-containers/buildkit-cache-dance@v3
|
||||
with:
|
||||
builder: ${{ steps.setup-buildx.outputs.name }}
|
||||
cache-dir: cache-mount
|
||||
dockerfile: Dockerfile
|
||||
skip-extraction: ${{ steps.cache.outputs.cache-hit }}
|
||||
|
||||
- name: Build and push image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@ -1 +1 @@
|
||||
drop-base/
|
||||
drop-base/
|
||||
|
||||
@ -148,6 +148,7 @@ type(scope)!: subject
|
||||
```
|
||||
|
||||
- `type`: the type of the commit is one of the following:
|
||||
|
||||
- `feat`: new features.
|
||||
- `fix`: bug fixes.
|
||||
- `docs`: documentation changes.
|
||||
@ -164,6 +165,7 @@ type(scope)!: subject
|
||||
- `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:
|
||||
|
||||
```
|
||||
@ -177,6 +179,7 @@ type(scope)!: subject
|
||||
```
|
||||
|
||||
For changes to plugins or themes, the scope should be the plugin or theme name:
|
||||
|
||||
- ✅ `fix(agnoster): commit subject`
|
||||
- ❌ `fix(theme/agnoster): commit subject`
|
||||
|
||||
@ -206,6 +209,7 @@ type(scope)!: subject
|
||||
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:
|
||||
|
||||
```
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@ -1,47 +1,41 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
### Unified deps builder
|
||||
# FROM node:lts-alpine AS deps
|
||||
# WORKDIR /app
|
||||
# COPY package.json yarn.lock ./
|
||||
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --network-timeout 1000000 --ignore-scripts
|
||||
# Unified deps builder
|
||||
FROM node:lts-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --network-timeout 1000000 --ignore-scripts
|
||||
|
||||
### Build for app
|
||||
# Build for app
|
||||
FROM node:lts-alpine AS build-system
|
||||
# setup workdir - has to be the same filepath as app because fuckin' Prisma
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_TELEMETRY_DISABLED=1
|
||||
# ENV YARN_CACHE_FOLDER=/root/.yarn
|
||||
|
||||
# add git so drop can determine its git ref at build
|
||||
# pnpm for build
|
||||
RUN apk add --no-cache git pnpm
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# copy deps and rest of project files
|
||||
# COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG BUILD_DROP_VERSION
|
||||
ARG BUILD_DROP_VERSION="v0.0.0-unknown.1"
|
||||
ARG BUILD_GIT_REF
|
||||
|
||||
# build
|
||||
RUN pnpm import
|
||||
RUN pnpm install --shamefully-hoist
|
||||
RUN pnpm run build
|
||||
# RUN --mount=type=cache,target=/root/.yarn yarn postinstall && yarn build
|
||||
RUN yarn postinstall
|
||||
RUN yarn build
|
||||
|
||||
### create run environment for Drop
|
||||
# create run environment for Drop
|
||||
FROM node:lts-alpine AS run-system
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
RUN yarn add --network-timeout 1000000 --no-lockfile prisma@6.7.0
|
||||
|
||||
COPY --from=build-system /app/package.json ./
|
||||
COPY --from=build-system /app/.output ./app
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
# This file starts up the Drop server by running migrations and then starting the executable
|
||||
echo "[Drop] performing migrations..."
|
||||
pnpm prisma migrate deploy
|
||||
yarn prisma migrate deploy
|
||||
|
||||
# Actually start the application
|
||||
node /app/app/server/index.mjs
|
||||
|
||||
192
changelog.md
192
changelog.md
@ -190,198 +190,6 @@
|
||||
|
||||
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
|
||||
|
||||
## Release 0.2.0-beta
|
||||
|
||||
### Fixes
|
||||
|
||||
- fix recursive dirs util #02d6346
|
||||
- Fix username length requirement #0a5a649
|
||||
- remove dynamic imports #0f10626
|
||||
- fix for missing developers or publishers #25fc957
|
||||
- split prisma schemas #2859005
|
||||
- results are returned alphabetically #33d3770
|
||||
- update prisma schemas #36776cc
|
||||
- removed global flag #43e32b4
|
||||
- properly disconnect websockets from task handler #5358f1f
|
||||
- follow best practices #54c5d55
|
||||
- future lenience #5c78b20
|
||||
- fix width of token breaking things #61d88c3
|
||||
- fixed websocket authentication #62ea9a1
|
||||
- fix delta manifest generation #6df560c
|
||||
- admin invitation w/ system user #8463e35
|
||||
- properly import icons #8945196
|
||||
- prisma create footprint #952ece8
|
||||
- game panel now always shows 3 lines exactly #9c2249e
|
||||
- remove unnecessary import #a361c38
|
||||
- fix disconnect code #a8f2106
|
||||
- fix types #b511b40
|
||||
- add drop-base as git submodule #b75ebd1
|
||||
- Update README.md with discord link #c6bb21d
|
||||
- fix expires requirement in the admin endpoint #c7b675f
|
||||
- fix always being created as admin #c7eb11a
|
||||
- moved icons and created PlatformClient so we can use the enum on the frontend #cada630
|
||||
- recurse submodules #db103de
|
||||
- fix FATAL: "root"... message #dbb315a
|
||||
- only show versions that are directories #ef8f3ae
|
||||
|
||||
### Features
|
||||
|
||||
- update prisma & delete games #089c3e0
|
||||
- manual handshake #12e3125
|
||||
- fetch game endpoint #1f4d075
|
||||
- under the hood organisation and consolidation #26a31f6
|
||||
- 'no images' slide on image carousel #28baabc
|
||||
- improve feedback when metadata fails #2c19e13
|
||||
- introduction of 'system user' #2c21a23
|
||||
- change name, description and icon #2cfe75a
|
||||
- 'manual' metadata provider #2f52a16
|
||||
- add disabled state #38fc6b8
|
||||
- overhauled version importing #39d7ce7
|
||||
- automatically create library folder if it doesn't exist #39fe9d5
|
||||
- smoother bar in admin task ui #4488ae2
|
||||
- add noWrapper option #4f9b949
|
||||
- add version metadata route #5393db3
|
||||
- completed admin UI, with minor changes to backend #599da0e
|
||||
- adjust gradient #5a1f841
|
||||
- keep track of last connected #69e4c25
|
||||
- added notification system w/ interwoven refactoring #6e6f09d
|
||||
- content length header for chunk downloads #76bceb1
|
||||
- add title to tab #7b0756c
|
||||
- add button to open in admin panel #7b3b919
|
||||
- client capability framework + peer API configuration #7d72a86
|
||||
- customisable image carousel and new layout #937954f
|
||||
- support more types #9b12d45
|
||||
- generate a server certificate for mtls APIs #9c4b6f3
|
||||
- new endpoints, ui and beginnings of main store page #9cbdcbc
|
||||
- backend #a309651
|
||||
- more subtle design improvements #a815542
|
||||
- add aden's carousel pagination design #a86045c
|
||||
- add header #a8a152e
|
||||
- client side search #b50e27f
|
||||
- new ws handler #bc0c47c
|
||||
- user widget now redirects to actual page #bfafe02
|
||||
- require lowercase usernames #d7160ab
|
||||
- more ui improvements #e408ac5
|
||||
- add modifying game descriptions #e505e58
|
||||
- mobile nav #e5cf13f
|
||||
- slightly improved game page #e796b46
|
||||
- game carousel #ecc819e
|
||||
- add enum dictionary type #f2e0182
|
||||
- improved ux #f3ed0f6
|
||||
- cleanup and raw accessors #f7d767d
|
||||
- add support for overriding UMU id #fd4a7d1
|
||||
- add .sh for linux #fe9373a
|
||||
|
||||
### Other Changes
|
||||
|
||||
- quexeky <git@quexeky.dev>
|
||||
- fixed manifest generation #03a37f7
|
||||
- manual ci/cd #03b0b0c
|
||||
- ability to fetch client certs for p2p #0a715fe
|
||||
- disable tls in build #0f80fcd
|
||||
- Updated README.md #17971e0
|
||||
- Merge pull request #18 from Drop-OSS/develop
|
||||
- initial work on metadata system #196f87c
|
||||
- more ui #1bd19ad
|
||||
- remove log statements #1d5e1bd
|
||||
- small fixes & SSR disabled #1f575b2
|
||||
- update information and setup guide #2236622
|
||||
- metadata engine #22ac7f6
|
||||
- Update CONTRIBUTING.md #2309407
|
||||
- slight bug fixes and clean up #24a0d11
|
||||
- almst complete admin ui and initial store designs #27070b6
|
||||
- handshakes #2b4382d
|
||||
- user mobile header #2e44ef3
|
||||
- more consistent naming for globals #305de9f
|
||||
- replaced markdown-it with micromark #31e8359
|
||||
- fixes to store page for mobile clients #328b9ba
|
||||
- game version re-ordering #329c74d
|
||||
- verbose yarn install #36568c3
|
||||
- patch for no version check in manifest generation #395219d
|
||||
- migrate bcrypt to bcryptjs #3a51c9c
|
||||
- added download chunk endpoint #3dd6062
|
||||
- Update README.md #425934d
|
||||
- build only ci #4273a20
|
||||
- object storage + full permission system + testing #435551c
|
||||
- rename admin socket session map #44c6028
|
||||
- bump droplet and add vue carousel #46551f9
|
||||
- version importing #46c8f0c
|
||||
- back to yarn, with nuxt telemetry force disabled #46d35ad
|
||||
- finished object endpoints #486bce8
|
||||
- update dependencies and add note about optional dependencies #4fa771a
|
||||
- use configuration from docs for ci/cd #52315d0
|
||||
- slight fixes to register logic #583301f
|
||||
- removed yarn.lock #584bcf1
|
||||
- Version bump #5f29c28
|
||||
- immutable application settings framework #5fe2036
|
||||
- fixed docker daemon location #62a111b
|
||||
- copy autodevops configuration #6328c24
|
||||
- Delete .gitlab-ci.yml #69f341b
|
||||
- admin ui shell #6b5e48d
|
||||
- bump @drop/droplet version for windows developers #6ba5cdd
|
||||
- Add LICENSE #6e2dc89
|
||||
- custom dind #716eac7
|
||||
- task API #718f5ba
|
||||
- use gitlab ci variable declaration #7194d35
|
||||
- move icons into dedicated folder #74fa671
|
||||
- another stage of client authentication #7523e53
|
||||
- refactoring #7869043
|
||||
- moved windows logo into logos dir #789d3ba
|
||||
- updated text colours across app #7a88f4c
|
||||
- starting docs infra #7d2a1c6
|
||||
- more cleaning #7e17626
|
||||
- slight patch to rename query to be more consistent #7f4db0c
|
||||
- move to raw docker #803752e
|
||||
- server side and user client side completed for registration #848a611
|
||||
- beginnings of download implementation #8674ac7
|
||||
- more consistent naming for object handler #87230fb
|
||||
- use autodevops build stage #886beb6
|
||||
- Updated tailwind config #88c95d6
|
||||
- change name of store file #8999303
|
||||
- split prisma schemas #9011cf5
|
||||
- client initiate #909432a
|
||||
- more client routes to support Drop app update #91b7e10
|
||||
- additional polish and QoL features #93bc143
|
||||
- upload images to games #9b7ee4e
|
||||
- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d
|
||||
- run yarn install in CI/CD non interactively #a208fbe
|
||||
- completed game importing; partial work on version importing #a7c33e7
|
||||
- remove canvas from dependencies #a8f58eb
|
||||
- fix registry authentication #ad25d3e
|
||||
- consolidate type utils #adb4b73
|
||||
- Updated README.md #b0ef675
|
||||
- add proper carousel to store page #b2ab827
|
||||
- move to yarn v2 #b744671
|
||||
- remove client API deadweight #b9ae26c
|
||||
- add expires field #be6c30d
|
||||
- ca groundwork #bfafd2a
|
||||
- cleanup & polish #c355f6f
|
||||
- remove bcrypt (debug) #c3914cc
|
||||
- non rounded bottom #c4391d3
|
||||
- failed gracefully on invalid chunk index #c4a3e4e
|
||||
- update deploy template #c4a419f
|
||||
- migrate to new droplet ca system #c4d8113
|
||||
- docker based deployment #c5d00b4
|
||||
- updated CONTRIBUTING.md #cd0d2bf
|
||||
- update prisma version #ce0a9ab
|
||||
- README update #ceacd84
|
||||
- patch metadata handler #cf578bd
|
||||
- Added SECURITY.md #d3d93b0
|
||||
- finalised client APIs and authentication method #d4e2dc8
|
||||
- Update README.md #db916bf
|
||||
- object storage interface + utility functions #de388a9
|
||||
- initial commit #e1a789f
|
||||
- fixed task system #e1c1d7e
|
||||
- Update file chunk.get.ts #e4339c3
|
||||
- ui groundwork #e52f072
|
||||
- Update changelog #eadcaa1
|
||||
- check for no version in manifest generation #eb3f9f9
|
||||
- break into single column store on lg devices #ecb381e
|
||||
- better server side signin redirects #ef13b68
|
||||
- patch signin #f3672f8
|
||||
|
||||
_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits)
|
||||
|
||||
## Release 0.1.0-beta
|
||||
|
||||
### Fixes
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4">
|
||||
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
|
||||
<UserIcon class="size-5" /> {{ $t("account.title") }}
|
||||
<UserIcon class="size-5" /> {{ $t("account.settings") }}
|
||||
</span>
|
||||
<nav class="flex flex-1 flex-col">
|
||||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||
@ -74,7 +74,7 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||
count: notifications.value.length,
|
||||
},
|
||||
{
|
||||
label: t("account.settings"),
|
||||
label: t("settings"),
|
||||
route: "/account/settings",
|
||||
prefix: "/account/settings",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
|
||||
@ -84,7 +84,7 @@
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<ModalCreateCollection
|
||||
<CreateCollectionModal
|
||||
v-model="createCollectionModal"
|
||||
:game-id="props.gameId"
|
||||
/>
|
||||
@ -122,9 +122,20 @@ async function toggleLibrary() {
|
||||
body: {
|
||||
id: props.gameId,
|
||||
},
|
||||
failTitle: t("errors.library.add.title"),
|
||||
});
|
||||
await refreshLibrary();
|
||||
} 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 {
|
||||
isLibraryLoading.value = false;
|
||||
}
|
||||
@ -136,18 +147,26 @@ async function toggleCollection(id: string) {
|
||||
if (!collection) return;
|
||||
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
|
||||
|
||||
await $dropFetch(`/api/v1/collection/:id/entry`, {
|
||||
await $dropFetch(`/api/v1/collection/${id}/entry`, {
|
||||
method: index == -1 ? "POST" : "DELETE",
|
||||
params: { id },
|
||||
body: {
|
||||
id: props.gameId,
|
||||
},
|
||||
failTitle: t("errors.library.add.title"),
|
||||
});
|
||||
|
||||
await refreshCollection(id);
|
||||
} finally {
|
||||
/* empty */
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { User } from "~/prisma/client";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
@ -124,6 +124,6 @@ async function signin() {
|
||||
},
|
||||
});
|
||||
const user = useUser();
|
||||
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
|
||||
user.value = await $dropFetch<User | null>("/api/v1/user");
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => createCollection()"
|
||||
>
|
||||
{{ $t("common.create") }}
|
||||
{{ $t("create") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
@ -46,7 +46,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { CollectionEntryModel, GameModel } from "~/prisma/client/models";
|
||||
import type { CollectionEntry, Game } from "~/prisma/client";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -79,7 +79,7 @@ async function createCollection() {
|
||||
// Add the game if provided
|
||||
if (props.gameId) {
|
||||
const entry = await $dropFetch<
|
||||
CollectionEntryModel & { game: SerializeObject<GameModel> }
|
||||
CollectionEntry & { game: SerializeObject<Game> }
|
||||
>(`/api/v1/collection/${response.id}/entry`, {
|
||||
method: "POST",
|
||||
body: { id: props.gameId },
|
||||
@ -35,10 +35,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CollectionModel } from "~/prisma/client/models";
|
||||
import type { Collection } from "~/prisma/client";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
|
||||
const collection = defineModel<CollectionModel | undefined>();
|
||||
const collection = defineModel<Collection | undefined>();
|
||||
const deleteLoading = ref(false);
|
||||
|
||||
const collections = await useCollections();
|
||||
@ -49,11 +49,9 @@ async function deleteCollection() {
|
||||
if (!collection.value) return;
|
||||
|
||||
deleteLoading.value = true;
|
||||
await $dropFetch(`/api/v1/collection/:id`, {
|
||||
await $dropFetch(`/api/v1/collection/${collection.value.id}`, {
|
||||
// @ts-expect-error not documented
|
||||
method: "DELETE",
|
||||
params: {
|
||||
id: collection.value.id,
|
||||
},
|
||||
});
|
||||
const index = collections.value.findIndex(
|
||||
(e) => e.id == collection.value?.id,
|
||||
@ -36,9 +36,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { User } from "~/prisma/client";
|
||||
|
||||
const user = defineModel<UserModel | undefined>();
|
||||
const user = defineModel<User | undefined>();
|
||||
const deleteLoading = ref(false);
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
@ -7,11 +7,7 @@
|
||||
:key="gameIdx"
|
||||
class="justify-start"
|
||||
>
|
||||
<GamePanel
|
||||
:game="game"
|
||||
:href="game ? `/store/${game.id}` : undefined"
|
||||
:show-title-description="showGamePanelTextDecoration"
|
||||
/>
|
||||
<GamePanel :game="game" />
|
||||
</VueSlide>
|
||||
|
||||
<template #addons>
|
||||
@ -35,21 +31,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import type { Game } from "~/prisma/client";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
items: Array<SerializeObject<GameModel>>;
|
||||
items: Array<SerializeObject<Game>>;
|
||||
min?: number;
|
||||
width?: number;
|
||||
}>();
|
||||
|
||||
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
|
||||
|
||||
const currentComponent = ref<HTMLDivElement>();
|
||||
|
||||
const min = computed(() => Math.max(props.min ?? 8, props.items.length));
|
||||
const games: Ref<Array<SerializeObject<GameModel> | undefined>> = computed(() =>
|
||||
const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() =>
|
||||
Array(min.value)
|
||||
.fill(0)
|
||||
.map((_, i) => props.items[i]),
|
||||
|
||||
266
components/GameEditor/Configuration.vue
Normal file
266
components/GameEditor/Configuration.vue
Normal file
@ -0,0 +1,266 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div v-if="game" class="h-full grow flex flex-row gap-y-8">
|
||||
<div class="grow w-full h-full px-6 py-4 flex flex-col gap-8">
|
||||
<!-- version manager -->
|
||||
|
||||
<div class="rounded-lg bg-zinc-950 shadow-sm divide-y divide-zinc-800">
|
||||
<div class="px-4 py-3 sm:px-6">
|
||||
<h1
|
||||
class="w-full inline-flex items-center justify gap-x-2 text-lg text-zinc-200 font-semibold"
|
||||
>
|
||||
<ServerStackIcon class="size-5" />
|
||||
{{ $t("library.admin.versionPriority.title") }}
|
||||
<NuxtLink
|
||||
v-if="unimportedVersions !== undefined"
|
||||
:href="
|
||||
unimportedVersions.length > 0
|
||||
? `/admin/library/${game.id}/import`
|
||||
: ''
|
||||
"
|
||||
type="button"
|
||||
:class="[
|
||||
unimportedVersions.length > 0
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-800/50',
|
||||
'ml-auto inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1.5 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',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
unimportedVersions.length > 0
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
</NuxtLink>
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.versionPriority.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-zinc-950/10 px-4 py-5 sm:p-6">
|
||||
<!-- version priority -->
|
||||
<div>
|
||||
<div class="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: GameVersion }">
|
||||
<div
|
||||
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
|
||||
>
|
||||
<div class="text-zinc-100 font-semibold">
|
||||
{{ item.versionName }}
|
||||
</div>
|
||||
<div class="text-zinc-400">
|
||||
{{ item.delta ? $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>
|
||||
|
||||
<!-- cloud saves manager -->
|
||||
<div class="rounded-lg bg-zinc-950 shadow-sm divide-y divide-zinc-800">
|
||||
<div class="px-4 py-3 sm:px-6">
|
||||
<h1
|
||||
class="w-full inline-flex items-center justify gap-x-2 text-lg text-zinc-200 font-semibold"
|
||||
>
|
||||
<CloudIcon class="size-5" />
|
||||
{{ $t("library.admin.cloudSaves.title") }}
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("library.admin.cloudSaves.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-zinc-950/10 px-4 py-5 sm:p-6">
|
||||
<LudusaviSearchbar
|
||||
:default="game.cloudSaveConfiguration?.ludusaviEntry?.name"
|
||||
@update="(name) => updateLudusaviEntry(name)"
|
||||
/>
|
||||
<dl
|
||||
v-if="game.cloudSaveConfiguration?.ludusaviEntry"
|
||||
class="mt-4 grid grid-cols-1 gap-0.5 overflow-hidden rounded-2xl text-center sm:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="stat in [
|
||||
{ name: 'Name', value: game.cloudSaveConfiguration.ludusaviEntry.name },
|
||||
{ name: 'Steam ID', value: game.cloudSaveConfiguration.ludusaviEntry.steamId },
|
||||
]"
|
||||
:key="stat.name"
|
||||
class="flex flex-col bg-white/5 p-8"
|
||||
>
|
||||
<dt class="text-sm/6 font-semibold text-gray-300">
|
||||
{{ stat.name }}
|
||||
</dt>
|
||||
<dd
|
||||
class="order-first text-3xl font-semibold tracking-tight text-white"
|
||||
>
|
||||
{{ stat.value }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col bg-white/5 p-8">
|
||||
<dt class="mt-1 text-sm/6 font-semibold text-gray-300">
|
||||
Platforms
|
||||
</dt>
|
||||
<dd
|
||||
class="inline-flex items-center justify-center gap-x-4 order-first text-3xl font-semibold tracking-tight text-white"
|
||||
>
|
||||
<component
|
||||
:is="item"
|
||||
v-for="item in game.cloudSaveConfiguration.ludusaviEntry.entries
|
||||
.map((e) => e.platform as PlatformClient)
|
||||
.map((e) => PLATFORM_ICONS[e])"
|
||||
:key="item.__name"
|
||||
class="size-8 text-zinc-100"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</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"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
CloudSaveConfiguration,
|
||||
Game,
|
||||
GameVersion,
|
||||
LudusaviEntry,
|
||||
LudusaviPlatformEntry,
|
||||
} from "~/prisma/client";
|
||||
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { H3Error } from "h3";
|
||||
import { CloudIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
// TODO implement UI for this page
|
||||
|
||||
defineProps<{ unimportedVersions: string[] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
type FullGame = Game & { versions: GameVersion[] } & {
|
||||
cloudSaveConfiguration?: CloudSaveConfiguration & {
|
||||
ludusaviEntry?: LudusaviEntry & { entries: Array<LudusaviPlatformEntry> } | null;
|
||||
};
|
||||
};
|
||||
const game = defineModel() as Ref<SerializeObject<FullGame>>;
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
async function updateLudusaviEntry(name: string) {
|
||||
try {
|
||||
const newConfig = await $dropFetch("/api/v1/admin/game/cloudsaves", {
|
||||
method: "PATCH",
|
||||
body: { id: game.value.id, name: name },
|
||||
});
|
||||
console.log(newConfig);
|
||||
game.value.cloudSaveConfiguration = newConfig;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: t("errors.game.ludusavi.title"),
|
||||
description: t("errors.game.ludusavi.description", [
|
||||
// @ts-expect-error attempt to display statusMessage on error
|
||||
e?.statusMessage ?? t("errors.unknown"),
|
||||
]),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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("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,
|
||||
);
|
||||
} 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("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -23,14 +23,10 @@
|
||||
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (showEditCoreMetadata = true)"
|
||||
>
|
||||
{{ $t("common.edit") }} <PencilIcon class="size-4" />
|
||||
{{ $t("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">
|
||||
@ -272,13 +268,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModalUploadFile
|
||||
<UploadFileDialog
|
||||
v-model="showUploadModal"
|
||||
:options="{ id: game.id }"
|
||||
accept="image/*"
|
||||
endpoint="/api/v1/admin/game/image"
|
||||
:multiple="true"
|
||||
@upload="(result: GameModel) => uploadAfterImageUpload(result)"
|
||||
@upload="(result: Game) => uploadAfterImageUpload(result)"
|
||||
/>
|
||||
<ModalTemplate v-model="showAddCarouselModal">
|
||||
<template #default>
|
||||
@ -318,7 +314,7 @@
|
||||
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") }}
|
||||
{{ $t("close") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
@ -339,7 +335,7 @@
|
||||
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") }}
|
||||
{{ $t("insert") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -380,7 +376,7 @@
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="(e: Event) => coreMetadataUploadFiles(e as any)"
|
||||
@change="(e) => coreMetadataUploadFiles(e as any)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@ -428,7 +424,7 @@
|
||||
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
|
||||
@click="() => coreMetadataUpdate_wrapper()"
|
||||
>
|
||||
{{ $t("common.save") }}
|
||||
{{ $t("save") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
@ -444,7 +440,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel, GameTagModel } from "~/prisma/client/models";
|
||||
import type { Game } from "~/prisma/client";
|
||||
import { micromark } from "micromark";
|
||||
import {
|
||||
CheckIcon,
|
||||
@ -455,42 +451,23 @@ import {
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { H3Error } from "h3";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
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>;
|
||||
const game = defineModel<SerializeObject<Game>>() as Ref<SerializeObject<Game>>;
|
||||
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.
|
||||
@ -514,7 +491,7 @@ function coreMetadataUploadFiles(e: InputEvent) {
|
||||
{
|
||||
title: t("errors.upload.title"),
|
||||
description: t("errors.upload.description", [t("errors.unknown")]),
|
||||
buttonText: t("common.close"),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
@ -531,16 +508,14 @@ async function coreMetadataUpdate() {
|
||||
formData.append("icon", newIcon);
|
||||
}
|
||||
|
||||
formData.append("id", game.value.id);
|
||||
formData.append("name", coreMetadataName.value);
|
||||
formData.append("description", coreMetadataDescription.value);
|
||||
|
||||
const result = await $dropFetch(
|
||||
`/api/v1/admin/game/${game.value.id}/metadata`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await $dropFetch(`/api/v1/admin/game/metadata`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -555,16 +530,14 @@ function coreMetadataUpdate_wrapper() {
|
||||
description: t("errors.game.metadata.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
buttonText: t("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;
|
||||
@ -590,7 +563,7 @@ const descriptionSaving = ref<DescriptionSavingState>(
|
||||
|
||||
let savingTimeout: undefined | NodeJS.Timeout;
|
||||
|
||||
type PatchGameBody = Partial<GameModel>;
|
||||
type PatchGameBody = Partial<Game>;
|
||||
|
||||
watch(descriptionHTML, (_v) => {
|
||||
descriptionSaving.value = DescriptionSavingState.Waiting;
|
||||
@ -598,12 +571,10 @@ watch(descriptionHTML, (_v) => {
|
||||
savingTimeout = setTimeout(async () => {
|
||||
try {
|
||||
descriptionSaving.value = DescriptionSavingState.Loading;
|
||||
await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
await $dropFetch("/api/v1/admin/game", {
|
||||
method: "PATCH",
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: {
|
||||
id: game.value.id,
|
||||
mDescription: game.value.mDescription,
|
||||
} satisfies PatchGameBody,
|
||||
});
|
||||
@ -616,7 +587,7 @@ watch(descriptionHTML, (_v) => {
|
||||
description: t("errors.game.description.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
@ -644,12 +615,10 @@ function insertImageAtCursor(id: string) {
|
||||
async function updateBannerImage(id: string) {
|
||||
try {
|
||||
if (game.value.mBannerObjectId == id) return;
|
||||
const { mBannerObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
const { mBannerObjectId } = await $dropFetch("/api/v1/admin/game", {
|
||||
method: "PATCH",
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: {
|
||||
id: game.value.id,
|
||||
mBannerObjectId: id,
|
||||
} satisfies PatchGameBody,
|
||||
});
|
||||
@ -662,7 +631,7 @@ async function updateBannerImage(id: string) {
|
||||
description: t("errors.game.banner.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
@ -672,12 +641,10 @@ async function updateBannerImage(id: string) {
|
||||
async function updateCoverImage(id: string) {
|
||||
try {
|
||||
if (game.value.mCoverObjectId == id) return;
|
||||
const { mCoverObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
const { mCoverObjectId } = await $dropFetch("/api/v1/admin/game", {
|
||||
method: "PATCH",
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: {
|
||||
id: game.value.id,
|
||||
mCoverObjectId: id,
|
||||
} satisfies PatchGameBody,
|
||||
});
|
||||
@ -690,7 +657,7 @@ async function updateCoverImage(id: string) {
|
||||
description: t("errors.game.cover.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
@ -719,14 +686,14 @@ async function deleteImage(id: string) {
|
||||
description: t("errors.game.deleteImage.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAfterImageUpload(result: GameModel) {
|
||||
async function uploadAfterImageUpload(result: Game) {
|
||||
if (!game.value) return;
|
||||
game.value.mImageLibraryObjectIds = result.mImageLibraryObjectIds;
|
||||
}
|
||||
@ -746,12 +713,10 @@ function removeImageFromCarousel(id: string) {
|
||||
|
||||
async function updateImageCarousel() {
|
||||
try {
|
||||
await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
await $dropFetch("/api/v1/admin/game", {
|
||||
method: "PATCH",
|
||||
params: {
|
||||
id: game.value.id,
|
||||
},
|
||||
body: {
|
||||
id: game.value.id,
|
||||
mImageCarouselObjectIds: game.value.mImageCarouselObjectIds,
|
||||
} satisfies PatchGameBody,
|
||||
});
|
||||
@ -763,7 +728,7 @@ async function updateImageCarousel() {
|
||||
description: t("errors.game.carousel.description", [
|
||||
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
|
||||
]),
|
||||
buttonText: t("common.close"),
|
||||
buttonText: t("close"),
|
||||
},
|
||||
(e, c) => c(),
|
||||
);
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div v-if="game && unimportedVersions">
|
||||
<div class="grow flex flex-row gap-y-8">
|
||||
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
|
||||
<div
|
||||
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
|
||||
>
|
||||
<!-- version manager -->
|
||||
<div>
|
||||
<!-- version priority -->
|
||||
<div>
|
||||
<div class="border-b border-zinc-800 pb-3">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
|
||||
>
|
||||
<h3
|
||||
class="text-base font-semibold font-display leading-6 text-zinc-100"
|
||||
>
|
||||
{{ $t("library.admin.versionPriority") }}
|
||||
|
||||
<!-- import games button -->
|
||||
|
||||
<NuxtLink
|
||||
:href="
|
||||
unimportedVersions.length > 0
|
||||
? `/admin/library/${game.id}/import`
|
||||
: ''
|
||||
"
|
||||
type="button"
|
||||
:class="[
|
||||
unimportedVersions.length > 0
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-800/50',
|
||||
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
unimportedVersions.length > 0
|
||||
? $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
|
||||
|
||||
defineProps<{ unimportedVersions: string[] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
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,
|
||||
);
|
||||
} 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,73 +1,44 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
v-if="game || defaultPlaceholder"
|
||||
:href="href"
|
||||
:class="{
|
||||
'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5':
|
||||
animate,
|
||||
}"
|
||||
class="group relative flex-1 min-w-42 max-w-48 h-64 rounded-lg overflow-hidden"
|
||||
v-if="game"
|
||||
:href="props.href ?? `/store/${game.id}`"
|
||||
class="group relative w-48 h-64 rounded-lg overflow-hidden transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5"
|
||||
@click="active = game.id"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'transition-all duration-300 group-hover:scale-110': animate,
|
||||
}"
|
||||
class="absolute inset-0"
|
||||
class="absolute inset-0 transition-all duration-300 group-hover:scale-110"
|
||||
>
|
||||
<img
|
||||
:src="imageProps.src"
|
||||
:src="useObject(game.mCoverObjectId)"
|
||||
class="w-full h-full object-cover brightness-[90%]"
|
||||
:alt="imageProps.alt"
|
||||
:class="{ active: active === game.id }"
|
||||
:alt="game.mName"
|
||||
/>
|
||||
<div
|
||||
v-if="showTitleDescription"
|
||||
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/0 to-transparent"
|
||||
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showTitleDescription"
|
||||
class="absolute bottom-0 left-0 w-full p-3"
|
||||
>
|
||||
<div class="absolute bottom-0 left-0 w-full p-3">
|
||||
<h1
|
||||
:class="{ 'group-hover:text-white transition-colors': animate }"
|
||||
class="text-zinc-100 text-sm font-bold font-display"
|
||||
class="text-zinc-100 text-sm font-bold font-display group-hover:text-white transition-colors"
|
||||
>
|
||||
{{
|
||||
game ? game.mName : $t("settings.admin.store.dropGameNamePlaceholder")
|
||||
}}
|
||||
{{ game.mName }}
|
||||
</h1>
|
||||
<p
|
||||
:class="{
|
||||
'group-hover:text-zinc-300 transition-colors': animate,
|
||||
}"
|
||||
class="text-zinc-400 text-xs line-clamp-2"
|
||||
class="text-zinc-400 text-xs line-clamp-2 group-hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{{
|
||||
game
|
||||
? game.mShortDescription
|
||||
: $t("settings.admin.store.dropGameDescriptionPlaceholder")
|
||||
}}
|
||||
{{ game.mShortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<SkeletonCard
|
||||
v-else-if="defaultPlaceholder === false"
|
||||
:message="$t('store.noGame')"
|
||||
/>
|
||||
<SkeletonCard v-else :message="$t('store.noGame')" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
game,
|
||||
href = undefined,
|
||||
showTitleDescription = true,
|
||||
animate = true,
|
||||
defaultPlaceholder = false,
|
||||
} = defineProps<{
|
||||
const props = defineProps<{
|
||||
game:
|
||||
| SerializeObject<{
|
||||
id: string;
|
||||
@ -75,25 +46,11 @@ const {
|
||||
mName: string;
|
||||
mShortDescription: string;
|
||||
}>
|
||||
| undefined
|
||||
| null;
|
||||
| undefined;
|
||||
href?: string;
|
||||
showTitleDescription?: boolean;
|
||||
animate?: boolean;
|
||||
defaultPlaceholder?: boolean;
|
||||
}>();
|
||||
|
||||
const imageProps = {
|
||||
src: "",
|
||||
alt: t("settings.admin.store.dropGameAltPlaceholder"),
|
||||
};
|
||||
|
||||
if (game) {
|
||||
imageProps.src = useObject(game.mCoverObjectId);
|
||||
imageProps.alt = game.mName;
|
||||
} else if (defaultPlaceholder) {
|
||||
imageProps.src = "/game-panel-placeholder.png";
|
||||
}
|
||||
const active = useState();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -19,6 +19,6 @@
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
|
||||
const { game } = defineProps<{
|
||||
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
|
||||
game: GameMetadataSearchResult & { sourceName?: string };
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@ -1,6 +1,80 @@
|
||||
<template>
|
||||
<div>
|
||||
<LanguageSelectorListbox />
|
||||
<Listbox v-model="wiredLocale" as="div">
|
||||
<ListboxLabel 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>
|
||||
<NuxtLink
|
||||
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
|
||||
to="https://translate.droposs.org/projects/drop/"
|
||||
@ -23,3 +97,80 @@
|
||||
</DevOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CheckIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { Locale } from "vue-i18n";
|
||||
|
||||
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>
|
||||
|
||||
@ -1,155 +0,0 @@
|
||||
<template>
|
||||
<Listbox v-model="wiredLocale" as="div">
|
||||
<ListboxLabel
|
||||
v-if="showText"
|
||||
class="block text-sm/6 font-medium text-zinc-400"
|
||||
>{{ $t("selectLanguage") }}</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="grid w-full cursor-default grid-cols-1 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-300 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="col-start-1 row-start-1 flex items-center gap-3 pr-6">
|
||||
<EmojiText
|
||||
:emoji="localeToEmoji(wiredLocale)"
|
||||
class="-mt-0.5 shrink-0 max-w-6"
|
||||
/>
|
||||
<span class="block truncate">{{
|
||||
currentLocaleInformation?.name ?? wiredLocale
|
||||
}}</span>
|
||||
</span>
|
||||
<ChevronUpDownIcon
|
||||
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-gray-500 sm:size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="listLocale in locales"
|
||||
:key="listLocale.code"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="listLocale.code"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-hidden'
|
||||
: 'text-zinc-300',
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<EmojiText
|
||||
:emoji="localeToEmoji(listLocale.code)"
|
||||
class="-mt-0.5 shrink-0 max-w-6"
|
||||
/>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'ml-3 block truncate',
|
||||
]"
|
||||
>{{ listLocale.name }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon } from "@heroicons/vue/24/outline";
|
||||
import type { Locale } from "vue-i18n";
|
||||
|
||||
const { showText = true } = defineProps<{ showText?: boolean }>();
|
||||
|
||||
const { locales, locale: currLocale, setLocale } = useI18n();
|
||||
|
||||
function changeLocale(locale: Locale) {
|
||||
setLocale(locale);
|
||||
|
||||
// dynamically update the HTML attributes for language and direction
|
||||
// this is necessary for proper rendering of the page in the new language
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: locale,
|
||||
dir: locales.value.find((l) => l.code === locale)?.dir || "ltr",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function localeToEmoji(local: string): string {
|
||||
switch (local) {
|
||||
// Default locale
|
||||
case "en":
|
||||
case "en-us":
|
||||
return "🇺🇸";
|
||||
|
||||
case "en-gb":
|
||||
return "🇬🇧";
|
||||
case "en-ca":
|
||||
return "🇨🇦";
|
||||
case "en-au":
|
||||
return "🇦🇺";
|
||||
case "en-pirate":
|
||||
return "🏴☠️";
|
||||
case "fr":
|
||||
return "🇫🇷";
|
||||
case "de":
|
||||
return "🇩🇪";
|
||||
case "es":
|
||||
return "🇪🇸";
|
||||
case "it":
|
||||
return "🇮🇹";
|
||||
case "zh":
|
||||
return "🇨🇳";
|
||||
case "zh-tw":
|
||||
return "🇹🇼";
|
||||
|
||||
default: {
|
||||
return "❓";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wiredLocale = computed({
|
||||
get() {
|
||||
return currLocale.value;
|
||||
},
|
||||
set(v) {
|
||||
changeLocale(v);
|
||||
},
|
||||
});
|
||||
const currentLocaleInformation = computed(() =>
|
||||
locales.value.find((e) => e.code == wiredLocale.value),
|
||||
);
|
||||
</script>
|
||||
@ -31,11 +31,11 @@
|
||||
<li v-for="game in filteredLibrary" :key="game.id" class="flex">
|
||||
<NuxtLink
|
||||
:to="`/library/game/${game.id}`"
|
||||
class="flex flex-row items-center w-full p-2 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
|
||||
class="flex flex-row items-center w-full p-1 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
|
||||
>
|
||||
<img
|
||||
:src="useObject(game.mIconObjectId)"
|
||||
class="h-5 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
|
||||
:src="useObject(game.mCoverObjectId)"
|
||||
class="h-9 w-9 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
|
||||
alt=""
|
||||
/>
|
||||
<div class="min-w-0 flex-1 pl-2.5">
|
||||
98
components/LudusaviSearchbar.vue
Normal file
98
components/LudusaviSearchbar.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<Combobox v-model="selectedGame" as="div" @update:model-value="query = ''">
|
||||
<ComboboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
|
||||
$t("library.admin.cloudSaves.search")
|
||||
}}</ComboboxLabel>
|
||||
<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-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
:display-value="(value) => value as string"
|
||||
@change="query = $event.target.value"
|
||||
@blur="query = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
|
||||
>
|
||||
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
v-if="searchResults.length > 0"
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-950 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="result in searchResults"
|
||||
:key="result"
|
||||
v-slot="{ active, selected }"
|
||||
:value="result"
|
||||
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', selected && 'font-semibold']">
|
||||
{{ result }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxLabel,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update", id: string): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{ default?: string }>();
|
||||
|
||||
const query = ref("");
|
||||
const selectedGame = ref<string | null>(props.default ?? null);
|
||||
|
||||
const searchResults = ref<Array<string>>([]);
|
||||
|
||||
let searchTimeout: NodeJS.Timeout | null = null;
|
||||
watch(query, (name) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
if (!name) return;
|
||||
searchResults.value = [];
|
||||
searchTimeout = setTimeout(async () => {
|
||||
const results = await $dropFetch("/api/v1/admin/game/cloudsaves/search", {
|
||||
query: { name },
|
||||
});
|
||||
searchResults.value = results.map((e) => e.name);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
watch(selectedGame, (v) => {
|
||||
if (!v) return;
|
||||
emit("update", v);
|
||||
});
|
||||
</script>
|
||||
@ -1,246 +0,0 @@
|
||||
<template>
|
||||
<ModalTemplate v-model="open">
|
||||
<template #default>
|
||||
<div>
|
||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||
{{ $t("library.admin.metadata.companies.addGame.title") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
{{ $t("library.admin.metadata.companies.addGame.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form @submit.prevent="() => addGame()">
|
||||
<Listbox v-model="currentGame" as="div">
|
||||
<ListboxLabel
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<GameSearchResultWidget
|
||||
v-if="currentGame"
|
||||
:game="currentGame"
|
||||
/>
|
||||
<span v-else class="block truncate text-zinc-600">
|
||||
{{ $t("library.admin.import.selectGamePlaceholder") }}
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="result in metadataGames"
|
||||
:key="result.id"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="result"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<GameSearchResultWidget :game="result" />
|
||||
</li>
|
||||
</ListboxOption>
|
||||
<p
|
||||
v-if="metadataGames.length == 0"
|
||||
class="w-full text-center p-2 uppercase font-display text-zinc-700 font-bold"
|
||||
>
|
||||
{{ $t("library.admin.metadata.companies.addGame.noGames") }}
|
||||
</p>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
<div class="mt-6 flex items-center justify-between gap-3">
|
||||
<label
|
||||
id="published-label"
|
||||
for="published"
|
||||
class="font-medium text-md text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.addGame.publisher")
|
||||
}}</label
|
||||
>
|
||||
|
||||
<div
|
||||
class="group/published relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-5"
|
||||
/>
|
||||
<input
|
||||
id="published"
|
||||
v-model="published"
|
||||
type="checkbox"
|
||||
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
|
||||
aria-labelledby="published-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between gap-3">
|
||||
<label
|
||||
id="developer-label"
|
||||
for="developer"
|
||||
class="font-medium text-md text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.addGame.developer")
|
||||
}}</label
|
||||
>
|
||||
|
||||
<div
|
||||
class="group/developer relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developer:translate-x-5"
|
||||
/>
|
||||
<input
|
||||
id="developer"
|
||||
v-model="developed"
|
||||
type="checkbox"
|
||||
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
|
||||
aria-labelledby="developer-label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="hidden" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="addError" class="mt-3 rounded-md bg-red-600/10 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ addError }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons="{ close }">
|
||||
<LoadingButton
|
||||
:loading="addGameLoading"
|
||||
:disabled="!(currentGame && (developed || published))"
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => addGame()"
|
||||
>
|
||||
{{ $t("common.add") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="() => close()"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import {
|
||||
DialogTitle,
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
const props = defineProps<{
|
||||
companyId: string;
|
||||
exclude?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [
|
||||
game: SerializeObject<GameModel>,
|
||||
published: boolean,
|
||||
developed: boolean,
|
||||
];
|
||||
}>();
|
||||
|
||||
const games = await $dropFetch("/api/v1/admin/game");
|
||||
const metadataGames = computed(() =>
|
||||
games
|
||||
.filter((e) => !(props.exclude ?? []).includes(e.id))
|
||||
.map(
|
||||
(e) =>
|
||||
({
|
||||
id: e.id,
|
||||
name: e.mName,
|
||||
icon: useObject(e.mIconObjectId),
|
||||
description: e.mShortDescription,
|
||||
}) satisfies Omit<GameMetadataSearchResult, "year">,
|
||||
),
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const currentGame = ref<(typeof metadataGames.value)[number]>();
|
||||
const developed = ref(false);
|
||||
const published = ref(false);
|
||||
const addGameLoading = ref(false);
|
||||
const addError = ref<string | undefined>(undefined);
|
||||
|
||||
async function addGame() {
|
||||
if (!currentGame.value) return;
|
||||
addGameLoading.value = true;
|
||||
|
||||
try {
|
||||
const game = await $dropFetch("/api/v1/admin/company/:id/game", {
|
||||
method: "POST",
|
||||
params: { id: props.companyId },
|
||||
body: {
|
||||
id: currentGame.value.id,
|
||||
developed: developed.value,
|
||||
published: published.value,
|
||||
},
|
||||
});
|
||||
emit("created", game, published.value, developed.value);
|
||||
} catch (e) {
|
||||
if (e instanceof FetchError) {
|
||||
addError.value = e.statusMessage ?? e.message ?? t("errors.unknown");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
currentGame.value = undefined;
|
||||
developed.value = false;
|
||||
published.value = false;
|
||||
addGameLoading.value = false;
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<ModalTemplate v-model="open">
|
||||
<template #default>
|
||||
<div>
|
||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||
{{ $t("library.admin.metadata.tags.modal.title") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
{{ $t("library.admin.metadata.tags.modal.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form @submit.prevent="() => createTag()">
|
||||
<input
|
||||
v-model="tagName"
|
||||
type="text"
|
||||
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
<button class="hidden" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #buttons="{ close }">
|
||||
<LoadingButton
|
||||
:loading="createTagLoading"
|
||||
:disabled="!tagName"
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => createTag()"
|
||||
>
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||
@click="() => close()"
|
||||
>
|
||||
{{ $t("cancel") }}
|
||||
</button>
|
||||
</template>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import type { GameTagModel } from "~/prisma/client/models";
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [tag: GameTagModel];
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ required: true });
|
||||
|
||||
const tagName = ref("");
|
||||
const createTagLoading = ref(false);
|
||||
|
||||
async function createTag() {
|
||||
if (!tagName.value || createTagLoading.value) return;
|
||||
|
||||
createTagLoading.value = true;
|
||||
|
||||
// Create the collection
|
||||
const tag = await $dropFetch("/api/v1/admin/tags", {
|
||||
method: "POST",
|
||||
body: { name: tagName.value },
|
||||
failTitle: "Failed to create tag",
|
||||
});
|
||||
|
||||
// Reset and emit
|
||||
tagName.value = "";
|
||||
open.value = false;
|
||||
|
||||
emit("created", tag);
|
||||
createTagLoading.value = false;
|
||||
}
|
||||
</script>
|
||||
@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="inline-flex gap-1 items-center flex-wrap">
|
||||
<span
|
||||
v-for="item in enabledItems"
|
||||
:key="item.param"
|
||||
class="inline-flex items-center gap-x-0.5 rounded-md bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-500 ring-1 ring-blue-800 ring-inset"
|
||||
>
|
||||
{{ item.name }}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-600/20"
|
||||
@click="() => remove(item.param)"
|
||||
>
|
||||
<span class="sr-only">{{ $t("common.remove") }}</span>
|
||||
<svg
|
||||
viewBox="0 0 14 14"
|
||||
class="size-3.5 stroke-blue-500 group-hover:stroke-blue-400"
|
||||
>
|
||||
<path d="M4 4l6 6m0-6l-6 6" />
|
||||
</svg>
|
||||
<span class="absolute -inset-1" />
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="enabledItems.length == 0"
|
||||
class="font-display uppercase text-xs font-bold text-zinc-700"
|
||||
>
|
||||
{{ $t("common.noSelected") }}
|
||||
</span>
|
||||
</div>
|
||||
<Combobox as="div" @update:model-value="add">
|
||||
<div class="relative mt-2">
|
||||
<ComboboxInput
|
||||
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
:display-value="(item) => (item as StoreSortOption)?.name"
|
||||
placeholder="Start typing..."
|
||||
@change="search = $event.target.value"
|
||||
@blur="search = ''"
|
||||
/>
|
||||
<ComboboxButton
|
||||
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
|
||||
>
|
||||
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
|
||||
</ComboboxButton>
|
||||
|
||||
<ComboboxOptions
|
||||
v-if="filteredItems.length > 0 || search.length > 0"
|
||||
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="item in filteredItems"
|
||||
:key="item.param"
|
||||
v-slot="{ active }"
|
||||
:value="item.param"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-hidden'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span class="block truncate">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon } from "@heroicons/vue/20/solid";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
const props = defineProps<{
|
||||
items: Array<StoreSortOption>;
|
||||
}>();
|
||||
|
||||
const model = defineModel<{ [key: string]: boolean }>();
|
||||
|
||||
const search = ref("");
|
||||
const filteredItems = computed(() =>
|
||||
props.items.filter(
|
||||
(item) =>
|
||||
!model.value?.[item.param] &&
|
||||
item.name.toLowerCase().includes(search.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
const enabledItems = computed(() =>
|
||||
props.items.filter((e) => model.value?.[e.param]),
|
||||
);
|
||||
|
||||
function add(item: string) {
|
||||
search.value = "";
|
||||
model.value ??= {};
|
||||
model.value[item] = true;
|
||||
}
|
||||
|
||||
function remove(item: string) {
|
||||
model.value ??= {};
|
||||
model.value[item] = false;
|
||||
}
|
||||
</script>
|
||||
@ -132,7 +132,7 @@
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="(e: Event) => (file = (e.target as any)?.files)"
|
||||
@change="(e) => (file = (e.target as any)?.files)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -91,7 +91,9 @@
|
||||
:src="useObject(article.imageObjectId)"
|
||||
class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-zinc-900/50" />
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-800 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="relative text-sm font-medium text-zinc-100">
|
||||
@ -149,8 +151,11 @@ const toggleTag = (tag: string) => {
|
||||
};
|
||||
|
||||
const formatExcerpt = (excerpt: string) => {
|
||||
// Convert markdown to HTML, micromark is safe
|
||||
return micromark(excerpt);
|
||||
// TODO: same as one in NewsArticleCreateButton
|
||||
// Convert markdown to HTML
|
||||
const html = micromark(excerpt);
|
||||
// Strip HTML tags using regex
|
||||
return html.replace(/<[^>]*>/g, "");
|
||||
};
|
||||
|
||||
const filteredArticles = computed(() => {
|
||||
@ -33,7 +33,7 @@
|
||||
class="inline-flex rounded-md text-zinc-400 hover:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@click="() => deleteMe()"
|
||||
>
|
||||
<span class="sr-only">{{ $t("common.close") }}</span>
|
||||
<span class="sr-only">{{ $t("close") }}</span>
|
||||
<XMarkIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@ -44,16 +44,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { Notification } from "~/prisma/client";
|
||||
|
||||
const props = defineProps<{ notification: NotificationModel }>();
|
||||
const props = defineProps<{ notification: Notification }>();
|
||||
|
||||
async function deleteMe() {
|
||||
await $dropFetch(`/api/v1/notifications/:id`, {
|
||||
await $dropFetch(`/api/v1/notifications/${props.notification.id}`, {
|
||||
method: "DELETE",
|
||||
params: {
|
||||
id: props.notification.id,
|
||||
},
|
||||
});
|
||||
const notifications = useNotifications();
|
||||
const indexOfMe = notifications.value.findIndex(
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'transition border border-3 rounded-xl relative cursor-pointer',
|
||||
active ? 'border-blue-600' : 'border-zinc-700',
|
||||
]"
|
||||
>
|
||||
<div v-if="active" class="absolute top-1 right-1 z-1">
|
||||
<CheckIcon
|
||||
class="rounded-full p-1.5 bg-blue-600 size-6 text-transparent stroke-3 stroke-zinc-900 font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from "@heroicons/vue/24/solid";
|
||||
|
||||
const { active = false } = defineProps<{ active?: boolean }>();
|
||||
</script>
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative inline-block group/relative-time">
|
||||
<div class="relative inline-block group">
|
||||
<!-- Visible relative time -->
|
||||
<time :datetime="isoDate" class="text-sm text-muted-foreground">
|
||||
{{ DateTime.fromJSDate(date).toRelative({ locale: $i18n.locale }) }}
|
||||
@ -8,7 +8,7 @@
|
||||
<!-- 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"
|
||||
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:opacity-100 transition-opacity pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ $d(date, "long") }}
|
||||
|
||||
@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div class="p-2 lg:p-4">
|
||||
<div class="px-4 py-2 max-w-xl">
|
||||
<h1 class="font-semibold text-zinc-100 text-xl">
|
||||
{{ $t("setup.auth.title") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("setup.auth.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid lg:grid-cols-2 xl:grid-cols-3 h-fit p-4 gap-4">
|
||||
<div class="p-4 border-1 border-zinc-800 rounded-xl">
|
||||
<div>
|
||||
<h1 class="text-zinc-100 font-semibold text-lg">
|
||||
{{ $t("setup.auth.simple.title") }}
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("setup.auth.simple.description") }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
|
||||
href="https://docs.droposs.org/docs/authentication/simple"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="setup.auth.docs"
|
||||
tag="span"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<span class="text-zinc-100 font-semibold text-sm">{{
|
||||
$t("setup.auth.enabled")
|
||||
}}</span>
|
||||
<CheckIcon
|
||||
v-if="enabledAuth.Simple"
|
||||
class="size-5 text-green-600"
|
||||
/>
|
||||
<XMarkIcon v-else class="size-5 text-red-600" />
|
||||
</div>
|
||||
<LoadingButton
|
||||
class="mt-4"
|
||||
:loading="invitationLoading"
|
||||
:disabled="!enabledAuth.Simple"
|
||||
@click="() => registerAsAdmin()"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="setup.auth.simple.register"
|
||||
tag="span"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
{{ $t("chars.arrow") }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-1 border-zinc-800 rounded-xl">
|
||||
<div>
|
||||
<h1 class="text-zinc-100 font-semibold text-lg">
|
||||
{{ $t("setup.auth.openid.title") }}
|
||||
</h1>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ $t("setup.auth.openid.description") }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
|
||||
href="https://docs.droposs.org/docs/authentication/oidc"
|
||||
target="_blank"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="setup.auth.docs"
|
||||
tag="span"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<ArrowTopRightOnSquareIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<span class="text-zinc-100 font-semibold text-sm">{{
|
||||
$t("setup.auth.enabled")
|
||||
}}</span>
|
||||
<CheckIcon
|
||||
v-if="enabledAuth.OpenID"
|
||||
class="size-5 text-green-600"
|
||||
/>
|
||||
<XMarkIcon v-else class="size-5 text-red-600" />
|
||||
</div>
|
||||
<LoadingButton
|
||||
class="mt-4"
|
||||
:loading="false"
|
||||
:disabled="!enabledAuth.OpenID"
|
||||
@click="() => (complete = true)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="setup.auth.openid.skip"
|
||||
tag="span"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
{{ $t("chars.arrow") }}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
const complete = defineModel<boolean>({ required: true });
|
||||
|
||||
const { token } = defineProps<{ token: string }>();
|
||||
|
||||
const invitationLoading = ref(false);
|
||||
|
||||
const enabledAuth = await $dropFetch("/api/v1/admin/auth", {
|
||||
headers: { Authorization: token },
|
||||
});
|
||||
|
||||
async function registerAsAdmin() {
|
||||
invitationLoading.value = true;
|
||||
const expiryDate = DateTime.now().plus({ year: 5000 }).toJSON();
|
||||
|
||||
const invitation = await $dropFetch("/api/v1/admin/auth/invitation", {
|
||||
method: "POST",
|
||||
body: { isAdmin: true, expires: expiryDate },
|
||||
headers: { Authorization: token },
|
||||
failTitle: "Failed to create admin invitation",
|
||||
});
|
||||
|
||||
window.open(`${invitation.inviteUrl}&after=close`, "_blank")?.focus();
|
||||
invitationLoading.value = false;
|
||||
complete.value = true;
|
||||
}
|
||||
</script>
|
||||
@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="p-8">
|
||||
<AdminSourcesPage :token="token" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AdminSourcesPage from "~/pages/admin/library/sources/index.vue";
|
||||
|
||||
const complete = defineModel<boolean>({ required: true });
|
||||
// Only runs on component load, so it's fine
|
||||
complete.value = true;
|
||||
|
||||
const { token } = defineProps<{ token: string }>();
|
||||
</script>
|
||||
@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<label
|
||||
for="path"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.sources.fsPath") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("library.admin.sources.fsPathDesc") }}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
id="path"
|
||||
v-model="model!.baseDir"
|
||||
name="path"
|
||||
type="text"
|
||||
autocomplete="path"
|
||||
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
|
||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<{ baseDir: string }>();
|
||||
</script>
|
||||
@ -1,488 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<!-- Mobile filter dialog -->
|
||||
<TransitionRoot as="template" :show="mobileFiltersOpen">
|
||||
<Dialog
|
||||
class="relative z-100 lg:hidden"
|
||||
@close="mobileFiltersOpen = false"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-black/25" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 z-40 flex">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enter-from="translate-x-full"
|
||||
enter-to="translate-x-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leave-from="translate-x-0"
|
||||
leave-to="translate-x-full"
|
||||
>
|
||||
<DialogPanel
|
||||
class="relative ml-auto flex size-full max-w-sm flex-col overflow-y-auto bg-zinc-900 pt-4 pb-6 shadow-xl"
|
||||
>
|
||||
<div class="flex items-center justify-between px-4">
|
||||
<h2 class="text-lg font-medium text-zinc-100">
|
||||
{{ $t("store.view.srFilters") }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="relative -mr-2 flex size-10 items-center justify-center rounded-md bg-zinc-900 p-2 text-zinc-500 hover:bg-zinc-800 focus:ring-2 focus:ring-blue-500 focus:outline-hidden"
|
||||
@click="mobileFiltersOpen = false"
|
||||
>
|
||||
<span class="absolute -inset-0.5" />
|
||||
<span class="sr-only">{{ $t("common.close") }}</span>
|
||||
<XMarkIcon class="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form class="mt-4 border-t border-zinc-700">
|
||||
<Disclosure
|
||||
v-for="section in options"
|
||||
v-slot="{ open }"
|
||||
:key="section.param"
|
||||
as="div"
|
||||
class="border-t border-zinc-700 px-4 py-6"
|
||||
>
|
||||
<h3 class="-mx-2 -my-3 flow-root">
|
||||
<DisclosureButton
|
||||
class="flex w-full items-center justify-between bg-zinc-900 px-2 py-3 text-zinc-500 hover:text-zinc-400"
|
||||
>
|
||||
<span class="font-medium text-zinc-100">{{
|
||||
section.name
|
||||
}}</span>
|
||||
<span class="ml-6 flex items-center">
|
||||
<PlusIcon
|
||||
v-if="!open"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MinusIcon v-else class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
</h3>
|
||||
<DisclosurePanel class="pt-6">
|
||||
<div
|
||||
v-if="section.options.length <= 10"
|
||||
class="gap-3 grid grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="(option, optionIdx) in section.options"
|
||||
:key="option.param"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div class="flex h-5 shrink-0 items-center">
|
||||
<div class="group grid size-4 grid-cols-1">
|
||||
<input
|
||||
v-if="section.multiple"
|
||||
:id="`filter-${section.param}-${option}`"
|
||||
v-model="
|
||||
(optionValues[section.param] as any)[
|
||||
option.param
|
||||
]
|
||||
"
|
||||
:name="`${section.param}[]`"
|
||||
type="checkbox"
|
||||
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-900 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="`filter-${section.param}`"
|
||||
:value="optionValues[section.param]"
|
||||
:name="`${section.param}[]`"
|
||||
type="checkbox"
|
||||
class="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
|
||||
@update:value="
|
||||
() =>
|
||||
(optionValues[section.param] = option.param)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
:for="`filter-mobile-${section.param}-${optionIdx}`"
|
||||
class="min-w-0 flex-1 text-zinc-400"
|
||||
>{{ option.name }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<MultiItemSelector
|
||||
v-else
|
||||
v-model="[optionValues[section.param] as any][0]"
|
||||
:items="section.options"
|
||||
/>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</form>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
|
||||
<main class="mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="flex items-baseline justify-between border-b border-zinc-700 py-6"
|
||||
>
|
||||
<div />
|
||||
<div class="flex items-center">
|
||||
<Menu as="div" class="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton
|
||||
class="group inline-flex justify-center text-sm font-medium text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
{{ $t("store.view.sort") }}
|
||||
<ChevronDownIcon
|
||||
class="-mr-1 ml-1 size-5 shrink-0 text-gray-400 group-hover:text-zinc-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-zinc-950 shadow-2xl ring-1 ring-white/5 focus:outline-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<MenuItem
|
||||
v-for="option in sorts"
|
||||
:key="option.param"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
currentSort == option.param
|
||||
? 'font-medium text-zinc-100'
|
||||
: 'text-zinc-400',
|
||||
active ? 'bg-zinc-900 outline-hidden' : '',
|
||||
'w-full text-left block px-4 py-2 text-sm',
|
||||
]"
|
||||
@click="() => (currentSort = option.param)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
|
||||
<button
|
||||
v-if="false"
|
||||
type="button"
|
||||
class="-m-2 ml-5 p-2 text-zinc-500 hover:text-zinc-400 sm:ml-7"
|
||||
>
|
||||
<span class="sr-only">{{ $t("store.view.srViewGrid") }}</span>
|
||||
<Squares2X2Icon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'-m-2 ml-4 p-2 sm:ml-6 lg:hidden',
|
||||
filterQuery
|
||||
? 'text-zinc-100 hover:text-zinc-200'
|
||||
: 'text-zinc-500 hover:text-zinc-400',
|
||||
]"
|
||||
@click="mobileFiltersOpen = true"
|
||||
>
|
||||
<span class="sr-only"> {{ $t("store.view.srFilters") }} </span>
|
||||
<FunnelIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="games-heading" class="pt-6 pb-24">
|
||||
<h2 id="games-heading" class="sr-only">
|
||||
{{ $t("store.view.srGames") }}
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-10 lg:grid-cols-5">
|
||||
<!-- Filters -->
|
||||
<form class="hidden lg:block">
|
||||
<Disclosure
|
||||
v-for="section in options"
|
||||
:key="section.param"
|
||||
v-slot="{ open }"
|
||||
as="div"
|
||||
class="border-b border-zinc-700 py-6"
|
||||
>
|
||||
<h3 class="-my-3 flow-root">
|
||||
<DisclosureButton
|
||||
class="flex w-full items-center justify-between bg-zinc-900 py-3 text-sm text-zinc-500 hover:text-zinc-400"
|
||||
>
|
||||
<span class="font-medium text-zinc-100">{{
|
||||
section.name
|
||||
}}</span>
|
||||
<span class="ml-6 flex items-center">
|
||||
<PlusIcon
|
||||
v-if="!open"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<MinusIcon v-else class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</DisclosureButton>
|
||||
</h3>
|
||||
<DisclosurePanel class="pt-6">
|
||||
<div v-if="section.options.length <= 10" class="space-y-4">
|
||||
<div
|
||||
v-for="(option, optionIdx) in section.options"
|
||||
:key="option.param"
|
||||
class="flex 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",
|
||||
},
|
||||
];
|
||||
const currentSort = ref(sorts[0].param);
|
||||
|
||||
const options: Array<StoreFilterOption> = [
|
||||
...(tags.length > 0
|
||||
? [
|
||||
{
|
||||
name: "Tags",
|
||||
param: "tags",
|
||||
multiple: true,
|
||||
options: tags.map((e) => ({ name: e.name, param: e.id })),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Platform",
|
||||
param: "platform",
|
||||
multiple: true,
|
||||
options: 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}${query ? "&" + query : ""}`,
|
||||
);
|
||||
if (resetGames) {
|
||||
games.value = newValues.results;
|
||||
if (import.meta.client) await reset();
|
||||
} else {
|
||||
games.value.push(...newValues.results);
|
||||
}
|
||||
canLoadMore.value = games.value.length < newValues.count;
|
||||
loading.value = false;
|
||||
}
|
||||
watch(filterQuery, (newUrl) => {
|
||||
updateGames(newUrl, true);
|
||||
});
|
||||
watch(currentSort, (_) => {
|
||||
updateGames(filterQuery.value, true);
|
||||
});
|
||||
|
||||
await updateGames(filterQuery.value, true);
|
||||
</script>
|
||||
@ -67,7 +67,7 @@
|
||||
class="hidden"
|
||||
type="file"
|
||||
:multiple="props.multiple"
|
||||
@change="(e: Event) => (file = (e.target as any)?.files)"
|
||||
@change="(e) => (file = (e.target as any)?.files)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -116,7 +116,7 @@ const { t } = useI18n();
|
||||
|
||||
const versionInfo = await $dropFetch("/api/v1");
|
||||
|
||||
const navigation = computed(() => ({
|
||||
const navigation = {
|
||||
games: [
|
||||
{ name: t("store.recentlyAdded"), href: "#" },
|
||||
{ name: t("store.recentlyReleased"), href: "#" },
|
||||
@ -156,5 +156,5 @@ const navigation = computed(() => ({
|
||||
icon: IconsDiscordLogo,
|
||||
},
|
||||
],
|
||||
}));
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { Notification } from "~/prisma/client";
|
||||
|
||||
const props = defineProps<{ notifications: Array<NotificationModel> }>();
|
||||
const props = defineProps<{ notifications: Array<Notification> }>();
|
||||
</script>
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import type {
|
||||
CollectionModel,
|
||||
CollectionEntryModel,
|
||||
GameModel,
|
||||
} from "~/prisma/client/models";
|
||||
import type { Collection, CollectionEntry, Game } from "~/prisma/client";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
type FullCollection = CollectionModel & {
|
||||
entries: Array<CollectionEntryModel & { game: SerializeObject<GameModel> }>;
|
||||
type FullCollection = Collection & {
|
||||
entries: Array<CollectionEntry & { game: SerializeObject<Game> }>;
|
||||
};
|
||||
|
||||
export const useCollections = async () => {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { ArticleModel } from "~/prisma/client/models";
|
||||
import type { Article } from "~/prisma/client";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
export const useNews = () =>
|
||||
useState<
|
||||
| Array<
|
||||
SerializeObject<
|
||||
ArticleModel & {
|
||||
Article & {
|
||||
tags: Array<{ id: string; name: string }>;
|
||||
author: { displayName: string; id: string } | null;
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { Notification } from "~/prisma/client";
|
||||
|
||||
const ws = new WebSocketHandler("/api/v1/notifications/ws");
|
||||
|
||||
export const useNotifications = () =>
|
||||
useState<Array<NotificationModel>>("notifications", () => []);
|
||||
useState<Array<Notification>>("notifications", () => []);
|
||||
|
||||
ws.listen((e) => {
|
||||
const notification = JSON.parse(e) as NotificationModel;
|
||||
const notification = JSON.parse(e) as Notification;
|
||||
const notifications = useNotifications();
|
||||
notifications.value.push(notification);
|
||||
});
|
||||
|
||||
@ -4,7 +4,6 @@ import type {
|
||||
NitroFetchRequest,
|
||||
TypedInternalResponse,
|
||||
} from "nitropack/types";
|
||||
import type { FetchError } from "ofetch";
|
||||
|
||||
interface DropFetch<
|
||||
DefaultT = unknown,
|
||||
@ -16,7 +15,7 @@ interface DropFetch<
|
||||
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
|
||||
>(
|
||||
request: R,
|
||||
opts?: O & { failTitle?: string },
|
||||
opts?: O,
|
||||
): Promise<
|
||||
// sometimes there is an error, other times there isn't
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@ -29,29 +28,12 @@ interface DropFetch<
|
||||
>;
|
||||
}
|
||||
|
||||
export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
const requestParts = rawRequest.toString().split("/");
|
||||
requestParts.forEach((part, index) => {
|
||||
if (!part.startsWith(":")) {
|
||||
return;
|
||||
}
|
||||
const partName = part.slice(1);
|
||||
const replacement = opts?.params?.[partName] as string | undefined;
|
||||
if (!replacement) {
|
||||
return;
|
||||
}
|
||||
requestParts[index] = replacement;
|
||||
|
||||
delete opts?.params?.[partName];
|
||||
});
|
||||
const request = requestParts.join("/");
|
||||
|
||||
export const $dropFetch: DropFetch = async (request, opts) => {
|
||||
if (!getCurrentInstance()?.proxy) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Excessive stack depth comparing types
|
||||
return await $fetch(request, opts);
|
||||
}
|
||||
|
||||
const id = request.toString();
|
||||
|
||||
const state = useState(id);
|
||||
@ -59,31 +41,15 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
// Deep copy
|
||||
const object = JSON.parse(JSON.stringify(state.value));
|
||||
// Never use again on client
|
||||
if (import.meta.client) state.value = undefined;
|
||||
state.value = undefined;
|
||||
return object;
|
||||
}
|
||||
|
||||
const headers = useRequestHeaders(["cookie", "authorization"]);
|
||||
try {
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (import.meta.client && opts?.failTitle) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.statusMessage ?? (e as string).toString(),
|
||||
buttonText: $t("common.close"),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const data = await $fetch(request, {
|
||||
...opts,
|
||||
headers: { ...opts?.headers, ...headers },
|
||||
});
|
||||
if (import.meta.server) state.value = data;
|
||||
return data;
|
||||
};
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
export type StoreFilterOption = {
|
||||
name: string;
|
||||
param: string;
|
||||
options: Array<StoreSortOption>;
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
export type StoreSortOption = {
|
||||
name: string;
|
||||
param: string;
|
||||
};
|
||||
@ -1,13 +1,13 @@
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { User } from "~/prisma/client";
|
||||
|
||||
// undefined = haven't check
|
||||
// null = check, no user
|
||||
// {} = check, user
|
||||
|
||||
export const useUser = () => useState<UserModel | undefined | null>(undefined);
|
||||
export const useUser = () => useState<User | undefined | null>(undefined);
|
||||
export const updateUser = async () => {
|
||||
const user = useUser();
|
||||
if (user.value === null) return;
|
||||
|
||||
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
|
||||
user.value = await $dropFetch<User | null>("/api/v1/user");
|
||||
};
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { AuthMec } from "~/prisma/client/enums";
|
||||
import type { User, AuthMec } from "~/prisma/client";
|
||||
|
||||
export const useUsers = () =>
|
||||
useState<
|
||||
| Array<
|
||||
SerializeObject<
|
||||
UserModel & {
|
||||
User & {
|
||||
authMecs?: Array<{ id: string; mec: AuthMec }>;
|
||||
}
|
||||
>
|
||||
|
||||
@ -17,7 +17,7 @@ services:
|
||||
- POSTGRES_USER=drop
|
||||
- POSTGRES_DB=drop
|
||||
drop:
|
||||
image: ghcr.io/drop-oss/drop:nightly
|
||||
image: ghcr.io/drop-oss/drop:latest
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
|
||||
Submodule drop-base updated: 04125e89be...a14d1b7081
@ -13,12 +13,11 @@ export default withNuxt([
|
||||
// Optional.
|
||||
"@intlify/vue-i18n/no-dynamic-keys": "error",
|
||||
"@intlify/vue-i18n/no-unused-keys": [
|
||||
"off",
|
||||
"error",
|
||||
{
|
||||
extensions: [".js", ".vue", ".ts"],
|
||||
},
|
||||
],
|
||||
"@intlify/vue-i18n/no-missing-keys": "error",
|
||||
},
|
||||
settings: {
|
||||
"vue-i18n": {
|
||||
|
||||
@ -1,5 +1 @@
|
||||
{
|
||||
"setup": {
|
||||
"welcome": "G'day."
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@ -24,8 +24,8 @@
|
||||
},
|
||||
"actions": "Deeds",
|
||||
"add": "Add",
|
||||
"adminTitle": "Cap'n's Quarters - Drop",
|
||||
"adminTitleTemplate": "{0} - Cap'n - Drop",
|
||||
"adminTitle": "Cap'n's Quarters | Drop",
|
||||
"adminTitleTemplate": "{0} | Cap'n | Drop",
|
||||
"auth": {
|
||||
"callback": {
|
||||
"authClient": "Grant passage to this scallywag?",
|
||||
@ -70,30 +70,27 @@
|
||||
"quoted": "\"\"",
|
||||
"srComma": ", {0}"
|
||||
},
|
||||
"close": "Shut yer trap!",
|
||||
"common": {
|
||||
"cannotUndo": "This deed cannot be undone, ye hear!",
|
||||
"close": "Shut yer trap!",
|
||||
"create": "Forge!",
|
||||
"date": "Date",
|
||||
"deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?",
|
||||
"divider": "{'|'}",
|
||||
"edit": "Amend",
|
||||
"friends": "Shipmates",
|
||||
"groups": "Crews",
|
||||
"insert": "Insert",
|
||||
"name": "Name, argh!",
|
||||
"noResults": "No plunder found!",
|
||||
"save": "Stow it!",
|
||||
"servers": "Ships",
|
||||
"srLoading": "Loading, loading, argh...",
|
||||
"tags": "Marks",
|
||||
"today": "Today"
|
||||
},
|
||||
"create": "Forge!",
|
||||
"delete": "Scuttle!",
|
||||
"drop": {
|
||||
"desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!",
|
||||
"drop": "Drop"
|
||||
},
|
||||
"edit": "Amend",
|
||||
"editor": {
|
||||
"bold": "Bold, like a cannonball!",
|
||||
"boldPlaceholder": "bold text, matey",
|
||||
@ -217,6 +214,7 @@
|
||||
"helpUsTranslate": "Help us translate Drop {arrow}, argh!",
|
||||
"highest": "highest",
|
||||
"home": "Home Port",
|
||||
"insert": "Insert",
|
||||
"library": {
|
||||
"addGames": "All Plunder",
|
||||
"addToLib": "Add to Yer Treasure Hoard",
|
||||
@ -329,6 +327,7 @@
|
||||
"subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!"
|
||||
},
|
||||
"lowest": "lowest",
|
||||
"name": "Name, argh!",
|
||||
"news": {
|
||||
"article": {
|
||||
"add": "Add, ye dog!",
|
||||
@ -361,6 +360,7 @@
|
||||
"title": "Latest News from the High Seas"
|
||||
},
|
||||
"options": "Options, matey!",
|
||||
"save": "Stow it!",
|
||||
"security": "Safety",
|
||||
"selectLanguage": "Pick yer tongue",
|
||||
"settings": "Settings",
|
||||
|
||||
@ -19,13 +19,14 @@
|
||||
"title": "Notifications",
|
||||
"unread": "Unread Notifications"
|
||||
},
|
||||
"settings": "Settings",
|
||||
"title": "Account Settings"
|
||||
"settings": "Account Settings",
|
||||
"title": "Account"
|
||||
},
|
||||
"actions": "Actions",
|
||||
"add": "Add",
|
||||
"adminTitle": "Admin Dashboard - Drop",
|
||||
"adminTitleTemplate": "{0} - Admin - Drop",
|
||||
"adminTitle": "Admin Dashboard | Drop",
|
||||
"adminTitleTemplate": "{0} | Admin | Drop",
|
||||
"title": "Drop",
|
||||
"titleTemplate": "{0} | Drop",
|
||||
"auth": {
|
||||
"callback": {
|
||||
"authClient": "Authorize client?",
|
||||
@ -63,38 +64,6 @@
|
||||
"signout": "Signout",
|
||||
"username": "Username"
|
||||
},
|
||||
"setup": {
|
||||
"welcome": "Hey there.",
|
||||
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works.",
|
||||
"finish": "Let's go {arrow}",
|
||||
"noPage": "no page",
|
||||
"auth": {
|
||||
"title": "Authentication",
|
||||
"description": "Authentication in Drop happens through multiple configured 'providers'. Each one can allow users to sign-in through their method. To get started, have at least one authentication provider enabled, and create an account through it.",
|
||||
"docs": "Documentation {arrow}",
|
||||
"enabled": "Enabled?",
|
||||
"simple": {
|
||||
"title": "Simple authentication",
|
||||
"description": "Simple authentication uses username/password to authentication users. It is enabled by default if no other authentication provider is enabled.",
|
||||
"register": "Register as admin {arrow}"
|
||||
},
|
||||
"openid": {
|
||||
"title": "OpenID Connect",
|
||||
"description": "OpenID Connect (OIDC) is an OAuth2 extension commonly supported. Drop requires OIDC configuration to be done via environment variables.",
|
||||
"skip": "I have a user with OIDC"
|
||||
}
|
||||
},
|
||||
"stages": {
|
||||
"account": {
|
||||
"name": "Setup your admin account.",
|
||||
"description": "You need at least one account to start using Drop."
|
||||
},
|
||||
"library": {
|
||||
"name": "Create a library.",
|
||||
"description": "Add at least one library source to use Drop."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"chars": {
|
||||
"arrow": "→",
|
||||
@ -102,34 +71,27 @@
|
||||
"quoted": "\"\"",
|
||||
"srComma": ", {0}"
|
||||
},
|
||||
"close": "Close",
|
||||
"common": {
|
||||
"cannotUndo": "This action cannot be undone.",
|
||||
"close": "Close",
|
||||
"create": "Create",
|
||||
"date": "Date",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{0}\"?",
|
||||
"divider": "{'|'}",
|
||||
"edit": "Edit",
|
||||
"friends": "Friends",
|
||||
"groups": "Groups",
|
||||
"insert": "Insert",
|
||||
"name": "Name",
|
||||
"noResults": "No results",
|
||||
"noSelected": "No items selected.",
|
||||
"remove": "Remove",
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"servers": "Servers",
|
||||
"srLoading": "Loading...",
|
||||
"tags": "Tags",
|
||||
"today": "Today",
|
||||
"add": "Add"
|
||||
"divider": "{'|'}",
|
||||
"srLoading": "Loading..."
|
||||
},
|
||||
"create": "Create",
|
||||
"delete": "Delete",
|
||||
"drop": {
|
||||
"desc": "An open-source game distribution platform built for speed, flexibility and beauty.",
|
||||
"drop": "Drop"
|
||||
},
|
||||
"edit": "Edit",
|
||||
"editor": {
|
||||
"bold": "Bold",
|
||||
"boldPlaceholder": "bold text",
|
||||
@ -145,6 +107,17 @@
|
||||
"listItemPlaceholder": "list item"
|
||||
},
|
||||
"errors": {
|
||||
"auth": {
|
||||
"method": {
|
||||
"signinDisabled": "Sign in method not enabled"
|
||||
},
|
||||
"invalidUserOrPass": "Invalid username or password.",
|
||||
"disabled": "Invalid or disabled account. Please contact the server administrator.",
|
||||
"invalidPassState": "Invalid password state. Please contact the server administrator.",
|
||||
"inviteIdRequired": "id required in fetching invitation",
|
||||
"invalidInvite": "Invalid or expired invitation",
|
||||
"usernameTaken": "Username already taken."
|
||||
},
|
||||
"admin": {
|
||||
"user": {
|
||||
"delete": {
|
||||
@ -153,44 +126,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"disabled": "Invalid or disabled account. Please contact the server administrator.",
|
||||
"invalidInvite": "Invalid or expired invitation",
|
||||
"invalidPassState": "Invalid password state. Please contact the server administrator.",
|
||||
"invalidUserOrPass": "Invalid username or password.",
|
||||
"inviteIdRequired": "id required in fetching invitation",
|
||||
"method": {
|
||||
"signinDisabled": "Sign in method not enabled"
|
||||
},
|
||||
"usernameTaken": "Username already taken."
|
||||
},
|
||||
"backHome": "{arrow} Back to home",
|
||||
"game": {
|
||||
"banner": {
|
||||
"description": "Drop failed to update the banner image: {0}",
|
||||
"title": "Failed to update the banner image"
|
||||
},
|
||||
"carousel": {
|
||||
"description": "Drop failed to update the image carousel: {0}",
|
||||
"title": "Failed to update image carousel"
|
||||
},
|
||||
"cover": {
|
||||
"description": "Drop failed to update the cover image: {0}",
|
||||
"title": "Failed to update the cover image"
|
||||
},
|
||||
"deleteImage": {
|
||||
"description": "Drop failed to delete the image: {0}",
|
||||
"title": "Failed to delete the image"
|
||||
},
|
||||
"description": {
|
||||
"description": "Drop failed to update the game description: {0}",
|
||||
"title": "Failed to update game description"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Drop failed to update the game's metadata: {0}",
|
||||
"title": "Failed to update metadata"
|
||||
}
|
||||
},
|
||||
"invalidBody": "Invalid request body: {0}",
|
||||
"inviteRequired": "Invitation required to sign up.",
|
||||
"library": {
|
||||
@ -227,10 +163,6 @@
|
||||
"signIn": "Sign in {arrow}",
|
||||
"support": "Support Discord",
|
||||
"unknown": "An unknown error occurred",
|
||||
"upload": {
|
||||
"description": "Drop couldn't upload the file: {0}",
|
||||
"title": "Failed to upload file"
|
||||
},
|
||||
"version": {
|
||||
"delete": {
|
||||
"desc": "Drop encountered an error while deleting the version: {error}",
|
||||
@ -240,17 +172,51 @@
|
||||
"desc": "Drop encountered an error while updating the version: {error}",
|
||||
"title": "There an error while updating the version order"
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"title": "Failed to upload file",
|
||||
"description": "Drop couldn't upload the file: {0}"
|
||||
},
|
||||
"game": {
|
||||
"metadata": {
|
||||
"title": "Failed to update metadata",
|
||||
"description": "Drop failed to update the game's metadata: {0}"
|
||||
},
|
||||
"description": {
|
||||
"title": "Failed to update game description",
|
||||
"description": "Drop failed to update the game description: {0}"
|
||||
},
|
||||
"banner": {
|
||||
"title": "Failed to update the banner image",
|
||||
"description": "Drop failed to update the banner image: {0}"
|
||||
},
|
||||
"cover": {
|
||||
"title": "Failed to update the cover image",
|
||||
"description": "Drop failed to update the cover image: {0}"
|
||||
},
|
||||
"deleteImage": {
|
||||
"title": "Failed to delete the image",
|
||||
"description": "Drop failed to delete the image: {0}"
|
||||
},
|
||||
"carousel": {
|
||||
"title": "Failed to update image carousel",
|
||||
"description": "Drop failed to update the image carousel: {0}"
|
||||
},
|
||||
"ludusavi": {
|
||||
"title": "Failed to update Ludusavi entry",
|
||||
"description": "Drop failed to update the Ludusavi entry: {0}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
"about": "About",
|
||||
"aboutDrop": "About Drop",
|
||||
"comparison": "Comparison",
|
||||
"docs": {
|
||||
"client": "Client Docs",
|
||||
"server": "Server Docs"
|
||||
},
|
||||
"documentation": "Documentation",
|
||||
"comparison": "Comparison",
|
||||
"findGame": "Find a Game",
|
||||
"footer": "Footer",
|
||||
"games": "Games",
|
||||
@ -264,43 +230,80 @@
|
||||
"header": {
|
||||
"admin": {
|
||||
"admin": "Admin",
|
||||
"metadata": "Meta",
|
||||
"settings": "Settings",
|
||||
"tasks": "Tasks",
|
||||
"users": "Users"
|
||||
},
|
||||
"back": "Back",
|
||||
"openSidebar": "Open sidebar"
|
||||
},
|
||||
"helpUsTranslate": "Help us translate Drop {arrow}",
|
||||
"highest": "highest",
|
||||
"home": "Home",
|
||||
"users": {
|
||||
"admin": {
|
||||
"description": "Manage the users on your Drop instance, and configure your authentication methods.",
|
||||
"authLink": "Authentication {arrow}",
|
||||
"displayNameHeader": "Display Name",
|
||||
"usernameHeader": "Username",
|
||||
"emailHeader": "Email",
|
||||
"adminHeader": "Admin?",
|
||||
"authoptionsHeader": "Auth Options",
|
||||
"srEditLabel": "Edit",
|
||||
"adminUserLabel": "Admin user",
|
||||
"normalUserLabel": "Normal user",
|
||||
|
||||
"delete": "Delete",
|
||||
"deleteUser": "Delete user {0}",
|
||||
|
||||
"authentication": {
|
||||
"title": "Authentication",
|
||||
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
|
||||
"enabledKey": "Enabled?",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"srOpenOptions": "Open options",
|
||||
"configure": "Configure",
|
||||
"simple": "Simple (username/password)",
|
||||
"oidc": "OpenID Connect"
|
||||
},
|
||||
"simple": {
|
||||
"title": "Simple authentication",
|
||||
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
|
||||
"invitationTitle": "invitations",
|
||||
"createInvitation": "Create invitation",
|
||||
"noUsernameEnforced": "No username enforced.",
|
||||
"noEmailEnforced": "No email enforced.",
|
||||
"adminInvitation": "Admin invitation",
|
||||
"userInvitation": "User invitation",
|
||||
"expires": "Expires: {expiry}",
|
||||
"neverExpires": "Never expires.",
|
||||
"noInvitations": "No invitations.",
|
||||
"inviteTitle": "Invite user to Drop",
|
||||
"inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.",
|
||||
"inviteUsernameLabel": "Username (optional)",
|
||||
"inviteUsernameFormat": "Must be 5 or more characters",
|
||||
"inviteUsernamePlaceholder": "myUsername",
|
||||
"inviteEmailLabel": "Email address (optional)",
|
||||
"inviteEmailDescription": "Must be in the format user{'@'}example.com",
|
||||
"inviteEmailPlaceholder": "me{'@'}example.com",
|
||||
"inviteAdminSwitchLabel": "Admin invitation",
|
||||
"inviteAdminSwitchDescription": "Create this user as an administrator",
|
||||
"inviteExpiryLabel": "Expires",
|
||||
"inviteButton": "Invite",
|
||||
"invite3Days": "3 days",
|
||||
"inviteWeek": "1 week",
|
||||
"inviteMonth": "1 month",
|
||||
"invite6Months": "6 months",
|
||||
"inviteYear": "1 year",
|
||||
"inviteNever": "Never"
|
||||
}
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"addGames": "All Games",
|
||||
"addToLib": "Add to Library",
|
||||
"admin": {
|
||||
"detectedGame": "Drop has detected you have new games to import.",
|
||||
"detectedVersion": "Drop has detected you have new verions of this game to import.",
|
||||
"offlineTitle": "Game offline",
|
||||
"offline": "Drop couldn't access this game.",
|
||||
"game": {
|
||||
"addCarouselNoImages": "No images to add.",
|
||||
"addDescriptionNoImages": "No images to add.",
|
||||
"addImageCarousel": "Add from image library",
|
||||
"currentBanner": "banner",
|
||||
"currentCover": "cover",
|
||||
"deleteImage": "Delete image",
|
||||
"editGameDescription": "Game Description",
|
||||
"editGameName": "Game Name",
|
||||
"imageCarousel": "Image Carousel",
|
||||
"imageCarouselDescription": "Customise what images and what order are shown on the store page.",
|
||||
"imageCarouselEmpty": "No images added to the carousel yet.",
|
||||
"imageLibrary": "Image library",
|
||||
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
|
||||
"removeImageCarousel": "Remove image",
|
||||
"setBanner": "Set as banner",
|
||||
"setCover": "Set as cover"
|
||||
},
|
||||
"gameLibrary": "Game Library",
|
||||
"import": {
|
||||
"import": "Import",
|
||||
@ -313,8 +316,6 @@
|
||||
"selectGamePlaceholder": "Please select a game...",
|
||||
"selectGameSearch": "Select game",
|
||||
"selectPlatform": "Please select a platform...",
|
||||
"bulkImportTitle": "Bulk import mode",
|
||||
"bulkImportDescription": "When on, this page won't redirect you to the import task, so you can import multiple games in succession.",
|
||||
"version": {
|
||||
"advancedOptions": "Advanced options",
|
||||
"import": "Import version",
|
||||
@ -345,13 +346,34 @@
|
||||
"openEditor": "Open in Editor {arrow}",
|
||||
"openStore": "Open in Store",
|
||||
"shortDesc": "Short Description",
|
||||
"version": {
|
||||
"noVersions": "You have no versions of this game available.",
|
||||
"noVersionsAdded": "no versions added",
|
||||
"delta": "Upgrade mode"
|
||||
},
|
||||
"game": {
|
||||
"imageCarousel": "Image Carousel",
|
||||
"imageCarouselDescription": "Customise what images and what order are shown on the store page.",
|
||||
"addImageCarousel": "Add from image library",
|
||||
"imageCarouselEmpty": "No images added to the carousel yet.",
|
||||
"removeImageCarousel": "Remove image",
|
||||
"addCarouselNoImages": "No images to add.",
|
||||
"imageLibrary": "Image library",
|
||||
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
|
||||
"setBanner": "Set as banner",
|
||||
"setCover": "Set as cover",
|
||||
"deleteImage": "Delete image",
|
||||
"currentBanner": "banner",
|
||||
"currentCover": "cover",
|
||||
"addDescriptionNoImages": "No images to add.",
|
||||
"editGameName": "Game Name",
|
||||
"editGameDescription": "Game Description"
|
||||
},
|
||||
"sources": {
|
||||
"create": "Create source",
|
||||
"edit": "Edit source",
|
||||
"createDesc": "Drop will use this source to access your game library, and make them available.",
|
||||
"desc": "Configure your library sources, where Drop will look for new games and versions to import.",
|
||||
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
|
||||
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
|
||||
"fsPath": "Path",
|
||||
"fsPathDesc": "An absolute path to your game library.",
|
||||
"fsPathPlaceholder": "/mnt/games",
|
||||
@ -364,57 +386,14 @@
|
||||
},
|
||||
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
|
||||
"title": "Libraries",
|
||||
"version": {
|
||||
"delta": "Upgrade mode",
|
||||
"noVersions": "You have no versions of this game available.",
|
||||
"noVersionsAdded": "no versions added"
|
||||
"versionPriority": {
|
||||
"title": "Version priority",
|
||||
"description": "Version priority is used to order games for upgrade mode versions."
|
||||
},
|
||||
"versionPriority": "Version priority",
|
||||
"metadata": {
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
"description": "Tags are automatically created from imported genres. You can add custom tags to add categorisation to your game library.",
|
||||
"action": "Manage {arrow}",
|
||||
"create": "Create",
|
||||
"modal": {
|
||||
"title": "Create Tag",
|
||||
"description": "Create a tag to organize your library."
|
||||
}
|
||||
},
|
||||
"companies": {
|
||||
"title": "Companies",
|
||||
"description": "Companies organize games by who they were developed or published by.",
|
||||
"action": "Manage {arrow}",
|
||||
"search": "Search companies...",
|
||||
"searchGames": "Search company games...",
|
||||
"noCompanies": "No companies",
|
||||
"noGames": "No games",
|
||||
"editor": {
|
||||
"libraryTitle": "Game Library",
|
||||
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
|
||||
"action": "Add Game {plus}",
|
||||
"published": "Published",
|
||||
"developed": "Developed",
|
||||
"uploadIcon": "Upload icon",
|
||||
"uploadBanner": "Upload banner",
|
||||
"noDescription": "(no description)"
|
||||
},
|
||||
"addGame": {
|
||||
"title": "Connect game to this company",
|
||||
"description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.",
|
||||
"publisher": "Publisher?",
|
||||
"developer": "Developer?",
|
||||
"noGames": "No games to add"
|
||||
},
|
||||
"modals": {
|
||||
"nameTitle": "Edit company name",
|
||||
"nameDescription": "Edit the company's name. Used to match to new game imports.",
|
||||
"shortDeckTitle": "Edit company description",
|
||||
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
|
||||
"websiteTitle": "Edit company website",
|
||||
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection."
|
||||
}
|
||||
}
|
||||
"cloudSaves": {
|
||||
"title": "Cloud Saves",
|
||||
"description": "Specify the Ludusavi manifest used to back up this game to the cloud.",
|
||||
"search": "Search"
|
||||
}
|
||||
},
|
||||
"back": "Back to Library",
|
||||
@ -438,7 +417,32 @@
|
||||
"search": "Search library...",
|
||||
"subheader": "Organize your games into collections for easy access, and access all your games."
|
||||
},
|
||||
"tasks": {
|
||||
"admin": {
|
||||
"scheduled": {
|
||||
"cleanupInvitationsName": "Clean up invitations",
|
||||
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
|
||||
"cleanupObjectsName": "Clean up objects",
|
||||
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
|
||||
"cleanupSessionsName": "Clean up sessions.",
|
||||
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
|
||||
"checkUpdateName": "Check update.",
|
||||
"checkUpdateDescription": "Check if Drop has an update.",
|
||||
|
||||
"ludusaviImportName": "Imports the latest Ludusavi manifest.",
|
||||
"ludusaviImportDescription": "Downloads and imports the latest Ludusavi manifest, to improve cloud save compatibility with new games and updates."
|
||||
},
|
||||
"runningTasksTitle": "Running tasks",
|
||||
"noTasksRunning": "No tasks currently running",
|
||||
"completedTasksTitle": "Completed tasks",
|
||||
"dailyScheduledTitle": "Daily scheduled tasks",
|
||||
"weeklyScheduledTitle": "Weekly scheduled tasks",
|
||||
"viewTask": "View {arrow}",
|
||||
"back": "{arrow} Back to Tasks"
|
||||
}
|
||||
},
|
||||
"lowest": "lowest",
|
||||
"name": "Name",
|
||||
"news": {
|
||||
"article": {
|
||||
"add": "Add",
|
||||
@ -471,37 +475,20 @@
|
||||
"title": "Latest News"
|
||||
},
|
||||
"options": "Options",
|
||||
"save": "Save",
|
||||
"add": "Add",
|
||||
"insert": "Insert",
|
||||
"security": "Security",
|
||||
"selectLanguage": "Select language",
|
||||
"settings": {
|
||||
"admin": {
|
||||
"description": "Configure Drop settings",
|
||||
"store": {
|
||||
"dropGameAltPlaceholder": "Example Game icon",
|
||||
"dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.",
|
||||
"dropGameNamePlaceholder": "Example Game",
|
||||
"showGamePanelTextDecoration": "Show title and description on game tiles (default: on)",
|
||||
"title": "Store"
|
||||
},
|
||||
"title": "Settings"
|
||||
}
|
||||
},
|
||||
"settings": "Settings",
|
||||
"store": {
|
||||
"about": "About",
|
||||
"commingSoon": "coming soon",
|
||||
"developers": "Developers | Developer | Developers",
|
||||
"exploreMore": "Explore more {arrow}",
|
||||
"featured": "Featured",
|
||||
"images": "Game Images",
|
||||
"lookAt": "Check it out",
|
||||
"noDevelopers": "No developers",
|
||||
"noGame": "no game",
|
||||
"noImages": "No images",
|
||||
"noPublishers": "No publishers.",
|
||||
"noTags": "No tags",
|
||||
"openAdminDashboard": "Open in Admin Dashboard",
|
||||
"platform": "Platform | Platform | Platforms",
|
||||
"publishers": "Publishers | Publisher | Publishers",
|
||||
"rating": "Rating",
|
||||
"readLess": "Click to read less",
|
||||
"readMore": "Click to read more",
|
||||
@ -510,41 +497,9 @@
|
||||
"recentlyUpdated": "Recently Updated",
|
||||
"released": "Released",
|
||||
"reviews": "({0} Reviews)",
|
||||
"tags": "Tags",
|
||||
"title": "Store",
|
||||
"view": {
|
||||
"sort": "Sort",
|
||||
"srFilters": "Filters",
|
||||
"srGames": "Games",
|
||||
"srViewGrid": "View grid"
|
||||
},
|
||||
"viewInStore": "View in Store",
|
||||
"website": "Website"
|
||||
"view": "View in Store"
|
||||
},
|
||||
"tasks": {
|
||||
"admin": {
|
||||
"back": "{arrow} Back to Tasks",
|
||||
"completedTasksTitle": "Completed tasks",
|
||||
"dailyScheduledTitle": "Daily scheduled tasks",
|
||||
"noTasksRunning": "No tasks currently running",
|
||||
"runningTasksTitle": "Running tasks",
|
||||
"scheduled": {
|
||||
"checkUpdateDescription": "Check if Drop has an update.",
|
||||
"checkUpdateName": "Check update.",
|
||||
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
|
||||
"cleanupInvitationsName": "Clean up invitations",
|
||||
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
|
||||
"cleanupObjectsName": "Clean up objects",
|
||||
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
|
||||
"cleanupSessionsName": "Clean up sessions."
|
||||
},
|
||||
"viewTask": "View {arrow}",
|
||||
"weeklyScheduledTitle": "Weekly scheduled tasks"
|
||||
}
|
||||
},
|
||||
"title": "Drop",
|
||||
"titleTemplate": "{0} - Drop",
|
||||
"todo": "Todo",
|
||||
"type": "Type",
|
||||
"upload": "Upload",
|
||||
"uploadFile": "Upload file",
|
||||
@ -560,63 +515,8 @@
|
||||
"settings": "Account settings"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"admin": {
|
||||
"adminHeader": "Admin?",
|
||||
"adminUserLabel": "Admin user",
|
||||
"authentication": {
|
||||
"configure": "Configure",
|
||||
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
|
||||
"disabled": "Disabled",
|
||||
"enabled": "Enabled",
|
||||
"enabledKey": "Enabled?",
|
||||
"oidc": "OpenID Connect",
|
||||
"simple": "Simple (username/password)",
|
||||
"srOpenOptions": "Open options",
|
||||
"title": "Authentication"
|
||||
},
|
||||
"authLink": "Authentication {arrow}",
|
||||
"authoptionsHeader": "Auth Options",
|
||||
"delete": "Delete",
|
||||
"deleteUser": "Delete user {0}",
|
||||
"description": "Manage the users on your Drop instance, and configure your authentication methods.",
|
||||
"displayNameHeader": "Display Name",
|
||||
"emailHeader": "Email",
|
||||
"normalUserLabel": "Normal user",
|
||||
"simple": {
|
||||
"adminInvitation": "Admin invitation",
|
||||
"createInvitation": "Create invitation",
|
||||
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
|
||||
"expires": "Expires: {expiry}",
|
||||
"invitationTitle": "invitations",
|
||||
"invite3Days": "3 days",
|
||||
"invite6Months": "6 months",
|
||||
"inviteAdminSwitchDescription": "Create this user as an administrator",
|
||||
"inviteAdminSwitchLabel": "Admin invitation",
|
||||
"inviteButton": "Invite",
|
||||
"inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.",
|
||||
"inviteEmailDescription": "Must be in the format user{'@'}example.com",
|
||||
"inviteEmailLabel": "Email address (optional)",
|
||||
"inviteEmailPlaceholder": "me{'@'}example.com",
|
||||
"inviteExpiryLabel": "Expires",
|
||||
"inviteMonth": "1 month",
|
||||
"inviteNever": "Never",
|
||||
"inviteTitle": "Invite user to Drop",
|
||||
"inviteUsernameFormat": "Must be 5 or more characters",
|
||||
"inviteUsernameLabel": "Username (optional)",
|
||||
"inviteUsernamePlaceholder": "myUsername",
|
||||
"inviteWeek": "1 week",
|
||||
"inviteYear": "1 year",
|
||||
"neverExpires": "Never expires.",
|
||||
"noEmailEnforced": "No email enforced.",
|
||||
"noInvitations": "No invitations.",
|
||||
"noUsernameEnforced": "No username enforced.",
|
||||
"title": "Simple authentication",
|
||||
"userInvitation": "User invitation"
|
||||
},
|
||||
"srEditLabel": "Edit",
|
||||
"usernameHeader": "Username"
|
||||
}
|
||||
},
|
||||
"todo": "Todo",
|
||||
"selectLanguage": "Select language",
|
||||
"helpUsTranslate": "Help us translate Drop {arrow}",
|
||||
"welcome": "American, Welcome!"
|
||||
}
|
||||
|
||||
@ -164,7 +164,6 @@ import {
|
||||
Cog6ToothIcon,
|
||||
UserGroupIcon,
|
||||
RectangleStackIcon,
|
||||
DocumentIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { NavigationItem } from "~/composables/types";
|
||||
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
|
||||
@ -181,12 +180,6 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
prefix: "/admin/library",
|
||||
icon: ServerStackIcon,
|
||||
},
|
||||
{
|
||||
label: $t("header.admin.metadata"),
|
||||
route: "/admin/metadata",
|
||||
prefix: "/admin/metadata",
|
||||
icon: DocumentIcon,
|
||||
},
|
||||
{
|
||||
label: $t("header.admin.users"),
|
||||
route: "/admin/users",
|
||||
@ -200,7 +193,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
icon: RectangleStackIcon,
|
||||
},
|
||||
{
|
||||
label: $t("header.admin.settings"),
|
||||
label: $t("settings"),
|
||||
route: "/admin/settings",
|
||||
prefix: "/admin/settings",
|
||||
icon: Cog6ToothIcon,
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { execSync } from "node:child_process";
|
||||
import { cpSync, readFileSync, existsSync } from "node:fs";
|
||||
import { cpSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import module from "module";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import { type } from "arktype";
|
||||
|
||||
const packageJsonSchema = type({
|
||||
name: "string",
|
||||
version: "string",
|
||||
});
|
||||
// get drop version
|
||||
const dropVersion = process.env.BUILD_DROP_VERSION ?? "v0.3.0-alpha.1";
|
||||
// example nightly: "v0.3.0-nightly.2025.05.28"
|
||||
|
||||
// get git ref or supply during build
|
||||
const commitHash =
|
||||
process.env.BUILD_GIT_REF ??
|
||||
execSync("git rev-parse --short HEAD").toString().trim();
|
||||
|
||||
console.log(`Building Drop ${dropVersion} #${commitHash}`);
|
||||
|
||||
const twemojiJson = module.findPackageJSON(
|
||||
"@discordapp/twemoji",
|
||||
@ -19,16 +24,6 @@ if (!twemojiJson) {
|
||||
throw new Error("Could not find @discordapp/twemoji package.");
|
||||
}
|
||||
|
||||
// get drop version
|
||||
const dropVersion = getDropVersion();
|
||||
|
||||
// get git ref or supply during build
|
||||
const commitHash =
|
||||
process.env.BUILD_GIT_REF ??
|
||||
execSync("git rev-parse --short HEAD").toString().trim();
|
||||
|
||||
console.log(`Drop ${dropVersion} #${commitHash}`);
|
||||
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
extends: ["./drop-base"],
|
||||
@ -36,11 +31,11 @@ export default defineNuxtConfig({
|
||||
|
||||
modules: [
|
||||
"vue3-carousel-nuxt",
|
||||
"nuxt-security", // "@nuxt/image",
|
||||
"nuxt-security",
|
||||
// "@nuxt/image",
|
||||
"@nuxt/fonts",
|
||||
"@nuxt/eslint",
|
||||
"@nuxtjs/i18n",
|
||||
"@vueuse/nuxt",
|
||||
],
|
||||
|
||||
// Nuxt-only config
|
||||
@ -262,41 +257,3 @@ export default defineNuxtConfig({
|
||||
requestSizeLimiter: false,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets the drop version from the environment variable or package.json
|
||||
* @returns {string} The drop version
|
||||
*/
|
||||
function getDropVersion(): string {
|
||||
// get drop version from environment variable
|
||||
if (process.env.BUILD_DROP_VERSION) {
|
||||
return process.env.BUILD_DROP_VERSION;
|
||||
}
|
||||
// example nightly: "v0.3.0-nightly.2025.05.28"
|
||||
const defaultVersion = "v0.0.0-alpha.0";
|
||||
|
||||
// get path
|
||||
const packageJsonPath = path.join(
|
||||
path.dirname(import.meta.url.replace("file://", "")),
|
||||
"package.json",
|
||||
);
|
||||
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
console.error("Could not find package.json, using default version.");
|
||||
return defaultVersion;
|
||||
}
|
||||
|
||||
// parse package.json
|
||||
const raw = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
const packageJson = packageJsonSchema(raw);
|
||||
if (packageJson instanceof type.errors) {
|
||||
console.error("Failed to parse package.json", packageJson.summary);
|
||||
return defaultVersion;
|
||||
}
|
||||
|
||||
// ensure version starts with 'v'
|
||||
if (packageJson.version.startsWith("v")) {
|
||||
return packageJson.version;
|
||||
}
|
||||
return `v${packageJson.version}`;
|
||||
}
|
||||
|
||||
25
package.json
25
package.json
@ -17,18 +17,17 @@
|
||||
"lint:fix": "eslint . --fix && prettier --write --list-different ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordapp/twemoji": "^16.0.1",
|
||||
"@drop-oss/droplet": "1.6.0",
|
||||
"@discordapp/twemoji": "^15.1.0",
|
||||
"@drop-oss/droplet": "^1.3.1",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@lobomfz/prismark": "0.0.3",
|
||||
"@nuxt/fonts": "^0.11.0",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxtjs/i18n": "^9.5.5",
|
||||
"@prisma/client": "^6.11.1",
|
||||
"@prisma/client": "^6.7.0",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@vueuse/nuxt": "13.6.0",
|
||||
"argon2": "^0.43.0",
|
||||
"argon2": "^0.41.1",
|
||||
"arktype": "^2.1.10",
|
||||
"axios": "^1.7.7",
|
||||
"bcryptjs": "^3.0.2",
|
||||
@ -37,14 +36,13 @@
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"file-type-mime": "^0.4.3",
|
||||
"jdenticon": "^3.3.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"luxon": "^3.6.1",
|
||||
"micromark": "^4.0.1",
|
||||
"normalize-url": "^8.0.2",
|
||||
"nuxt": "^3.17.4",
|
||||
"nuxt-security": "2.2.0",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"prisma": "^6.11.1",
|
||||
"prisma": "^6.7.0",
|
||||
"prisma-extension-pg-trgm": "^1.1.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"semver": "^7.7.1",
|
||||
"stream-mime-type": "^2.0.0",
|
||||
@ -53,7 +51,7 @@
|
||||
"vite-plugin-static-copy": "^3.0.0",
|
||||
"vue": "latest",
|
||||
"vue-router": "latest",
|
||||
"vue3-carousel": "^0.16.0",
|
||||
"vue3-carousel": "^0.15.0",
|
||||
"vue3-carousel-nuxt": "^1.1.5",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
@ -63,6 +61,7 @@
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/node": "^22.13.16",
|
||||
"@types/semver": "^7.7.0",
|
||||
@ -73,15 +72,17 @@
|
||||
"h3": "^1.15.3",
|
||||
"nitropack": "^2.11.12",
|
||||
"ofetch": "^1.4.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.5.3",
|
||||
"sass": "^1.79.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vue-tsc": "^3.0.1"
|
||||
"vue-tsc": "^2.2.8"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
||||
"overrides": {
|
||||
"vue3-carousel-nuxt": {
|
||||
"vue3-carousel": "^0.16.0"
|
||||
"vue3-carousel": "^0.15.0"
|
||||
}
|
||||
},
|
||||
"prisma": {
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
|
||||
>
|
||||
{{ $t("common.name") }}
|
||||
{{ $t("name") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
|
||||
@ -40,16 +40,14 @@
|
||||
{{ notification.description }}
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<NuxtLink
|
||||
v-for="[name, href] in notification.actions.map((v) =>
|
||||
v.split('|'),
|
||||
)"
|
||||
:key="href"
|
||||
:href="href"
|
||||
<a
|
||||
v-for="action in notification.actions"
|
||||
:key="action"
|
||||
:href="action.split('|')[1]"
|
||||
class="inline-flex items-center rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20 transition-all duration-200 hover:bg-blue-400/20 hover:scale-105 active:scale-95"
|
||||
>
|
||||
{{ name }}
|
||||
</NuxtLink>
|
||||
{{ action.split("|")[0] }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2">
|
||||
@ -59,7 +57,7 @@
|
||||
<button
|
||||
v-if="!notification.read"
|
||||
type="button"
|
||||
class="inline-flex gap-x-1 items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20 transition-all duration-200 hover:bg-zinc-400/20 hover:scale-105 active:scale-95"
|
||||
class="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20 transition-all duration-200 hover:bg-zinc-400/20 hover:scale-105 active:scale-95"
|
||||
@click="markAsRead(notification.id)"
|
||||
>
|
||||
<CheckIcon class="size-3" />
|
||||
@ -67,7 +65,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex gap-x-1 items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||
@click="deleteNotification(notification.id)"
|
||||
>
|
||||
<TrashIcon class="size-3" />
|
||||
@ -92,7 +90,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
||||
import type { NotificationModel } from "~/prisma/client/models";
|
||||
import type { Notification } from "~/prisma/client";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
definePageMeta({
|
||||
@ -106,7 +104,7 @@ useHead({
|
||||
});
|
||||
|
||||
// Fetch notifications
|
||||
const notifications = ref<SerializeObject<NotificationModel>[]>([]);
|
||||
const notifications = ref<SerializeObject<Notification>[]>([]);
|
||||
|
||||
async function fetchNotifications() {
|
||||
const { data } = await useFetch("/api/v1/notifications");
|
||||
@ -120,7 +118,7 @@ await fetchNotifications();
|
||||
async function markAsRead(id: string) {
|
||||
await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" });
|
||||
const notification = notifications.value.find(
|
||||
(n: SerializeObject<NotificationModel>) => n.id === id,
|
||||
(n: SerializeObject<Notification>) => n.id === id,
|
||||
);
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
@ -130,18 +128,16 @@ async function markAsRead(id: string) {
|
||||
// Mark all notifications as read
|
||||
async function markAllAsRead() {
|
||||
await $dropFetch("/api/v1/notifications/readall", { method: "POST" });
|
||||
notifications.value.forEach(
|
||||
(notification: SerializeObject<NotificationModel>) => {
|
||||
notification.read = true;
|
||||
},
|
||||
);
|
||||
notifications.value.forEach((notification: SerializeObject<Notification>) => {
|
||||
notification.read = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Delete a notification
|
||||
async function deleteNotification(id: string) {
|
||||
await $dropFetch(`/api/v1/notifications/${id}`, { method: "DELETE" });
|
||||
const index = notifications.value.findIndex(
|
||||
(n: SerializeObject<NotificationModel>) => n.id === id,
|
||||
(n: SerializeObject<Notification>) => n.id === id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1);
|
||||
|
||||
@ -7,67 +7,6 @@
|
||||
>
|
||||
<!--start-->
|
||||
<div>
|
||||
<Listbox v-if="false" v-model="currentMode" as="div">
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||
>
|
||||
<span class="col-start-1 row-start-1 truncate">{{
|
||||
currentMode
|
||||
}}</span>
|
||||
|
||||
<PencilIcon class="ml-auto size-5" />
|
||||
|
||||
<ChevronUpDownIcon
|
||||
class="text-gray-500 size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[value] in Object.entries(components)"
|
||||
v-slot="{ active, selected }"
|
||||
:key="value"
|
||||
as="template"
|
||||
:value="value"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-hidden'
|
||||
: 'text-zinc-100',
|
||||
'relative cursor-default py-2 pr-9 pl-3 select-none',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ value }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
class="text-white absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
>
|
||||
<PencilIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<div class="pt-4 inline-flex gap-x-2">
|
||||
<div
|
||||
v-for="[value, { icon }] in Object.entries(components)"
|
||||
@ -112,27 +51,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
|
||||
import { GameEditorMetadata, GameEditorVersion } from "#components";
|
||||
import { GameEditorConfiguration, GameEditorMetadata } from "#components";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
DocumentIcon,
|
||||
PencilIcon,
|
||||
ServerStackIcon,
|
||||
WrenchIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { Component } from "vue";
|
||||
|
||||
const route = useRoute();
|
||||
const gameId = route.params.id.toString();
|
||||
const { game: rawGame, unimportedVersions } = await $dropFetch(
|
||||
`/api/v1/admin/game/:id`,
|
||||
{ params: { id: gameId } },
|
||||
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`,
|
||||
);
|
||||
const game = ref(rawGame);
|
||||
|
||||
@ -140,31 +70,25 @@ definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
// To do a title with the game name in it, we need some sort of watch
|
||||
title: "Game Editor",
|
||||
});
|
||||
|
||||
enum GameEditorMode {
|
||||
Metadata = "Metadata",
|
||||
Versions = "Versions",
|
||||
Configuration = "Configuration",
|
||||
}
|
||||
|
||||
const components: {
|
||||
[key in GameEditorMode]: { editor: Component; icon: Component };
|
||||
} = {
|
||||
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
|
||||
[GameEditorMode.Versions]: {
|
||||
editor: GameEditorVersion,
|
||||
icon: ServerStackIcon,
|
||||
[GameEditorMode.Configuration]: {
|
||||
editor: GameEditorConfiguration,
|
||||
icon: WrenchIcon,
|
||||
},
|
||||
};
|
||||
|
||||
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
|
||||
|
||||
useHead({
|
||||
// To do a title with the game name in it, we need some sort of watch
|
||||
title: `${currentMode.value} - ${game.value.mName}`,
|
||||
});
|
||||
|
||||
watch(currentMode, (v) => {
|
||||
useHead({
|
||||
title: `${v} - ${game.value.mName}`,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -12,15 +12,9 @@
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<span v-if="currentlySelectedGame != -1" class="block truncate"
|
||||
>{{ games.unimportedGames[currentlySelectedGame].game }}
|
||||
<span
|
||||
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
|
||||
>{{
|
||||
games.unimportedGames[currentlySelectedGame].library.name
|
||||
}}</span
|
||||
></span
|
||||
>
|
||||
<span v-if="currentlySelectedGame != -1" class="block truncate">{{
|
||||
games.unimportedGames[currentlySelectedGame].game
|
||||
}}</span>
|
||||
<span v-else class="block truncate text-zinc-400">{{
|
||||
$t("library.admin.import.selectDir")
|
||||
}}</span>
|
||||
@ -43,9 +37,9 @@
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="({ game, library }, gameIdx) in games.unimportedGames"
|
||||
v-for="({ game }, gameIdx) in games.unimportedGames"
|
||||
:key="game"
|
||||
v-slot="{ active }"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="gameIdx"
|
||||
>
|
||||
@ -57,20 +51,14 @@
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
gameIdx === currentlySelectedGame
|
||||
? 'font-semibold'
|
||||
: 'font-normal',
|
||||
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
|
||||
selected ? 'font-semibold' : 'font-normal',
|
||||
'block truncate',
|
||||
]"
|
||||
>{{ game }}
|
||||
<span
|
||||
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
|
||||
>{{ library.name }}</span
|
||||
></span
|
||||
>{{ game }}</span
|
||||
>
|
||||
|
||||
<span
|
||||
v-if="gameIdx === currentlySelectedGame"
|
||||
v-if="selected"
|
||||
:class="[
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
@ -84,34 +72,6 @@
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
<div class="flex items-center justify-between gap-x-8">
|
||||
<span class="flex grow flex-col">
|
||||
<label
|
||||
id="bulkImport-label"
|
||||
class="text-sm/6 font-medium text-zinc-100"
|
||||
>{{ $t("library.admin.import.bulkImportTitle") }}</label
|
||||
>
|
||||
<span id="bulkImport-description" class="text-sm text-zinc-400">{{
|
||||
$t("library.admin.import.bulkImportDescription")
|
||||
}}</span>
|
||||
</span>
|
||||
<div
|
||||
class="group relative inline-flex w-11 shrink-0 rounded-full bg-zinc-800 p-0.5 inset-ring inset-ring-zinc-100/5 outline-offset-2 outline-blue-600 transition-colors duration-200 ease-in-out has-checked:bg-blue-600 has-focus-visible:outline-2"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-zinc-100/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5"
|
||||
/>
|
||||
<input
|
||||
id="bulkImport"
|
||||
v-model="bulkImportMode"
|
||||
type="checkbox"
|
||||
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
|
||||
name="bulkImport"
|
||||
aria-labelledby="bulkImport-label"
|
||||
aria-describedby="bulkImport-description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4">
|
||||
<!-- without metadata option -->
|
||||
@ -317,20 +277,18 @@ definePageMeta({
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const rawGames = await $dropFetch("/api/v1/admin/import/game");
|
||||
const games = ref(rawGames);
|
||||
const games = await $dropFetch("/api/v1/admin/import/game");
|
||||
const currentlySelectedGame = ref(-1);
|
||||
const gameSearchResultsLoading = ref(false);
|
||||
const gameSearchResultsError = ref<string | undefined>();
|
||||
const gameSearchTerm = ref("");
|
||||
const gameSearchLoading = ref(false);
|
||||
const bulkImportMode = ref(false);
|
||||
|
||||
async function updateSelectedGame(value: number) {
|
||||
if (currentlySelectedGame.value == value) return;
|
||||
currentlySelectedGame.value = value;
|
||||
if (currentlySelectedGame.value == -1) return;
|
||||
const option = games.value.unimportedGames[currentlySelectedGame.value];
|
||||
const option = games.unimportedGames[currentlySelectedGame.value];
|
||||
if (!option) return;
|
||||
|
||||
metadataResults.value = undefined;
|
||||
@ -341,19 +299,12 @@ async function updateSelectedGame(value: number) {
|
||||
}
|
||||
|
||||
async function searchGame() {
|
||||
gameSearchResultsError.value = undefined;
|
||||
gameSearchLoading.value = true;
|
||||
try {
|
||||
const results = await $dropFetch(
|
||||
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
|
||||
);
|
||||
metadataResults.value = results;
|
||||
gameSearchLoading.value = false;
|
||||
} catch (e) {
|
||||
gameSearchLoading.value = false;
|
||||
|
||||
throw e;
|
||||
}
|
||||
const results = await $dropFetch(
|
||||
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
|
||||
);
|
||||
metadataResults.value = results;
|
||||
gameSearchLoading.value = false;
|
||||
}
|
||||
|
||||
function updateSelectedGame_wrapper(value: number) {
|
||||
@ -381,24 +332,18 @@ async function importGame(useMetadata: boolean) {
|
||||
useMetadata && metadataResults.value
|
||||
? metadataResults.value[currentlySelectedMetadata.value]
|
||||
: undefined;
|
||||
const option = games.value.unimportedGames[currentlySelectedGame.value];
|
||||
const option = games.unimportedGames[currentlySelectedGame.value];
|
||||
|
||||
const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
|
||||
method: "POST",
|
||||
body: {
|
||||
path: option.game,
|
||||
library: option.library.id,
|
||||
library: option.library,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bulkImportMode.value) {
|
||||
router.push(`/admin/task/${taskId}`);
|
||||
} else {
|
||||
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
|
||||
currentlySelectedGame.value = -1;
|
||||
gameSearchResultsError.value = undefined;
|
||||
}
|
||||
router.push(`/admin/task/${taskId}`);
|
||||
}
|
||||
function importGame_wrapper(metadata = true) {
|
||||
importLoading.value = true;
|
||||
|
||||
@ -78,55 +78,20 @@
|
||||
<li
|
||||
v-for="game in filteredLibraryGames"
|
||||
:key="game.id"
|
||||
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
|
||||
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border border-zinc-800 transition-all duration-200 hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 group"
|
||||
>
|
||||
<div class="flex flex-1 flex-row p-4 gap-x-4">
|
||||
<img
|
||||
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
|
||||
class="h-20 w-20 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
|
||||
:src="useObject(game.mIconObjectId)"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<h3
|
||||
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-zinc-100 font-display">
|
||||
{{ game.mName }}
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
game.featured
|
||||
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
|
||||
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
|
||||
]"
|
||||
@click="() => featureGame(game.id)"
|
||||
>
|
||||
<svg
|
||||
v-if="gameFeatureLoading[game.id]"
|
||||
aria-hidden="true"
|
||||
:class="[
|
||||
game.featured ? ' fill-zinc-900' : 'fill-zinc-100',
|
||||
'size-3 text-transparent animate-spin',
|
||||
]"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<StarIcon v-else class="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
|
||||
>{{ game.library!.name }}</span
|
||||
class="ml-2 inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
|
||||
>{{ game.metadataSource }}</span
|
||||
>
|
||||
</h3>
|
||||
<dl class="mt-1 flex flex-col justify-between">
|
||||
@ -215,24 +180,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="game.notifications.offline"
|
||||
class="rounded-md bg-red-600/10 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<ExclamationCircleIcon
|
||||
class="h-5 w-5 text-red-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-600">
|
||||
{{ $t("library.admin.offline") }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<p
|
||||
@ -252,11 +199,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/vue/16/solid";
|
||||
import { InformationCircleIcon, StarIcon } from "@heroicons/vue/20/solid";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid";
|
||||
import { InformationCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const { t } = useI18n();
|
||||
@ -272,37 +216,15 @@ useHead({
|
||||
const searchQuery = ref("");
|
||||
|
||||
const libraryState = await $dropFetch("/api/v1/admin/library");
|
||||
type LibraryStateGame = (typeof libraryState.games)[number]["game"];
|
||||
|
||||
console.log(Object.values(libraryState.unimportedGames));
|
||||
|
||||
const toImport = ref(
|
||||
Object.values(libraryState.unimportedGames).flat().length > 0,
|
||||
);
|
||||
|
||||
const libraryGames = ref<
|
||||
Array<
|
||||
LibraryStateGame & {
|
||||
status: "online" | "offline";
|
||||
hasNotifications?: boolean;
|
||||
notifications: {
|
||||
noVersions?: boolean;
|
||||
toImport?: boolean;
|
||||
offline?: boolean;
|
||||
};
|
||||
}
|
||||
>
|
||||
>(
|
||||
const libraryGames = ref(
|
||||
libraryState.games.map((e) => {
|
||||
if (e.status == "offline") {
|
||||
return {
|
||||
...e.game,
|
||||
status: "offline" as const,
|
||||
hasNotifications: true,
|
||||
notifications: {
|
||||
offline: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const noVersions = e.status.noVersions;
|
||||
const toImport = e.status.unimportedVersions.length > 0;
|
||||
|
||||
@ -313,7 +235,6 @@ const libraryGames = ref<
|
||||
toImport,
|
||||
},
|
||||
hasNotifications: noVersions || toImport,
|
||||
status: "online" as const,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@ -332,31 +253,9 @@ const filteredLibraryGames = computed(() =>
|
||||
);
|
||||
|
||||
async function deleteGame(id: string) {
|
||||
await $dropFetch(`/api/v1/admin/game/${id}`, {
|
||||
method: "DELETE",
|
||||
failTitle: "Failed to delete game",
|
||||
});
|
||||
await $dropFetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" });
|
||||
const index = libraryGames.value.findIndex((e) => e.id === id);
|
||||
libraryGames.value.splice(index, 1);
|
||||
toImport.value = true;
|
||||
}
|
||||
|
||||
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
|
||||
async function featureGame(id: string) {
|
||||
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);
|
||||
const game = libraryGames.value[gameIndex];
|
||||
gameFeatureLoading.value[game.id] = true;
|
||||
|
||||
await $dropFetch(`/api/v1/admin/game/:id`, {
|
||||
method: "PATCH",
|
||||
params: {
|
||||
id: game.id,
|
||||
},
|
||||
body: { featured: !game.featured },
|
||||
failTitle: "Failed to feature/unfeature game",
|
||||
});
|
||||
|
||||
libraryGames.value[gameIndex].featured = !game.featured;
|
||||
gameFeatureLoading.value[game.id] = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (actionSourceOpen = true)"
|
||||
>
|
||||
{{ $t("common.create") }}
|
||||
{{ $t("create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -28,7 +28,7 @@
|
||||
scope="col"
|
||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("common.name") }}
|
||||
{{ $t("name") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
@ -49,7 +49,7 @@
|
||||
{{ $t("options") }}
|
||||
</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
|
||||
<span class="sr-only">{{ $t("common.edit") }}</span>
|
||||
<span class="sr-only">{{ $t("edit") }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -84,7 +84,7 @@
|
||||
class="text-blue-500 hover:text-blue-400"
|
||||
@click="() => edit(sourceIdx)"
|
||||
>
|
||||
{{ $t("common.edit") }}
|
||||
{{ $t("edit") }}
|
||||
<span class="sr-only">
|
||||
{{ $t("chars.srComma", [source.name]) }}
|
||||
</span>
|
||||
@ -110,20 +110,9 @@
|
||||
<ModalTemplate v-model="actionSourceOpen">
|
||||
<template #default>
|
||||
<div>
|
||||
<DialogTitle
|
||||
v-if="createMode"
|
||||
as="h3"
|
||||
class="text-lg font-medium leading-6 text-white"
|
||||
>
|
||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||
{{ $t("library.admin.sources.create") }}
|
||||
</DialogTitle>
|
||||
<DialogTitle
|
||||
v-else
|
||||
as="h3"
|
||||
class="text-lg font-medium leading-6 text-white"
|
||||
>
|
||||
{{ $t("library.admin.sources.edit") }}
|
||||
</DialogTitle>
|
||||
<p class="mt-1 text-zinc-400 text-sm">
|
||||
{{ $t("library.admin.sources.createDesc") }}
|
||||
</p>
|
||||
@ -136,7 +125,7 @@
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("common.name") }}</label
|
||||
>{{ $t("name") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||
{{ $t("library.admin.sources.nameDesc") }}
|
||||
@ -178,7 +167,7 @@
|
||||
'relative block cursor-pointer bg-zinc-800 rounded-lg border border-zinc-900 px-2 py-2 shadow-sm focus:outline-none sm:flex sm:justify-between',
|
||||
]"
|
||||
>
|
||||
<span class="flex items-center gap-x-4">
|
||||
<span class="flex items-center gap-x-2">
|
||||
<div>
|
||||
<component
|
||||
:is="metadata.icon"
|
||||
@ -239,7 +228,7 @@
|
||||
class="w-full sm:w-fit"
|
||||
@click="() => performActionSource_wrapper()"
|
||||
>
|
||||
{{ createMode ? $t("common.create") : $t("common.save") }}
|
||||
{{ createMode ? $t("create") : $t("save") }}
|
||||
</LoadingButton>
|
||||
<button
|
||||
ref="cancelButtonRef"
|
||||
@ -268,10 +257,7 @@
|
||||
* between 'create' and 'edit'
|
||||
*/
|
||||
|
||||
import {
|
||||
SourceOptionsFilesystem,
|
||||
SourceOptionsFlatFilesystem,
|
||||
} from "#components";
|
||||
import { SourceOptionsFilesystem } from "#components";
|
||||
import {
|
||||
DialogTitle,
|
||||
RadioGroup,
|
||||
@ -283,28 +269,17 @@ import { XCircleIcon } from "@heroicons/vue/20/solid";
|
||||
import { CheckIcon, DocumentIcon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { Component } from "vue";
|
||||
import type { LibraryBackend } from "~/prisma/client/enums";
|
||||
import type { LibraryBackend } from "~/prisma/client";
|
||||
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Library Sources",
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Optional token for setup wizard
|
||||
const { token = undefined } = defineProps<{ token?: string }>();
|
||||
|
||||
const headers = token ? { Authorization: token } : undefined;
|
||||
|
||||
const sources = ref(
|
||||
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources", {
|
||||
headers,
|
||||
}),
|
||||
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources"),
|
||||
);
|
||||
|
||||
const editIndex = ref<undefined | number>(undefined);
|
||||
@ -320,7 +295,6 @@ const modalLoading = ref(false);
|
||||
|
||||
const optionUIs: { [key in LibraryBackend]: Component } = {
|
||||
Filesystem: SourceOptionsFilesystem,
|
||||
FlatFilesystem: SourceOptionsFlatFilesystem,
|
||||
};
|
||||
const optionsMetadata: {
|
||||
[key in LibraryBackend]: {
|
||||
@ -332,10 +306,6 @@ const optionsMetadata: {
|
||||
description: t("library.admin.sources.fsDesc"),
|
||||
icon: DocumentIcon,
|
||||
},
|
||||
FlatFilesystem: {
|
||||
description: t("library.admin.sources.fsFlatDesc"),
|
||||
icon: DocumentIcon,
|
||||
},
|
||||
};
|
||||
const optionsMetadataIter = Object.entries(optionsMetadata);
|
||||
|
||||
@ -352,7 +322,6 @@ async function performActionSource() {
|
||||
options: sourceConfig.value,
|
||||
},
|
||||
method: createMode ? "POST" : "PATCH",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (createMode) {
|
||||
@ -402,7 +371,6 @@ async function deleteSource(index: number) {
|
||||
await $dropFetch("/api/v1/admin/library/sources", {
|
||||
method: "DELETE",
|
||||
body: { id: source.id },
|
||||
headers,
|
||||
});
|
||||
} catch (e) {
|
||||
createModal(
|
||||
|
||||
@ -1,419 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-lg flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2 p-8"
|
||||
>
|
||||
<img
|
||||
:src="useObject(company.mBannerObjectId)"
|
||||
class="absolute inset-0 w-full h-full object-cover object-center"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-zinc-900/80" />
|
||||
|
||||
<div class="relative inline-flex items-center gap-4">
|
||||
<!-- icon image -->
|
||||
<div class="relative group/iconupload rounded-xl overflow-hidden">
|
||||
<img :src="useObject(company.mLogoObjectId)" class="size-20" />
|
||||
<button
|
||||
class="rounded-xl transition duration-200 absolute inset-0 opacity-0 group-hover/iconupload:opacity-100 focus-visible/iconupload:opacity-100 cursor-pointer bg-zinc-900/80 text-zinc-100 flex flex-col items-center justify-center text-center text-xs font-semibold ring-1 ring-inset ring-zinc-800 px-2"
|
||||
@click="() => (uploadLogoOpen = true)"
|
||||
>
|
||||
<ArrowUpTrayIcon class="size-5" />
|
||||
<span>{{
|
||||
$t("library.admin.metadata.companies.editor.uploadIcon")
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1
|
||||
class="group/name inline-flex items-center gap-x-3 text-5xl font-bold font-display text-zinc-100"
|
||||
>
|
||||
{{ company.mName }}
|
||||
<button @click="() => editName()">
|
||||
<PencilIcon
|
||||
class="transition duration-200 opacity-0 group-hover/name:opacity-100 size-8"
|
||||
/>
|
||||
</button>
|
||||
</h1>
|
||||
<p
|
||||
class="group/description mt-1 inline-flex items-center gap-x-3 text-lg text-zinc-400 max-w-xl"
|
||||
>
|
||||
{{
|
||||
company.mShortDescription ||
|
||||
$t("library.admin.metadata.companies.editor.noDescription")
|
||||
}}
|
||||
<button @click="() => editShortDescription()">
|
||||
<PencilIcon
|
||||
class="transition duration-200 opacity-0 group-hover/description:opacity-100 size-5"
|
||||
/>
|
||||
</button>
|
||||
</p>
|
||||
<p
|
||||
class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3"
|
||||
>
|
||||
{{ company.mWebsite }}
|
||||
<button @click="() => editWebsite()">
|
||||
<PencilIcon
|
||||
class="transition duration-200 opacity-0 group-hover/website:opacity-100 size-4"
|
||||
/>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (uploadBannerOpen = true)"
|
||||
>
|
||||
{{ $t("library.admin.metadata.companies.editor.uploadBanner") }}
|
||||
<ArrowUpTrayIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">
|
||||
{{ $t("library.admin.metadata.companies.editor.libraryTitle") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("library.admin.metadata.companies.editor.libraryDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<button
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (addGameModelOpen = true)"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.metadata.companies.editor.action"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="inline-flex items-center gap-x-1"
|
||||
>
|
||||
<template #plus>
|
||||
<PlusIcon class="size-4" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-1">
|
||||
<input
|
||||
id="search"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
name="search"
|
||||
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
|
||||
:placeholder="$t('library.admin.metadata.companies.searchGames')"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
role="list"
|
||||
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="game in filteredGames"
|
||||
:key="game.id"
|
||||
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border border-zinc-800 group"
|
||||
>
|
||||
<div class="flex flex-1 flex-row p-4 gap-x-4">
|
||||
<img
|
||||
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
|
||||
:src="useObject(game.mIconObjectId)"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<h3
|
||||
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
|
||||
>
|
||||
{{ game.mName }}
|
||||
</h3>
|
||||
<dl class="mt-1 flex flex-col justify-between">
|
||||
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
|
||||
<dd class="text-sm text-zinc-400">
|
||||
{{ game.mShortDescription }}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="mt-4 flex flex-col gap-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="group/published relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
|
||||
>
|
||||
<span
|
||||
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-3"
|
||||
/>
|
||||
<input
|
||||
id="published"
|
||||
v-model="published[game.id]"
|
||||
type="checkbox"
|
||||
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
|
||||
aria-labelledby="published-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
id="published-label"
|
||||
for="published"
|
||||
class="font-medium text-xs text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.editor.published")
|
||||
}}</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="group/developed relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
|
||||
>
|
||||
<span
|
||||
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developed:translate-x-3"
|
||||
/>
|
||||
<input
|
||||
id="developed"
|
||||
v-model="developed[game.id]"
|
||||
type="checkbox"
|
||||
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
|
||||
aria-labelledby="developed-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
id="developed-label"
|
||||
for="published"
|
||||
class="font-medium text-xs text-zinc-100"
|
||||
>{{
|
||||
$t("library.admin.metadata.companies.editor.developed")
|
||||
}}</label
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
@click="() => removeGame(game.id)"
|
||||
>
|
||||
{{ $t("common.remove") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<p
|
||||
v-if="filteredGames.length == 0 && games.length != 0"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("common.noResults") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="filteredGames.length == 0 && games.length == 0"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("library.admin.metadata.companies.noGames") }}
|
||||
</p>
|
||||
</ul>
|
||||
<ModalAddCompanyGame
|
||||
v-model="addGameModelOpen"
|
||||
:exclude="games.map((e) => e.id)"
|
||||
:company-id="company.id"
|
||||
@created="appendGame"
|
||||
/>
|
||||
<ModalUploadFile
|
||||
v-model="uploadLogoOpen"
|
||||
:endpoint="`/api/v1/admin/company/${company.id}/icon`"
|
||||
accept="image/*"
|
||||
@upload="updateLogo"
|
||||
/>
|
||||
<ModalUploadFile
|
||||
v-model="uploadBannerOpen"
|
||||
:endpoint="`/api/v1/admin/company/${company.id}/banner`"
|
||||
accept="image/*"
|
||||
@upload="updateBanner"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
import { ArrowUpTrayIcon, PencilIcon, PlusIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const companyId = route.params.id!.toString();
|
||||
const result = await $dropFetch("/api/v1/admin/company/:id", {
|
||||
params: { id: companyId },
|
||||
});
|
||||
const company = ref(result.company);
|
||||
const games = ref(result.games);
|
||||
|
||||
const addGameModelOpen = ref(false);
|
||||
const uploadLogoOpen = ref(false);
|
||||
const uploadBannerOpen = ref(false);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead({
|
||||
title: `${company.value.mName} - Company`,
|
||||
});
|
||||
|
||||
const searchQuery = ref("");
|
||||
|
||||
const filteredGames = computed(() =>
|
||||
games.value.filter(
|
||||
(e: SerializeObject<GameModel>) =>
|
||||
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
e.mShortDescription.includes(searchQuery.value.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
function buildToggleProxy(param: "developed" | "published") {
|
||||
async function tick(id: string, enabled: boolean) {
|
||||
if (
|
||||
company.value.developed.length == 0 &&
|
||||
company.value.published.length == 0
|
||||
)
|
||||
return await removeGame(id);
|
||||
|
||||
await $dropFetch("/api/v1/admin/company/:id/game", {
|
||||
method: "PATCH",
|
||||
params: {
|
||||
id: company.value.id,
|
||||
},
|
||||
body: {
|
||||
action: param,
|
||||
enabled,
|
||||
id,
|
||||
},
|
||||
failTitle: `Failed to update ${param} for game`,
|
||||
});
|
||||
}
|
||||
return new Proxy({} as { [key: string]: boolean }, {
|
||||
get(_target, prop, _reciever) {
|
||||
return company.value[param].includes(prop.toString());
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
if (typeof value !== "boolean") return false;
|
||||
const id = prop.toString();
|
||||
const exists = company.value[param].findIndex((e) => e === id);
|
||||
if (value && exists == -1) {
|
||||
company.value[param].push(id);
|
||||
}
|
||||
if (!value && exists != -1) {
|
||||
company.value[param].splice(exists, 1);
|
||||
}
|
||||
tick(id, value);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const published = buildToggleProxy("published");
|
||||
const developed = buildToggleProxy("developed");
|
||||
|
||||
async function removeGame(gameId: string) {
|
||||
await $dropFetch("/api/v1/admin/company/:id/game", {
|
||||
params: {
|
||||
id: company.value.id,
|
||||
},
|
||||
body: {
|
||||
id: gameId,
|
||||
},
|
||||
method: "DELETE",
|
||||
failTitle: "Failed to remove game",
|
||||
});
|
||||
const gameIndex = games.value.findIndex((e) => e.id == gameId);
|
||||
if (gameIndex == -1) return;
|
||||
games.value.splice(gameIndex, 1);
|
||||
|
||||
const publishedIndex = company.value.published.findIndex((e) => e === gameId);
|
||||
if (publishedIndex != -1) {
|
||||
company.value.published.splice(publishedIndex, 1);
|
||||
}
|
||||
|
||||
const developedIndex = company.value.developed.findIndex((e) => e === gameId);
|
||||
if (developedIndex != -1) {
|
||||
company.value.developed.splice(developedIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function appendGame(
|
||||
game: (typeof games.value)[number],
|
||||
published: boolean,
|
||||
developed: boolean,
|
||||
) {
|
||||
games.value.push(game);
|
||||
if (published) {
|
||||
company.value.published.push(game.id);
|
||||
}
|
||||
if (developed) {
|
||||
company.value.developed.push(game.id);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFieldEditModal(
|
||||
field: "mName" | "mShortDescription" | "mWebsite",
|
||||
title: string,
|
||||
description: string,
|
||||
) {
|
||||
function modal() {
|
||||
createModal(
|
||||
ModalType.TextInput,
|
||||
{
|
||||
title,
|
||||
description,
|
||||
dft: company.value[field],
|
||||
},
|
||||
async (e, c, s) => {
|
||||
switch (e) {
|
||||
case "cancel": {
|
||||
c();
|
||||
break;
|
||||
}
|
||||
case "submit": {
|
||||
const result = await $dropFetch("/api/v1/admin/company/:id", {
|
||||
method: "PATCH",
|
||||
params: { id: company.value.id },
|
||||
body: { [field]: s! },
|
||||
failTitle: "Failed to update company details",
|
||||
});
|
||||
company.value[field] = result[field];
|
||||
c();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
const editName = buildFieldEditModal(
|
||||
"mName",
|
||||
t("library.admin.metadata.companies.modals.nameTitle"),
|
||||
t("library.admin.metadata.companies.modals.nameDescription"),
|
||||
);
|
||||
|
||||
const editShortDescription = buildFieldEditModal(
|
||||
"mShortDescription",
|
||||
t("library.admin.metadata.companies.modals.shortDeckTitle"),
|
||||
t("library.admin.metadata.companies.modals.shortDeckDescription"),
|
||||
);
|
||||
|
||||
const editWebsite = buildFieldEditModal(
|
||||
"mWebsite",
|
||||
t("library.admin.metadata.companies.modals.websiteTitle"),
|
||||
t("library.admin.metadata.companies.modals.websiteDescription"),
|
||||
);
|
||||
|
||||
function updateLogo(response: { id: string }) {
|
||||
company.value.mLogoObjectId = response.id;
|
||||
}
|
||||
|
||||
function updateBanner(response: { id: string }) {
|
||||
company.value.mBannerObjectId = response.id;
|
||||
}
|
||||
</script>
|
||||
@ -1,150 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">
|
||||
{{ $t("library.admin.metadata.companies.title") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("library.admin.metadata.companies.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<NuxtLink
|
||||
to="/admin/library/sources"
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.sources.link"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-1">
|
||||
<input
|
||||
id="search"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
name="search"
|
||||
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
|
||||
:placeholder="$t('library.admin.metadata.companies.search')"
|
||||
/>
|
||||
<MagnifyingGlassIcon
|
||||
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
role="list"
|
||||
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="company in filteredCompanies"
|
||||
:key="company.id"
|
||||
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
|
||||
>
|
||||
<div class="flex flex-1 flex-row p-4 gap-x-4">
|
||||
<img
|
||||
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
|
||||
:src="useObject(company.mLogoObjectId)"
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<h3
|
||||
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
|
||||
>
|
||||
{{ company.mName }}
|
||||
</h3>
|
||||
<dl class="mt-1 flex flex-col justify-between">
|
||||
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
|
||||
<dd class="text-sm text-zinc-400">
|
||||
{{ company.mShortDescription }}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="mt-4 flex flex-col gap-y-1">
|
||||
<NuxtLink
|
||||
:href="`/admin/metadata/companies/${company.id}`"
|
||||
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.openEditor"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||
@click="() => deleteCompany(company.id)"
|
||||
>
|
||||
{{ $t("delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<p
|
||||
v-if="filteredCompanies.length == 0 && companies.length != 0"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("common.noResults") }}
|
||||
</p>
|
||||
<p
|
||||
v-if="filteredCompanies.length == 0 && companies.length == 0"
|
||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||
>
|
||||
{{ $t("library.admin.metadata.companies.noCompanies") }}
|
||||
</p>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||
import type { CompanyModel } from "~/prisma/client/models";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: t("library.admin.metadata.companies.title"),
|
||||
});
|
||||
|
||||
const searchQuery = ref("");
|
||||
|
||||
const companies = ref(await $dropFetch("/api/v1/admin/company"));
|
||||
|
||||
const filteredCompanies = computed(() =>
|
||||
companies.value.filter((e: CompanyModel) => {
|
||||
if (!searchQuery.value) return true;
|
||||
const searchQueryLower = searchQuery.value.toLowerCase();
|
||||
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
|
||||
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
|
||||
return true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
async function deleteCompany(id: string) {
|
||||
await $dropFetch(`/api/v1/admin/company/:id`, {
|
||||
method: "DELETE",
|
||||
params: { id },
|
||||
failTitle: "Failed to delete company",
|
||||
});
|
||||
|
||||
const index = companies.value.findIndex((e) => e.id === id);
|
||||
companies.value.splice(index, 1);
|
||||
}
|
||||
</script>
|
||||
@ -1,62 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-16">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">
|
||||
{{ $t("library.admin.metadata.tags.title") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("library.admin.metadata.tags.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
|
||||
<NuxtLink
|
||||
to="/admin/metadata/tags"
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.metadata.tags.action"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">
|
||||
{{ $t("library.admin.metadata.companies.title") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("library.admin.metadata.companies.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
|
||||
<NuxtLink
|
||||
to="/admin/metadata/companies"
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="library.admin.metadata.companies.action"
|
||||
tag="span"
|
||||
scope="global"
|
||||
>
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
</script>
|
||||
@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-zinc-100">
|
||||
{{ $t("library.admin.metadata.tags.title") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-zinc-400">
|
||||
{{ $t("library.admin.metadata.tags.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
|
||||
<button
|
||||
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="() => (createModalOpen = true)"
|
||||
>
|
||||
{{ $t("library.admin.metadata.tags.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div
|
||||
v-for="(tag, tagIdx) in tags"
|
||||
:key="tag.id"
|
||||
class="py-2 px-3 inline-flex gap-x-3 bg-zinc-950 ring-1 ring-zinc-800 text-zinc-300"
|
||||
>
|
||||
{{ tag.name }}
|
||||
<button @click="() => deleteTag(tagIdx)">
|
||||
<TrashIcon
|
||||
class="transition size-4 text-zinc-700 hover:text-red-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ModalCreateTag v-model="createModalOpen" @created="onTagCreate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon } from "@heroicons/vue/24/outline";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { GameTagModel } from "~/prisma/client/models";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const createModalOpen = ref(false);
|
||||
|
||||
const tags = ref(
|
||||
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/admin/tags"),
|
||||
);
|
||||
|
||||
function sort() {
|
||||
tags.value.sort((a, b) =>
|
||||
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
sort();
|
||||
|
||||
async function onTagCreate(tag: GameTagModel) {
|
||||
tags.value.push(tag);
|
||||
sort();
|
||||
}
|
||||
|
||||
async function deleteTag(tagIdx: number) {
|
||||
const tag = tags.value[tagIdx];
|
||||
await $dropFetch(`/api/v1/admin/tags/${tag.id}`, {
|
||||
method: "DELETE",
|
||||
failTitle: "Failed to delete tag",
|
||||
});
|
||||
tags.value.splice(tagIdx, 1);
|
||||
}
|
||||
</script>
|
||||
@ -1,121 +1,12 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-zinc-100">
|
||||
{{ $t("settings.admin.title") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-base text-zinc-400">
|
||||
{{ $t("settings.admin.description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="() => saveSettings()">
|
||||
<div class="py-6 border-y border-zinc-700">
|
||||
<h2 class="text-xl font-semibold text-zinc-100">
|
||||
{{ $t("settings.admin.store.title") }}
|
||||
</h2>
|
||||
|
||||
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
|
||||
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
|
||||
</h3>
|
||||
<ul class="flex gap-3">
|
||||
<li class="inline-block">
|
||||
<OptionWrapper
|
||||
:active="showGamePanelTextDecoration"
|
||||
@click="setShowTitleDescription(true)"
|
||||
>
|
||||
<div class="flex">
|
||||
<GamePanel
|
||||
:animate="false"
|
||||
:game="game"
|
||||
:default-placeholder="true"
|
||||
/>
|
||||
</div>
|
||||
</OptionWrapper>
|
||||
</li>
|
||||
<li class="inline-block">
|
||||
<OptionWrapper
|
||||
:active="!showGamePanelTextDecoration"
|
||||
@click="setShowTitleDescription(false)"
|
||||
>
|
||||
<div class="flex">
|
||||
<GamePanel
|
||||
:game="game"
|
||||
:show-title-description="false"
|
||||
:animate="false"
|
||||
:default-placeholder="true"
|
||||
/>
|
||||
</div>
|
||||
</OptionWrapper>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
class="inline-flex w-full shadow-sm sm:w-auto"
|
||||
:loading="saving"
|
||||
:disabled="!allowSave"
|
||||
>
|
||||
{{ allowSave ? $t("common.save") : $t("common.saved") }}
|
||||
</LoadingButton>
|
||||
</form>
|
||||
</div>
|
||||
<div class="text-gray-100">{{ $t("todo") }}</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
const { t } = useI18n();
|
||||
<script lang="ts" setup>
|
||||
useHead({
|
||||
title: "Settings",
|
||||
});
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: t("settings.admin.title"),
|
||||
});
|
||||
|
||||
const settings = await $dropFetch("/api/v1/settings");
|
||||
const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data");
|
||||
|
||||
const allowSave = ref(false);
|
||||
|
||||
const showGamePanelTextDecoration = ref<boolean>(
|
||||
settings.showGamePanelTextDecoration,
|
||||
);
|
||||
|
||||
function setShowTitleDescription(value: boolean) {
|
||||
showGamePanelTextDecoration.value = value;
|
||||
allowSave.value = true;
|
||||
}
|
||||
|
||||
const saving = ref<boolean>(false);
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await $dropFetch("/api/v1/admin/settings", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
showGamePanelTextDecoration: showGamePanelTextDecoration.value,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: `Failed to save settings.`,
|
||||
description:
|
||||
e instanceof FetchError
|
||||
? (e.statusMessage ?? e.message)
|
||||
: (e as string).toString(),
|
||||
},
|
||||
(_, c) => c(),
|
||||
);
|
||||
}
|
||||
saving.value = false;
|
||||
allowSave.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate text-sm text-zinc-400">
|
||||
{{ parseTaskLog(task.value.log.at(-1) ?? "").message }}
|
||||
{{ parseTaskLog(task.value.log.at(-1)).message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
@ -115,7 +115,7 @@
|
||||
{{ task.id }}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-sm text-zinc-400">
|
||||
{{ parseTaskLog(task.log.at(-1) ?? "").message }}
|
||||
{{ parseTaskLog(task.log.at(-1)).message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
type="button"
|
||||
@ -230,5 +230,9 @@ const scheduledTasks: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
"ludusavi:import": {
|
||||
name: t("tasks.admin.scheduled.ludusaviImportName"),
|
||||
description: t("tasks.admin.scheduled.ludusaviImportDescription"),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -116,7 +116,7 @@ import { IconsSimpleAuthenticationLogo, IconsSSOLogo } from "#components";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/vue/20/solid";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/solid";
|
||||
import type { AuthMec } from "~/prisma/client/enums";
|
||||
import type { AuthMec } from "~/prisma/client";
|
||||
import type { Component } from "vue";
|
||||
|
||||
useHead({
|
||||
|
||||
@ -36,14 +36,20 @@
|
||||
</div>
|
||||
<ul role="list" class="divide-y divide-zinc-800">
|
||||
<li
|
||||
v-for="invitation in invitations"
|
||||
v-for="(invitation, invitationIdx) in invitations"
|
||||
:key="invitation.id"
|
||||
class="relative flex justify-between gap-x-6 py-5"
|
||||
>
|
||||
<div class="flex min-w-0 gap-x-4">
|
||||
<div class="min-w-0 flex-auto">
|
||||
<div class="text-sm/6 font-semibold text-zinc-100">
|
||||
<p>{{ invitation.inviteUrl }}</p>
|
||||
<p v-if="invitationUrls">
|
||||
{{ invitationUrls[invitationIdx] }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="h-4 w-full bg-zinc-800 animate-pulse rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 flex text-xs/5 text-gray-500">
|
||||
@ -394,7 +400,7 @@ import {
|
||||
} from "@headlessui/vue";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import { TrashIcon, XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
import type { InvitationModel } from "~/prisma/client/models";
|
||||
import type { Invitation } from "~/prisma/client";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { DurationLike } from "luxon";
|
||||
import { DateTime } from "luxon";
|
||||
@ -409,11 +415,20 @@ useHead({
|
||||
title: "Simple authentication",
|
||||
});
|
||||
|
||||
const data = await $dropFetch<
|
||||
Array<SerializeObject<InvitationModel & { inviteUrl: string }>>
|
||||
>("/api/v1/admin/auth/invitation");
|
||||
const data = await $dropFetch<Array<SerializeObject<Invitation>>>(
|
||||
"/api/v1/admin/auth/invitation",
|
||||
);
|
||||
const invitations = ref(data ?? []);
|
||||
|
||||
const generateInvitationUrl = (id: string) =>
|
||||
`${window.location.protocol}//${window.location.host}/auth/register?id=${id}`;
|
||||
const invitationUrls = ref<undefined | Array<string>>();
|
||||
onMounted(() => {
|
||||
invitationUrls.value = invitations.value.map((invitation) =>
|
||||
generateInvitationUrl(invitation.id),
|
||||
);
|
||||
});
|
||||
|
||||
// Makes username undefined if it's empty
|
||||
const _username = ref<undefined | string>(undefined);
|
||||
const username = computed({
|
||||
@ -500,6 +515,7 @@ function invite_wrapper() {
|
||||
invite()
|
||||
.then((invitation) => {
|
||||
invitations.value.push(invitation);
|
||||
invitationUrls.value?.push(generateInvitationUrl(invitation.id));
|
||||
})
|
||||
.catch((response) => {
|
||||
const message = response.statusMessage || t("errors.unknown");
|
||||
@ -520,6 +536,7 @@ async function deleteInvitation(id: string) {
|
||||
|
||||
const index = invitations.value.findIndex((e) => e.id === id);
|
||||
invitations.value.splice(index, 1);
|
||||
invitationUrls.value?.splice(index, 1);
|
||||
}
|
||||
|
||||
const createModalOpen = ref(false);
|
||||
|
||||
@ -138,13 +138,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModalDeleteUser v-model="userToDelete" />
|
||||
<DeleteUserModal v-model="userToDelete" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUsers } from "~/composables/users";
|
||||
import type { UserModel } from "~/prisma/client/models";
|
||||
import type { User } from "~/prisma/client";
|
||||
|
||||
useHead({
|
||||
title: "Users",
|
||||
@ -163,5 +163,5 @@ if (!users.value) {
|
||||
|
||||
const userToDelete = ref();
|
||||
|
||||
const setUserToDelete = (user: UserModel) => (userToDelete.value = user);
|
||||
const setUserToDelete = (user: User) => (userToDelete.value = user);
|
||||
</script>
|
||||
|
||||
@ -157,7 +157,7 @@
|
||||
|
||||
<div>
|
||||
<LoadingButton type="submit" :loading="loading" class="w-full">
|
||||
{{ $t("common.create") }}
|
||||
{{ $t("create") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
@ -264,10 +264,6 @@ function register_wrapper() {
|
||||
loading.value = true;
|
||||
register()
|
||||
.then(() => {
|
||||
if (route.query.after == "close") {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
router.push("/auth/signin");
|
||||
})
|
||||
.catch((response) => {
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuthMec } from "~/prisma/client/enums";
|
||||
import type { AuthMec } from "~/prisma/client";
|
||||
import DropLogo from "~/components/DropLogo.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
</TransitionChild>
|
||||
<!-- Sidebar component, swap this element with another sidebar if you like -->
|
||||
<div class="bg-zinc-900">
|
||||
<DirectoryLibrary />
|
||||
<LibraryDirectory />
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
@ -64,7 +64,7 @@
|
||||
class="hidden lg:flex lg:inset-y-0 lg:z-50 lg:shrink-0 lg:basis-[18rem] lg:flex-col lg:border-r-2 lg:border-zinc-800"
|
||||
>
|
||||
<!-- Sidebar component, swap this element with another sidebar if you like -->
|
||||
<DirectoryLibrary />
|
||||
<LibraryDirectory />
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
:to="`/store/${game.id}`"
|
||||
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-105 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
|
||||
>
|
||||
{{ $t("store.viewInStore") }}
|
||||
{{ $t("store.view") }}
|
||||
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
@ -88,19 +88,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalCreateCollection v-model="collectionCreateOpen" />
|
||||
<ModalDeleteCollection v-model="currentlyDeleting" />
|
||||
<CreateCollectionModal v-model="collectionCreateOpen" />
|
||||
<DeleteCollectionModal v-model="currentlyDeleting" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, PlusIcon } from "@heroicons/vue/20/solid";
|
||||
import type { CollectionModel } from "~/prisma/client/models";
|
||||
import type { Collection } from "~/prisma/client";
|
||||
|
||||
const collections = await useCollections();
|
||||
const collectionCreateOpen = ref(false);
|
||||
|
||||
const currentlyDeleting = ref<CollectionModel | undefined>();
|
||||
const currentlyDeleting = ref<Collection | undefined>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const library = await useLibrary();
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
</TransitionChild>
|
||||
<div class="bg-zinc-900">
|
||||
<NewsArticleCreateButton />
|
||||
<DirectoryNews />
|
||||
<NewsDirectory :articles="news" />
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
@ -64,7 +64,7 @@
|
||||
class="hidden lg:block lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col lg:border-r-2 lg:border-zinc-800"
|
||||
>
|
||||
<NewsArticleCreateButton />
|
||||
<DirectoryNews />
|
||||
<NewsDirectory />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@ -86,7 +86,7 @@
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6 grow">
|
||||
<NuxtPage :articles="news" />
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -105,6 +105,7 @@ const news = useNews();
|
||||
|
||||
if (!news.value) {
|
||||
await fetchNews();
|
||||
console.log("fetched news");
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="article" class="px-4 sm:px-6 lg:px-8">
|
||||
<div v-if="article" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Banner header with blurred background -->
|
||||
<div class="relative w-full h-[300px] mb-8 rounded-lg overflow-hidden">
|
||||
<div class="absolute inset-0">
|
||||
<div v-if="article.imageObjectId" class="absolute inset-0">
|
||||
<img
|
||||
:src="
|
||||
article.imageObjectId
|
||||
? useObject(article.imageObjectId)
|
||||
: '/wallpapers/news-placeholder.jpg'
|
||||
"
|
||||
:src="useObject(article.imageObjectId)"
|
||||
alt=""
|
||||
class="w-full h-full object-cover blur-sm scale-110"
|
||||
/>
|
||||
@ -18,6 +14,12 @@
|
||||
class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- Fallback gradient background when no image -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-zinc-800 to-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative h-full flex flex-col justify-end p-8">
|
||||
<div class="flex items-center gap-x-3 mb-6">
|
||||
@ -71,12 +73,12 @@
|
||||
|
||||
<!-- Article content - markdown -->
|
||||
<div
|
||||
class="mx-auto prose prose-blue prose-invert prose-lg"
|
||||
class="mx-auto prose prose-invert prose-lg"
|
||||
v-html="renderedContent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalDeleteNews v-model="currentlyDeleting" />
|
||||
<DeleteNewsModal v-model="currentlyDeleting" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col px-8">
|
||||
<div class="flex flex-col max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<div>
|
||||
@ -14,11 +14,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Articles list -->
|
||||
<TransitionGroup
|
||||
name="article-list"
|
||||
tag="div"
|
||||
class="gap-6 grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5"
|
||||
>
|
||||
<TransitionGroup name="article-list" tag="div" class="space-y-6">
|
||||
<NuxtLink
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
@ -30,11 +26,8 @@
|
||||
>
|
||||
<div class="relative h-48 w-full overflow-hidden">
|
||||
<img
|
||||
:src="
|
||||
article.imageObjectId
|
||||
? useObject(article.imageObjectId)
|
||||
: '/wallpapers/news-placeholder.jpg'
|
||||
"
|
||||
v-if="article.imageObjectId"
|
||||
:src="useObject(article.imageObjectId)"
|
||||
alt=""
|
||||
class="h-full w-full object-cover object-center transition-all duration-500 group-hover:scale-110 scale-105"
|
||||
/>
|
||||
@ -90,14 +83,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DocumentIcon } from "@heroicons/vue/24/outline";
|
||||
import type { ArticleModel } from "~/prisma/client/models";
|
||||
import type { Article } from "~/prisma/client";
|
||||
import type { SerializeObject } from "nitropack/types";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { articles } = defineProps<{
|
||||
articles: SerializeObject<
|
||||
ArticleModel & {
|
||||
Article & {
|
||||
tags: Array<{ name: string; id: string }>;
|
||||
author: { displayName: string };
|
||||
}
|
||||
|
||||
227
pages/setup.vue
227
pages/setup.vue
@ -1,227 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<div class="grow grid grid-cols-1 lg:grid-cols-2">
|
||||
<div class="border-b lg:border-b-0 lg:border-r border-zinc-700">
|
||||
<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"
|
||||
>
|
||||
<DropWordmark />
|
||||
</header>
|
||||
<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"
|
||||
>
|
||||
<div>
|
||||
<h1 class="text-4xl font-display font-bold text-zinc-100">
|
||||
{{ $t("setup.welcome") }}
|
||||
</h1>
|
||||
<LanguageSelectorListbox :show-text="false" class="mt-4 max-w-sm" />
|
||||
<p class="mt-6 text-zinc-400 max-w-xl">
|
||||
{{ $t("setup.welcomeDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<ul role="list" class="mt-10 divide-y divide-zinc-700/5">
|
||||
<li
|
||||
v-for="(action, actionIdx) in actions"
|
||||
:key="action.name"
|
||||
class="relative flex gap-x-6 py-6"
|
||||
>
|
||||
<div
|
||||
class="flex size-10 flex-none items-center justify-center rounded-lg shadow-xs outline-1 outline-zinc-100/10"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
v-if="!actionsComplete[actionIdx]"
|
||||
class="size-6 text-blue-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon v-else class="size-6 text-blue-500" />
|
||||
</div>
|
||||
<div class="flex-auto">
|
||||
<h3 class="text-sm/6 font-semibold text-zinc-100">
|
||||
<button
|
||||
:class="
|
||||
actionsComplete[actionIdx]
|
||||
? 'line-through text-zinc-300'
|
||||
: ''
|
||||
"
|
||||
@click="() => (currentAction = actionIdx)"
|
||||
>
|
||||
<span class="absolute inset-0" aria-hidden="true" />
|
||||
{{ action.name }}
|
||||
</button>
|
||||
</h3>
|
||||
<p class="mt-2 text-sm/6 text-zinc-400">
|
||||
{{ action.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none self-center">
|
||||
<ChevronRightIcon
|
||||
class="size-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<LoadingButton
|
||||
:disabled="!finished"
|
||||
:loading="finishLoading"
|
||||
@click="() => finish()"
|
||||
>
|
||||
<i18n-t keypath="setup.finish" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</LoadingButton>
|
||||
</main>
|
||||
</div>
|
||||
<component
|
||||
:is="actions[currentAction].page"
|
||||
v-if="actions[currentAction] && !useModal"
|
||||
v-model="actionsComplete[currentAction]"
|
||||
:token="bearerToken"
|
||||
/>
|
||||
<div
|
||||
v-else-if="!useModal"
|
||||
class="bg-zinc-950/30 flex items-center justify-center"
|
||||
>
|
||||
<p class="uppercase text-sm font-display text-zinc-700 font-bold">
|
||||
{{ $t("setup.noPage") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Transition>
|
||||
<div v-if="useModal && open" class="relative z-10">
|
||||
<div class="fixed inset-0 bg-zinc-900/75 transition-opacity" />
|
||||
|
||||
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div
|
||||
class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
|
||||
>
|
||||
<div
|
||||
class="relative transform overflow-hidden rounded-lg bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm"
|
||||
>
|
||||
<div>
|
||||
<component
|
||||
:is="actions[currentAction].page"
|
||||
v-model="actionsComplete[currentAction]"
|
||||
:token="bearerToken"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5 sm:mt-6 p-4">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
@click="currentAction = -1"
|
||||
>
|
||||
{{ $t("common.close") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SetupAccount, SetupLibrary } from "#components";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
ServerStackIcon,
|
||||
UserCircleIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import type { Component } from "vue";
|
||||
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core";
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const useModal = computed(() => !breakpoints.lg.value);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
useHead({
|
||||
title: t("setup.welcome"),
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const token = route.query.token;
|
||||
if (!token)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "No token.",
|
||||
fatal: true,
|
||||
});
|
||||
const bearerToken = `Bearer ${token}`;
|
||||
|
||||
const allowed = await $dropFetch("/api/v1/admin", {
|
||||
headers: { Authorization: bearerToken },
|
||||
});
|
||||
if (!allowed)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid setup token. Please check the URL you opened.",
|
||||
fatal: true,
|
||||
});
|
||||
|
||||
const currentAction = ref(-1);
|
||||
const actions = ref<
|
||||
Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
icon: Component;
|
||||
page: Component;
|
||||
}>
|
||||
>([
|
||||
{
|
||||
name: t("setup.stages.account.name"),
|
||||
description: t("setup.stages.account.description"),
|
||||
icon: UserCircleIcon,
|
||||
page: SetupAccount,
|
||||
},
|
||||
{
|
||||
name: t("setup.stages.library.name"),
|
||||
description: t("setup.stages.library.description"),
|
||||
icon: ServerStackIcon,
|
||||
page: SetupLibrary,
|
||||
},
|
||||
]);
|
||||
|
||||
const actionsComplete = ref(Array(actions.value.length).fill(false));
|
||||
|
||||
const finished = computed(
|
||||
() => actionsComplete.value.filter((e) => !e).length == 0,
|
||||
);
|
||||
|
||||
const open = computed(() => currentAction.value != -1);
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
const finishLoading = ref(false);
|
||||
async function finish() {
|
||||
currentAction.value = -1;
|
||||
finishLoading.value = true;
|
||||
await $dropFetch("/api/v1/setup", {
|
||||
headers: { Authorization: bearerToken },
|
||||
method: "POST",
|
||||
});
|
||||
router.push("/auth/signin");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -108,72 +108,6 @@
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.tags") }}
|
||||
</td>
|
||||
<td class="flex flex-col gap-1 px-3 py-4 text-sm text-zinc-400">
|
||||
<NuxtLink
|
||||
v-for="tag in game.tags"
|
||||
:key="tag.id"
|
||||
:href="`/store/t/${tag.id}`"
|
||||
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-if="game.tags.length == 0"
|
||||
class="text-zinc-700 font-bold uppercase font-display"
|
||||
>{{ $t("store.noTags") }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.developers", game.developers.length) }}
|
||||
</td>
|
||||
<td class="flex flex-col px-3 py-4 text-sm text-zinc-400">
|
||||
<NuxtLink
|
||||
v-for="developer in game.developers"
|
||||
:key="developer.id"
|
||||
:href="`/store/c/${developer.id}`"
|
||||
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
|
||||
>
|
||||
{{ developer.mName }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-if="game.developers.length == 0"
|
||||
class="text-zinc-700 font-bold uppercase font-display"
|
||||
>{{ $t("store.noDevelopers") }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ $t("store.publishers", game.publishers.length) }}
|
||||
</td>
|
||||
<td class="flex flex-col px-3 py-4 text-sm text-zinc-400">
|
||||
<NuxtLink
|
||||
v-for="publisher in game.publishers"
|
||||
:key="publisher.id"
|
||||
:href="`/store/c/${publisher.id}`"
|
||||
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
|
||||
>
|
||||
{{ publisher.mName }}
|
||||
</NuxtLink>
|
||||
<span
|
||||
v-if="game.publishers.length == 0"
|
||||
class="text-zinc-700 font-bold uppercase font-display"
|
||||
>{{ $t("store.noPublishers") }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -291,7 +225,6 @@ const ratingArray = Array(5)
|
||||
|
||||
useHead({
|
||||
title: game.mName,
|
||||
link: [{ rel: "icon", href: useObject(game.mIconObjectId) }],
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="w-full overflow-x-hidden">
|
||||
<div class="relative overflow-hidden bg-zinc-900">
|
||||
<!-- Decorative background image and gradient -->
|
||||
<div aria-hidden="true" class="absolute inset-0">
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<img
|
||||
:src="useObject(company.mBannerObjectId)"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-zinc-900/75" />
|
||||
<div class="absolute inset-0 bg-linear-to-t from-zinc-900" />
|
||||
</div>
|
||||
|
||||
<!-- Callout -->
|
||||
<section
|
||||
aria-labelledby="sale-heading"
|
||||
class="relative mx-auto flex max-w-7xl flex-col items-center px-4 pt-32 pb-8 text-center sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="mx-auto max-w-2xl lg:max-w-none">
|
||||
<h2
|
||||
id="sale-heading"
|
||||
class="text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl"
|
||||
>
|
||||
{{ company.mName }}
|
||||
</h2>
|
||||
<p class="mx-auto line-clamp-3 mt-4 max-w-xl text-xl text-zinc-400">
|
||||
{{ company.mShortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<StoreView
|
||||
:extra-options="[
|
||||
{
|
||||
name: 'Company',
|
||||
param: 'companyActions',
|
||||
multiple: true,
|
||||
options: [
|
||||
{ name: 'Published', param: 'published' },
|
||||
{ name: 'Developed', param: 'developed' },
|
||||
],
|
||||
},
|
||||
]"
|
||||
:params="{
|
||||
company: company.id,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const companyId = route.params.id;
|
||||
|
||||
const { company } = await $dropFetch(`/api/v1/companies/${companyId}`);
|
||||
|
||||
useHead({
|
||||
title: company.mName,
|
||||
link: [{ rel: "icon", href: useObject(company.mLogoObjectId) }],
|
||||
});
|
||||
</script>
|
||||
@ -24,16 +24,14 @@
|
||||
>
|
||||
<div class="relative text-center">
|
||||
<h3 class="text-base/7 font-semibold text-blue-300">
|
||||
{{ $t("store.featured") }}
|
||||
{{ $t("store.recentlyAdded") }}
|
||||
</h3>
|
||||
<h2
|
||||
class="text-3xl font-bold tracking-tight text-white sm:text-5xl"
|
||||
>
|
||||
{{ game.mName }}
|
||||
</h2>
|
||||
<p
|
||||
class="mt-3 text-lg text-zinc-300 line-clamp-2 max-w-xl mx-auto"
|
||||
>
|
||||
<p class="mt-3 text-lg text-zinc-300 line-clamp-2 max-w-xl">
|
||||
{{ game.mShortDescription }}
|
||||
</p>
|
||||
<div>
|
||||
@ -68,12 +66,49 @@
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<StoreView />
|
||||
<!-- new releases -->
|
||||
<div class="px-4 sm:px-12 py-4">
|
||||
<h1 class="text-zinc-100 text-2xl font-bold font-display">
|
||||
{{ $t("store.recentlyReleased") }}
|
||||
</h1>
|
||||
<NuxtLink class="text-blue-600 font-semibold">
|
||||
<i18n-t keypath="store.exploreMore" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
<div class="mt-4">
|
||||
<GameCarousel :items="released" :min="12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- recently updated -->
|
||||
<div class="px-4 sm:px-12 py-4" hydrate-on-visible>
|
||||
<h1 class="text-zinc-100 text-2xl font-bold font-display">
|
||||
{{ $t("store.recentlyUpdated") }}
|
||||
</h1>
|
||||
<NuxtLink class="text-blue-600 font-semibold">
|
||||
<i18n-t keypath="store.exploreMore" tag="span" scope="global">
|
||||
<template #arrow>
|
||||
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
<div class="mt-4">
|
||||
<GameCarousel :items="updated" :min="12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const recent = await $dropFetch("/api/v1/store/featured");
|
||||
const recent = await $dropFetch("/api/v1/store/recent");
|
||||
const updated = await $dropFetch("/api/v1/store/updated");
|
||||
const released = await $dropFetch("/api/v1/store/released");
|
||||
|
||||
// const developers = await $dropFetch("/api/v1/store/developers");
|
||||
// const publishers = await $dropFetch("/api/v1/store/publishers");
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="w-full overflow-x-hidden">
|
||||
<div class="relative overflow-hidden bg-zinc-900">
|
||||
<!-- Decorative background image and gradient -->
|
||||
<div aria-hidden="true" class="absolute inset-0">
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<img alt="" class="size-full object-cover" />
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-zinc-900/75" />
|
||||
<div class="absolute inset-0 bg-linear-to-t from-zinc-900" />
|
||||
</div>
|
||||
|
||||
<!-- Callout -->
|
||||
<section
|
||||
aria-labelledby="sale-heading"
|
||||
class="relative mx-auto flex max-w-7xl flex-col items-center px-4 pt-32 pb-8 text-center sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="mx-auto max-w-2xl lg:max-w-none">
|
||||
<h2
|
||||
id="sale-heading"
|
||||
class="text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</h2>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<StoreView
|
||||
:prefilled="{
|
||||
tags: {
|
||||
[tag.id]: true,
|
||||
} as any,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const tagId = route.params.id;
|
||||
|
||||
const { tag } = await $dropFetch(`/api/v1/tags/${tagId}`);
|
||||
|
||||
useHead({
|
||||
title: tag.name,
|
||||
});
|
||||
</script>
|
||||
@ -1,5 +1,5 @@
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hook("vue:error", (error, instance, info) => {
|
||||
console.error(info, error, instance);
|
||||
console.error(error, instance, info);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "LudusaviEntry" (
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "LudusaviEntry_pkey" PRIMARY KEY ("name")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LudusaviPlatformEntry" (
|
||||
"ludusaviEntryName" TEXT NOT NULL,
|
||||
"platform" "Platform" NOT NULL,
|
||||
"files" TEXT[],
|
||||
"registry" TEXT[],
|
||||
|
||||
CONSTRAINT "LudusaviPlatformEntry_pkey" PRIMARY KEY ("ludusaviEntryName","platform")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LudusaviPlatformEntry" ADD CONSTRAINT "LudusaviPlatformEntry_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LudusaviEntry" ADD COLUMN "steamId" TEXT;
|
||||
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Game" ADD COLUMN "ludusaviEntryName" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Game" ADD CONSTRAINT "Game_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,30 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `ludusaviEntryName` on the `Game` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CloudSaveType" AS ENUM ('Ludusavi', 'LuaScript');
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Game" DROP CONSTRAINT "Game_ludusaviEntryName_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Game" DROP COLUMN "ludusaviEntryName";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CloudSaveConfiguration" (
|
||||
"gameId" TEXT NOT NULL,
|
||||
"type" "CloudSaveType" NOT NULL,
|
||||
"ludusaviEntryName" TEXT,
|
||||
"scriptContent" TEXT,
|
||||
|
||||
CONSTRAINT "CloudSaveConfiguration_pkey" PRIMARY KEY ("gameId")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CloudSaveConfiguration" ADD CONSTRAINT "CloudSaveConfiguration_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CloudSaveConfiguration" ADD CONSTRAINT "CloudSaveConfiguration_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ApplicationSettings" ADD COLUMN "showGamePanelTextDecoration" BOOLEAN NOT NULL DEFAULT true;
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "LibraryBackend" ADD VALUE 'FlatFilesystem';
|
||||
@ -1,5 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Genre" AS ENUM ('Action', 'Strategy', 'Sports', 'Adventure', 'Roleplay', 'Racing', 'Simulation', 'Educational', 'Fighting', 'Shooter', 'RealTimeStrategy', 'CardGame', 'BoardGame', 'Compilation', 'MMORPG', 'MinigameCollection', 'Puzzle', 'MusicRhythm');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Game" ADD COLUMN "genres" "Genre"[];
|
||||
@ -1,14 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The values [RealTimeStrategy,CardGame,BoardGame,MinigameCollection,MusicRhythm] on the enum `Genre` will be removed. If these variants are still used in the database, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
CREATE TYPE "Genre_new" AS ENUM ('Action', 'Strategy', 'Sports', 'Adventure', 'Roleplay', 'Racing', 'Simulation', 'Educational', 'Fighting', 'Shooter', 'RTS', 'Card', 'Board', 'Compilation', 'MMORPG', 'Minigames', 'Puzzle', 'Rhythm');
|
||||
ALTER TABLE "Game" ALTER COLUMN "genres" TYPE "Genre_new"[] USING ("genres"::text::"Genre_new"[]);
|
||||
ALTER TYPE "Genre" RENAME TO "Genre_old";
|
||||
ALTER TYPE "Genre_new" RENAME TO "Genre";
|
||||
DROP TYPE "Genre_old";
|
||||
COMMIT;
|
||||
@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Game" ADD COLUMN "featured" BOOLEAN NOT NULL DEFAULT false;
|
||||
@ -1,11 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `genres` on the `Game` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Game" DROP COLUMN "genres";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "Genre";
|
||||
@ -1,5 +0,0 @@
|
||||
-- Add pg_trgm
|
||||
CREATE EXTENSION pg_trgm;
|
||||
|
||||
-- Create index for tag names
|
||||
-- CREATE INDEX trgm_tag_name ON "Tag" USING GIST (name gist_trgm_ops(siglen=32));
|
||||
@ -1,2 +0,0 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Tag_name_idx" ON "Tag" USING GIST ("name" gist_trgm_ops(siglen=32));
|
||||
@ -1,87 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `Tag` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `_ArticleToTag` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `_GameToTag` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "_ArticleToTag" DROP CONSTRAINT "_ArticleToTag_A_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "_ArticleToTag" DROP CONSTRAINT "_ArticleToTag_B_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "_GameToTag" DROP CONSTRAINT "_GameToTag_A_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "_GameToTag" DROP CONSTRAINT "_GameToTag_B_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "Tag";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "_ArticleToTag";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "_GameToTag";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GameTag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "GameTag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NewsTag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "NewsTag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_GameToGameTag" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_GameToGameTag_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ArticleToNewsTag" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_ArticleToNewsTag_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GameTag_name_key" ON "GameTag"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NewsTag_name_key" ON "NewsTag"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_GameToGameTag_B_index" ON "_GameToGameTag"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ArticleToNewsTag_B_index" ON "_ArticleToNewsTag"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_GameToGameTag" ADD CONSTRAINT "_GameToGameTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_GameToGameTag" ADD CONSTRAINT "_GameToGameTag_B_fkey" FOREIGN KEY ("B") REFERENCES "GameTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArticleToNewsTag" ADD CONSTRAINT "_ArticleToNewsTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArticleToNewsTag" ADD CONSTRAINT "_ArticleToNewsTag_B_fkey" FOREIGN KEY ("B") REFERENCES "NewsTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -6,8 +6,6 @@ model ApplicationSettings {
|
||||
saveSlotCountLimit Int @default(5)
|
||||
saveSlotSizeLimit Float @default(10) // MB
|
||||
saveSlotHistoryLimit Int @default(3)
|
||||
|
||||
showGamePanelTextDecoration Boolean @default(true)
|
||||
}
|
||||
|
||||
enum Platform {
|
||||
@ -18,7 +16,6 @@ enum Platform {
|
||||
|
||||
enum LibraryBackend {
|
||||
Filesystem
|
||||
FlatFilesystem
|
||||
}
|
||||
|
||||
model Library {
|
||||
@ -28,5 +25,5 @@ model Library {
|
||||
backend LibraryBackend
|
||||
options Json
|
||||
|
||||
games Game[]
|
||||
games Game[]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user