mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
i18n Support and Task improvements (#80)
* fix: release workflow * feat: move mostly to internal tasks system * feat: migrate object clean to new task system * fix: release not getting good base version * chore: set version v0.3.0 * chore: style * feat: basic task concurrency * feat: temp pages to fill in page links * feat: inital i18n support * feat: localize store page * chore: style * fix: weblate doesn't like multifile thing * fix: update nuxt * feat: improved error logging * fix: using old task api * feat: basic translation docs * feat: add i18n eslint plugin * feat: translate store and auth pages * feat: more translation progress * feat: admin dash i18n progress * feat: enable update check by default in prod * fix: using wrong i18n keys * fix: crash in library sources page * feat: finish i18n work * fix: missing i18n translations * feat: use twemoji for emojis * feat: sanatize object ids * fix: EmojiText's alt text * fix: UserWidget not using links * feat: cache and auth for emoji api * fix: add more missing translations
This commit is contained in:
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
@ -23,32 +23,23 @@ jobs:
|
|||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get base tag
|
- name: Determine final version
|
||||||
id: get_base_tag
|
id: get_final_ver
|
||||||
run: |
|
run: |
|
||||||
git fetch --tags
|
BASE_VER=v$(jq -r '.version' package.json)
|
||||||
|
|
||||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
|
||||||
echo "Using base tag: $TAG"
|
|
||||||
echo "base_tag=$TAG" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Determine final tag
|
|
||||||
id: get_final_tag
|
|
||||||
run: |
|
|
||||||
BASE_TAG=${{ steps.get_base_tag.outputs.base_tag }}
|
|
||||||
TODAY=$(date +'%Y.%m.%d')
|
TODAY=$(date +'%Y.%m.%d')
|
||||||
|
|
||||||
echo "Today will be: $TODAY"
|
echo "Today will be: $TODAY"
|
||||||
echo "today=$TODAY" >> $GITHUB_OUTPUT
|
echo "today=$TODAY" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
if [[ "${{ github.event_name }}" == "release" ]]; then
|
if [[ "${{ github.event_name }}" == "release" ]]; then
|
||||||
FINAL_TAG="$BASE_TAG"
|
FINAL_VER="$BASE_VER"
|
||||||
else
|
else
|
||||||
FINAL_TAG="${BASE_TAG}-nightly.$TODAY"
|
FINAL_VER="${BASE_VER}-nightly.$TODAY"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Drop's release tag will be: $FINAL_TAG"
|
echo "Drop's release tag will be: $FINAL_VER"
|
||||||
echo "final_tag=$FINAL_TAG" >> $GITHUB_OUTPUT
|
echo "final_ver=$FINAL_VER" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@ -74,7 +65,7 @@ jobs:
|
|||||||
ghcr.io/drop-OSS/drop
|
ghcr.io/drop-OSS/drop
|
||||||
tags: |
|
tags: |
|
||||||
type=schedule,pattern=nightly
|
type=schedule,pattern=nightly
|
||||||
type=schedule,pattern=nightly.${{ steps.get_final_tag.outputs.today }}
|
type=schedule,pattern=nightly.${{ steps.get_final_ver.outputs.today }}
|
||||||
type=semver,pattern=v{{version}}
|
type=semver,pattern=v{{version}}
|
||||||
type=semver,pattern=v{{major}}.{{minor}}
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
type=semver,pattern=v{{major}}
|
type=semver,pattern=v{{major}}
|
||||||
@ -98,4 +89,4 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
build-args: |
|
build-args: |
|
||||||
BUILD_DROP_VERSION=${{ steps.get_final_tag.outputs.final_tag }}
|
BUILD_DROP_VERSION=${{ steps.get_final_ver.outputs.final_ver }}
|
||||||
|
|||||||
12
.vscode/extensions.json
vendored
Normal file
12
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"lokalise.i18n-ally",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"Prisma.prisma",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"Vue.volar",
|
||||||
|
"arktypeio.arkdark",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"dbaeumer.vscode-eslint"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@ -17,5 +17,20 @@
|
|||||||
"strings": "on"
|
"strings": "on"
|
||||||
},
|
},
|
||||||
// prioritize ArkType's "type" for autoimports
|
// prioritize ArkType's "type" for autoimports
|
||||||
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
|
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"],
|
||||||
|
// i18n Ally settings
|
||||||
|
"i18n-ally.sortKeys": true,
|
||||||
|
"i18n-ally.keepFulfilled": true,
|
||||||
|
"i18n-ally.extract.autoDetect": true,
|
||||||
|
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
|
||||||
|
"i18n-ally.keystyle": "nested",
|
||||||
|
"i18n-ally.extract.ignored": [
|
||||||
|
"string >= 14",
|
||||||
|
"string.alphanumeric >= 5",
|
||||||
|
"/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}"
|
||||||
|
],
|
||||||
|
"i18n-ally.extract.ignoredByFiles": {
|
||||||
|
"pages/admin/library/sources/index.vue": ["Filesystem"],
|
||||||
|
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -130,6 +130,10 @@ and [create an issue](#reporting-issues) or [submit a PR](#submitting-pull-reque
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Translation
|
||||||
|
|
||||||
|
If you want to help translate Drop, we would love to have your help! You can do so on our weblate instance. Please make sure to read the [message format syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) page before starting. Failure to do so may result in your translations causing errors in Drop.
|
||||||
|
|
||||||
## Commit Guidelines
|
## Commit Guidelines
|
||||||
|
|
||||||
Drop uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
Drop uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4">
|
<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">
|
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
|
||||||
<UserIcon class="size-5" /> Account Settings
|
<UserIcon class="size-5" /> {{ $t("account.settings") }}
|
||||||
</span>
|
</span>
|
||||||
<nav class="flex flex-1 flex-col">
|
<nav class="flex flex-1 flex-col">
|
||||||
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
<ul role="list" class="flex flex-1 flex-col gap-y-7">
|
||||||
@ -50,30 +50,31 @@ import { UserIcon } from "@heroicons/vue/24/solid";
|
|||||||
import type { Component } from "vue";
|
import type { Component } from "vue";
|
||||||
|
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||||
{ label: "Home", route: "/account", icon: HomeIcon, prefix: "/account" },
|
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
|
||||||
{
|
{
|
||||||
label: "Security",
|
label: t("security"),
|
||||||
route: "/account/security",
|
route: "/account/security",
|
||||||
prefix: "/account/security",
|
prefix: "/account/security",
|
||||||
icon: LockClosedIcon,
|
icon: LockClosedIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Devices",
|
label: t("account.devices.title"),
|
||||||
route: "/account/devices",
|
route: "/account/devices",
|
||||||
prefix: "/account/devices",
|
prefix: "/account/devices",
|
||||||
icon: DevicePhoneMobileIcon,
|
icon: DevicePhoneMobileIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Notifications",
|
label: t("account.notifications.notifications"),
|
||||||
route: "/account/notifications",
|
route: "/account/notifications",
|
||||||
prefix: "/account/notifications",
|
prefix: "/account/notifications",
|
||||||
icon: BellIcon,
|
icon: BellIcon,
|
||||||
count: notifications.value.length,
|
count: notifications.value.length,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: t("settings"),
|
||||||
route: "/account/settings",
|
route: "/account/settings",
|
||||||
prefix: "/account/settings",
|
prefix: "/account/settings",
|
||||||
icon: WrenchScrewdriverIcon,
|
icon: WrenchScrewdriverIcon,
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
class="transition w-full inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
|
class="transition w-full inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
|
||||||
@click="() => toggleLibrary()"
|
@click="() => toggleLibrary()"
|
||||||
>
|
>
|
||||||
{{ inLibrary ? "In Library" : "Add to Library" }}
|
{{ inLibrary ? $t("library.inLib") : $t("library.addToLib") }}
|
||||||
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||||
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
@ -36,7 +36,7 @@
|
|||||||
<div
|
<div
|
||||||
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
|
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
|
||||||
>
|
>
|
||||||
Collections
|
{{ $t("library.collection.collections") }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-y-2 py-1 max-h-[150px] overflow-y-auto"
|
class="flex flex-col gap-y-2 py-1 max-h-[150px] overflow-y-auto"
|
||||||
@ -45,7 +45,7 @@
|
|||||||
v-if="collections.length === 0"
|
v-if="collections.length === 0"
|
||||||
class="px-3 py-2 text-sm text-zinc-500"
|
class="px-3 py-2 text-sm text-zinc-500"
|
||||||
>
|
>
|
||||||
No collections
|
{{ $t("library.collection.noCollections") }}
|
||||||
</div>
|
</div>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-for="(collection, collectionIdx) in collections"
|
v-for="(collection, collectionIdx) in collections"
|
||||||
@ -75,7 +75,7 @@
|
|||||||
@click="createCollectionModal = true"
|
@click="createCollectionModal = true"
|
||||||
>
|
>
|
||||||
<PlusIcon class="mr-2 h-4 w-4" />
|
<PlusIcon class="mr-2 h-4 w-4" />
|
||||||
Add to new collection
|
{{ $t("library.collection.addToNew") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -100,6 +100,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const isLibraryLoading = ref(false);
|
const isLibraryLoading = ref(false);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const createCollectionModal = ref(false);
|
const createCollectionModal = ref(false);
|
||||||
const collections = await useCollections();
|
const collections = await useCollections();
|
||||||
const library = await useLibrary();
|
const library = await useLibrary();
|
||||||
@ -127,9 +128,11 @@ async function toggleLibrary() {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "Failed to add game to library",
|
title: t("errors.library.add.title"),
|
||||||
// @ts-expect-error attempt to display statusMessage on error
|
description: t("errors.library.add.desc", [
|
||||||
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
|
// @ts-expect-error attempt to display statusMessage on error
|
||||||
|
e?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
(_, c) => c(),
|
(_, c) => c(),
|
||||||
);
|
);
|
||||||
@ -156,9 +159,11 @@ async function toggleCollection(id: string) {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "Failed to add game to library",
|
title: t("errors.library.add.title"),
|
||||||
// @ts-expect-error attempt to display statusMessage on error
|
description: t("errors.library.add.desc", [
|
||||||
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
|
// @ts-expect-error attempt to display statusMessage on error
|
||||||
|
e?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
(_, c) => c(),
|
(_, c) => c(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,11 @@
|
|||||||
:href="`/auth/oidc?redirect=${route.query.redirect ?? '/'}`"
|
:href="`/auth/oidc?redirect=${route.query.redirect ?? '/'}`"
|
||||||
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
|
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
|
||||||
>
|
>
|
||||||
Sign in with external provider →
|
<i18n-t keypath="auth.signin.externalProvider" tag="span" scope="global">
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<label
|
<label
|
||||||
for="username"
|
for="username"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-300"
|
class="block text-sm font-medium leading-6 text-zinc-300"
|
||||||
>Username</label
|
>{{ $t("auth.username") }}</label
|
||||||
>
|
>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<input
|
<input
|
||||||
@ -23,7 +23,7 @@
|
|||||||
<label
|
<label
|
||||||
for="password"
|
for="password"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-300"
|
class="block text-sm font-medium leading-6 text-zinc-300"
|
||||||
>Password</label
|
>{{ $t("auth.password") }}</label
|
||||||
>
|
>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<input
|
<input
|
||||||
@ -50,19 +50,23 @@
|
|||||||
<label
|
<label
|
||||||
for="remember-me"
|
for="remember-me"
|
||||||
class="ml-3 block text-sm leading-6 text-zinc-400"
|
class="ml-3 block text-sm leading-6 text-zinc-400"
|
||||||
>Remember me</label
|
>{{ $t("auth.signin.rememberMe") }}</label
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<NuxtLink to="#" class="font-semibold text-blue-600 hover:text-blue-500"
|
<NuxtLink
|
||||||
>Forgot password?</NuxtLink
|
to="#"
|
||||||
|
class="font-semibold text-blue-600 hover:text-blue-500"
|
||||||
|
>{{ $t("auth.signin.forgot") }}</NuxtLink
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<LoadingButton class="w-full" :loading="loading"> Sign in</LoadingButton>
|
<LoadingButton class="w-full" :loading="loading">{{
|
||||||
|
$t("auth.signin.signin")
|
||||||
|
}}</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
|
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
|
||||||
@ -93,6 +97,7 @@ const error = ref<string | undefined>();
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
function signin_wrapper() {
|
function signin_wrapper() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -101,7 +106,7 @@ function signin_wrapper() {
|
|||||||
router.push(route.query.redirect?.toString() ?? "/");
|
router.push(route.query.redirect?.toString() ?? "/");
|
||||||
})
|
})
|
||||||
.catch((response) => {
|
.catch((response) => {
|
||||||
const message = response.statusMessage || "An unknown error occurred";
|
const message = response.statusMessage || t("errors.unknown");
|
||||||
error.value = message;
|
error.value = message;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@ -3,11 +3,10 @@
|
|||||||
<template #default>
|
<template #default>
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||||
Create collection
|
{{ $t("library.collection.create") }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p class="mt-1 text-zinc-400 text-sm">
|
<p class="mt-1 text-zinc-400 text-sm">
|
||||||
Collections can used to organise your games and find them more easily,
|
{{ $t("library.collection.createDesc") }}
|
||||||
especially if you have a large library.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
@ -15,7 +14,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="collectionName"
|
v-model="collectionName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Collection name"
|
:placeholder="$t('library.collection.namePlaceholder')"
|
||||||
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
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" />
|
<button class="hidden" type="submit" />
|
||||||
@ -30,7 +29,7 @@
|
|||||||
class="w-full sm:w-fit"
|
class="w-full sm:w-fit"
|
||||||
@click="() => createCollection()"
|
@click="() => createCollection()"
|
||||||
>
|
>
|
||||||
Create
|
{{ $t("create") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
ref="cancelButtonRef"
|
ref="cancelButtonRef"
|
||||||
@ -38,7 +37,7 @@
|
|||||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
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()"
|
@click="() => close()"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ $t("cancel") }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
@ -60,6 +59,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const open = defineModel<boolean>({ required: true });
|
const open = defineModel<boolean>({ required: true });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const collectionName = ref("");
|
const collectionName = ref("");
|
||||||
const createCollectionLoading = ref(false);
|
const createCollectionLoading = ref(false);
|
||||||
const collections = await useCollections();
|
const collections = await useCollections();
|
||||||
@ -101,8 +101,10 @@ async function createCollection() {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "Failed to create collection",
|
title: t("errors.library.collection.create.title"),
|
||||||
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
|
description: t("errors.library.collection.create.desc", [
|
||||||
|
err?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
(_, c) => c(),
|
(_, c) => c(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,13 +6,13 @@
|
|||||||
as="h3"
|
as="h3"
|
||||||
class="text-lg font-bold font-display text-zinc-100"
|
class="text-lg font-bold font-display text-zinc-100"
|
||||||
>
|
>
|
||||||
Delete Collection
|
{{ $t("library.collection.delete") }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p class="mt-1 text-sm text-zinc-400">
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
Are you sure you want to delete "{{ collection?.name }}"?
|
{{ $t("common.deleteConfirm", [collection?.name]) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm font-bold text-red-500">
|
<p class="mt-2 text-sm font-bold text-red-500">
|
||||||
This action cannot be undone.
|
{{ $t("common.cannotUndo") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -22,13 +22,13 @@
|
|||||||
class="bg-red-600 text-white hover:bg-red-500"
|
class="bg-red-600 text-white hover:bg-red-500"
|
||||||
@click="() => deleteCollection()"
|
@click="() => deleteCollection()"
|
||||||
>
|
>
|
||||||
Delete
|
{{ $t("delete") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||||
@click="() => (collection = undefined)"
|
@click="() => (collection = undefined)"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ $t("cancel") }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
@ -42,6 +42,7 @@ const collection = defineModel<Collection | undefined>();
|
|||||||
const deleteLoading = ref(false);
|
const deleteLoading = ref(false);
|
||||||
|
|
||||||
const collections = await useCollections();
|
const collections = await useCollections();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
async function deleteCollection() {
|
async function deleteCollection() {
|
||||||
try {
|
try {
|
||||||
@ -62,9 +63,11 @@ async function deleteCollection() {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "Failed to add game to library",
|
title: t("errors.library.add.title"),
|
||||||
// @ts-expect-error attempt to display statusMessage on error
|
description: t("errors.library.add.desc", [
|
||||||
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
|
// @ts-expect-error attempt to display statusMessage on error
|
||||||
|
e?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
(_, c) => c(),
|
(_, c) => c(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,13 +6,13 @@
|
|||||||
as="h3"
|
as="h3"
|
||||||
class="text-lg font-bold font-display text-zinc-100"
|
class="text-lg font-bold font-display text-zinc-100"
|
||||||
>
|
>
|
||||||
Delete Article
|
{{ $t("news.delete") }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p class="mt-1 text-sm text-zinc-400">
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
Are you sure you want to delete "{{ article?.title }}"?
|
{{ $t("common.deleteConfirm", [article?.title]) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm font-bold text-red-500">
|
<p class="mt-2 text-sm font-bold text-red-500">
|
||||||
This action cannot be undone.
|
{{ $t("common.cannotUndo") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -22,13 +22,13 @@
|
|||||||
class="bg-red-600 text-white hover:bg-red-500"
|
class="bg-red-600 text-white hover:bg-red-500"
|
||||||
@click="() => deleteArticle()"
|
@click="() => deleteArticle()"
|
||||||
>
|
>
|
||||||
Delete
|
{{ $t("delete") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||||
@click="() => (article = undefined)"
|
@click="() => (article = undefined)"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ $t("cancel") }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
@ -45,6 +45,7 @@ interface Article {
|
|||||||
const article = defineModel<Article | undefined>();
|
const article = defineModel<Article | undefined>();
|
||||||
const deleteLoading = ref(false);
|
const deleteLoading = ref(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const news = useNews();
|
const news = useNews();
|
||||||
if (!news.value) {
|
if (!news.value) {
|
||||||
news.value = await fetchNews();
|
news.value = await fetchNews();
|
||||||
@ -68,9 +69,11 @@ async function deleteArticle() {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "Failed to delete article",
|
title: t("errors.news.article.delete.title"),
|
||||||
// @ts-expect-error attempt to display statusMessage on error
|
description: t("errors.news.article.delete.desc", [
|
||||||
description: `Drop couldn't delete this article: ${e?.statusMessage}`,
|
// @ts-expect-error attempt to display statusMessage on error
|
||||||
|
e?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
(_, c) => c(),
|
(_, c) => c(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -11,8 +11,8 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<DropLogo class="h-6" />
|
<DropLogo class="h-6" />
|
||||||
<span class="text-blue-400 font-display font-bold text-xl uppercase"
|
<span class="text-blue-400 font-display font-bold text-xl uppercase">
|
||||||
>Drop</span
|
{{ $t("drop.drop") }}
|
||||||
>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
17
components/EmojiText.vue
Normal file
17
components/EmojiText.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<img ref="emojiEl" class="inline-block emoji" :src="url" :alt="emoji" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import twemoji from "@discordapp/twemoji";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
emoji: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emojiEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const url = computed(() => {
|
||||||
|
return `/api/v1/emojis/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -32,7 +32,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<SkeletonCard v-else message="no game" />>
|
<SkeletonCard v-else :message="$t('store.noGame')" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
|
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
|
||||||
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
|
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
|
||||||
<Bars3Icon class="size-6" /> Library
|
<Bars3Icon class="size-6" /> {{ $t("userHeader.links.library") }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Search bar -->
|
<!-- Search bar -->
|
||||||
@ -13,7 +13,7 @@
|
|||||||
name="search"
|
name="search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
|
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
|
||||||
placeholder="Search library..."
|
placeholder="$t('library.search')"
|
||||||
/>
|
/>
|
||||||
<MagnifyingGlassIcon
|
<MagnifyingGlassIcon
|
||||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
|
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
|
||||||
@ -53,7 +53,7 @@
|
|||||||
v-else
|
v-else
|
||||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
|
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
|
||||||
>
|
>
|
||||||
{{ !!searchQuery ? "No results" : "No games in library" }}
|
{{ !!searchQuery ? $t("common.noResults") : $t("library.noGames") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -11,18 +11,18 @@
|
|||||||
class="h-5 w-5 transition-transform duration-200"
|
class="h-5 w-5 transition-transform duration-200"
|
||||||
:class="{ 'rotate-90': modalOpen }"
|
:class="{ 'rotate-90': modalOpen }"
|
||||||
/>
|
/>
|
||||||
<span>New article</span>
|
<span>{{ $t("news.article.new") }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ModalTemplate v-model="modalOpen" size-class="sm:max-w-[80vw]">
|
<ModalTemplate v-model="modalOpen" size-class="sm:max-w-[80vw]">
|
||||||
<h3 class="text-lg font-semibold text-zinc-100 mb-4">
|
<h3 class="text-lg font-semibold text-zinc-100 mb-4">
|
||||||
Create New Article
|
{{ $t("news.article.create") }}
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="() => createArticle()">
|
<form class="space-y-4" @submit.prevent="() => createArticle()">
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block text-sm font-medium text-zinc-400"
|
<label for="title" class="block text-sm font-medium text-zinc-400">{{
|
||||||
>Title</label
|
$t("news.article.titles")
|
||||||
>
|
}}</label>
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="title"
|
||||||
v-model="newArticle.title"
|
v-model="newArticle.title"
|
||||||
@ -34,8 +34,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="excerpt" class="block text-sm font-medium text-zinc-400"
|
<label
|
||||||
>Short description</label
|
for="excerpt"
|
||||||
|
class="block text-sm font-medium text-zinc-400"
|
||||||
|
>{{ $t("news.article.shortDesc") }}</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="excerpt"
|
id="excerpt"
|
||||||
@ -47,8 +49,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="content" class="block text-sm font-medium text-zinc-400"
|
<label
|
||||||
>Content (Markdown)</label
|
for="content"
|
||||||
|
class="block text-sm font-medium text-zinc-400"
|
||||||
|
>{{ $t("news.article.content") }}</label
|
||||||
>
|
>
|
||||||
<div class="mt-1 flex flex-col gap-4">
|
<div class="mt-1 flex flex-col gap-4">
|
||||||
<!-- Markdown shortcuts -->
|
<!-- Markdown shortcuts -->
|
||||||
@ -69,7 +73,9 @@
|
|||||||
>
|
>
|
||||||
<!-- Editor -->
|
<!-- Editor -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-sm text-zinc-500 mb-2">Editor</span>
|
<span class="text-sm text-zinc-500 mb-2">{{
|
||||||
|
$t("news.article.editor")
|
||||||
|
}}</span>
|
||||||
<textarea
|
<textarea
|
||||||
id="content"
|
id="content"
|
||||||
ref="contentEditor"
|
ref="contentEditor"
|
||||||
@ -82,7 +88,9 @@
|
|||||||
|
|
||||||
<!-- Preview -->
|
<!-- Preview -->
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="text-sm text-zinc-500 mb-2">Preview</span>
|
<span class="text-sm text-zinc-500 mb-2">{{
|
||||||
|
$t("news.article.preview")
|
||||||
|
}}</span>
|
||||||
<div
|
<div
|
||||||
class="flex-1 p-4 rounded-md bg-zinc-900 border border-zinc-700 overflow-y-auto"
|
class="flex-1 p-4 rounded-md bg-zinc-900 border border-zinc-700 overflow-y-auto"
|
||||||
>
|
>
|
||||||
@ -95,8 +103,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-zinc-500">
|
<p class="mt-2 text-sm text-zinc-500">
|
||||||
Use the shortcuts above or write Markdown directly. Supports
|
{{ $t("news.article.editorGuide") }}
|
||||||
**bold**, *italic*, [links](url), and more.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -114,7 +121,7 @@
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
||||||
>Upload cover image</span
|
>{{ $t("news.article.uploadCover") }}</span
|
||||||
>
|
>
|
||||||
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
|
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
|
||||||
{{ currentFile.name }}
|
{{ currentFile.name }}
|
||||||
@ -130,9 +137,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-zinc-400 mb-2"
|
<label class="block text-sm font-medium text-zinc-400 mb-2">{{
|
||||||
>Tags</label
|
$t("common.tags")
|
||||||
>
|
}}</label>
|
||||||
<div class="flex flex-wrap gap-2 mb-2">
|
<div class="flex flex-wrap gap-2 mb-2">
|
||||||
<span
|
<span
|
||||||
v-for="tag in newArticle.tags"
|
v-for="tag in newArticle.tags"
|
||||||
@ -153,7 +160,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="newTagInput"
|
v-model="newTagInput"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Add a tag..."
|
:placeholder="$t('news.article.tagPlaceholder')"
|
||||||
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||||
@keydown.enter.prevent="addTag"
|
@keydown.enter.prevent="addTag"
|
||||||
/>
|
/>
|
||||||
@ -162,7 +169,7 @@
|
|||||||
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||||
@click="addTag"
|
@click="addTag"
|
||||||
>
|
>
|
||||||
Add
|
{{ $t("news.article.add") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -188,13 +195,13 @@
|
|||||||
class="bg-blue-600 text-white hover:bg-blue-500"
|
class="bg-blue-600 text-white hover:bg-blue-500"
|
||||||
@click="() => createArticle()"
|
@click="() => createArticle()"
|
||||||
>
|
>
|
||||||
Submit
|
{{ $t("news.article.submit") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||||
@click="() => (modalOpen = !modalOpen)"
|
@click="() => (modalOpen = !modalOpen)"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ $t("cancel") }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
@ -236,18 +243,49 @@ const markdownPreview = computed(() => {
|
|||||||
|
|
||||||
const file = ref<FileList | undefined>();
|
const file = ref<FileList | undefined>();
|
||||||
const currentFile = computed(() => file.value?.item(0));
|
const currentFile = computed(() => file.value?.item(0));
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const error = ref<string | undefined>();
|
const error = ref<string | undefined>();
|
||||||
|
|
||||||
const contentEditor = ref<HTMLTextAreaElement>();
|
const contentEditor = ref<HTMLTextAreaElement>();
|
||||||
|
|
||||||
const markdownShortcuts = [
|
const markdownShortcuts = [
|
||||||
{ label: "Bold", prefix: "**", suffix: "**", placeholder: "bold text" },
|
{
|
||||||
{ label: "Italic", prefix: "_", suffix: "_", placeholder: "italic text" },
|
label: t("editor.bold"),
|
||||||
{ label: "Link", prefix: "[", suffix: "](url)", placeholder: "link text" },
|
prefix: "**",
|
||||||
{ label: "Code", prefix: "`", suffix: "`", placeholder: "code" },
|
suffix: "**",
|
||||||
{ label: "List Item", prefix: "- ", suffix: "", placeholder: "list item" },
|
placeholder: t("editor.boldPlaceholder"),
|
||||||
{ label: "Heading", prefix: "## ", suffix: "", placeholder: "heading" },
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.italic"),
|
||||||
|
prefix: "_",
|
||||||
|
suffix: "_",
|
||||||
|
placeholder: t("editor.italicPlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.link"),
|
||||||
|
prefix: "[",
|
||||||
|
suffix: "](url)",
|
||||||
|
placeholder: t("editor.linkPlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.code"),
|
||||||
|
prefix: "`",
|
||||||
|
suffix: "`",
|
||||||
|
placeholder: t("editor.codePlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.listItem"),
|
||||||
|
prefix: "- ",
|
||||||
|
suffix: "",
|
||||||
|
placeholder: t("editor.listItemPlaceholder"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("editor.heading"),
|
||||||
|
prefix: "## ",
|
||||||
|
suffix: "",
|
||||||
|
placeholder: t("editor.headingPlaceholder"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function handleContentKeydown(e: KeyboardEvent) {
|
function handleContentKeydown(e: KeyboardEvent) {
|
||||||
@ -369,7 +407,7 @@ async function createArticle() {
|
|||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// @ts-expect-error attempt to get statusMessage on error
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
error.value = e?.statusMessage ?? "An unknown error occured.";
|
error.value = e?.statusMessage ?? t("errors.unknown");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<!-- Search and filters -->
|
<!-- Search and filters -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label for="search" class="sr-only">Search articles</label>
|
<label for="search" class="sr-only">{{ $t("news.search") }}</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
|
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
|
||||||
@ -21,31 +21,35 @@
|
|||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||||
placeholder="Search articles..."
|
:placeholder="$t('news.searchPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<label for="date" class="block text-sm font-medium text-zinc-400 mb-2"
|
<label
|
||||||
>Date</label
|
for="date"
|
||||||
|
class="block text-sm font-medium text-zinc-400 mb-2"
|
||||||
|
>{{ $t("common.date") }}</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
id="date"
|
id="date"
|
||||||
v-model="dateFilter"
|
v-model="dateFilter"
|
||||||
class="mt-1 block w-full rounded-md border-0 bg-zinc-800 py-2 pl-3 pr-10 text-zinc-100 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
class="mt-1 block w-full rounded-md border-0 bg-zinc-800 py-2 pl-3 pr-10 text-zinc-100 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
|
||||||
>
|
>
|
||||||
<option value="all">All time</option>
|
<option value="all">{{ $t("news.filter.all") }}</option>
|
||||||
<option value="today">Today</option>
|
<option value="today">{{ $t("common.today") }}</option>
|
||||||
<option value="week">This week</option>
|
<option value="week">{{ $t("news.filter.week") }}</option>
|
||||||
<option value="month">This month</option>
|
<option value="month">{{ $t("news.filter.month") }}</option>
|
||||||
<option value="year">This year</option>
|
<option value="year">{{ $t("news.filter.year") }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-zinc-400 mb-2">Tags</label>
|
<label class="block text-sm font-medium text-zinc-400 mb-2">
|
||||||
|
{{ $t("common.tags") }}
|
||||||
|
</label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="tag in availableTags"
|
v-for="tag in availableTags"
|
||||||
@ -102,9 +106,9 @@
|
|||||||
<div
|
<div
|
||||||
class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"
|
class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"
|
||||||
>
|
>
|
||||||
<time :datetime="article.publishedAt">{{
|
<time :datetime="article.publishedAt">
|
||||||
formatDate(article.publishedAt)
|
{{ $d(new Date(article.publishedAt), "short") }}
|
||||||
}}</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@ -146,14 +150,6 @@ const toggleTag = (tag: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatExcerpt = (excerpt: string) => {
|
const formatExcerpt = (excerpt: string) => {
|
||||||
// TODO: same as one in NewsArticleCreateButton
|
// TODO: same as one in NewsArticleCreateButton
|
||||||
// Convert markdown to HTML
|
// Convert markdown to HTML
|
||||||
|
|||||||
@ -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"
|
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()"
|
@click="() => deleteMe()"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">{{ $t("close") }}</span>
|
||||||
<XMarkIcon class="size-5" aria-hidden="true" />
|
<XMarkIcon class="size-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
/>
|
/>
|
||||||
<span class="ml-3 block truncate">{{ model }}</span>
|
<span class="ml-3 block truncate">{{ model }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else>Please select a platform...</span>
|
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2"
|
class="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2"
|
||||||
>
|
>
|
||||||
|
|||||||
32
components/RelativeTime.vue
Normal file
32
components/RelativeTime.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<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 }) }}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
<!-- Custom tooltip that shows on hover -->
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 rounded bg-zinc-900 text-white text-xs whitespace-nowrap shadow z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{{ $d(date, "long") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
date: string | Date;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const date = computed(() =>
|
||||||
|
typeof props.date === "string" ? new Date(props.date) : props.date,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isoDate = computed(() => date.value.toISOString());
|
||||||
|
</script>
|
||||||
@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<label for="path" class="block text-sm font-medium leading-6 text-zinc-100"
|
<label
|
||||||
>Path</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">
|
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||||
An absolute path to your game library.
|
{{ $t("library.admin.sources.fsPathDesc") }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<input
|
<input
|
||||||
@ -13,7 +15,7 @@
|
|||||||
name="path"
|
name="path"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="path"
|
autocomplete="path"
|
||||||
placeholder="/mnt/games"
|
: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"
|
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>
|
||||||
|
|||||||
@ -49,7 +49,7 @@
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
|
||||||
>Upload file</span
|
>{{ $t("uploadFile") }}</span
|
||||||
>
|
>
|
||||||
<div v-if="currentFileList">
|
<div v-if="currentFileList">
|
||||||
<p
|
<p
|
||||||
@ -80,7 +80,7 @@
|
|||||||
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
|
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
|
||||||
@click="() => uploadFile_wrapper()"
|
@click="() => uploadFile_wrapper()"
|
||||||
>
|
>
|
||||||
Upload
|
{{ $t("upload") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
ref="cancelButtonRef"
|
ref="cancelButtonRef"
|
||||||
@ -88,7 +88,7 @@
|
|||||||
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||||
@click="open = false"
|
@click="open = false"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ $t("cancel") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="uploadError" class="mt-3 rounded-md bg-red-600/10 p-4">
|
<div v-if="uploadError" class="mt-3 rounded-md bg-red-600/10 p-4">
|
||||||
@ -129,6 +129,7 @@ const open = defineModel<boolean>({
|
|||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const file = ref<FileList | undefined>();
|
const file = ref<FileList | undefined>();
|
||||||
const currentFiles = computed(() => file.value);
|
const currentFiles = computed(() => file.value);
|
||||||
const currentFileList = computed(() => {
|
const currentFileList = computed(() => {
|
||||||
@ -176,7 +177,7 @@ function uploadFile_wrapper() {
|
|||||||
uploadLoading.value = true;
|
uploadLoading.value = true;
|
||||||
uploadFile()
|
uploadFile()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
uploadError.value = error.statusMessage ?? "An unknown error occurred.";
|
uploadError.value = error.statusMessage ?? t("errors.unknown");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
uploadLoading.value = false;
|
uploadLoading.value = false;
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
|
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
|
||||||
<h2 id="footer-heading" class="sr-only">Footer</h2>
|
<h2 id="footer-heading" class="sr-only">{{ $t("footer.footer") }}</h2>
|
||||||
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
|
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
|
||||||
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
|
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<DropWordmark class="h-10" />
|
<DropWordmark class="h-10" />
|
||||||
<p class="text-sm leading-6 text-zinc-300">
|
<p class="text-sm leading-6 text-zinc-300">
|
||||||
An open-source game distribution platform built for speed,
|
{{ $t("drop.desc") }}
|
||||||
flexibility and beauty.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="flex space-x-6">
|
<div class="flex space-x-6">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@ -25,7 +24,9 @@
|
|||||||
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
||||||
<div class="md:grid md:grid-cols-2 md:gap-8">
|
<div class="md:grid md:grid-cols-2 md:gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
|
{{ $t("footer.games") }}
|
||||||
|
</h3>
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
<li v-for="item in navigation.games" :key="item.name">
|
<li v-for="item in navigation.games" :key="item.name">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@ -38,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-10 md:mt-0">
|
<div class="mt-10 md:mt-0">
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
Community
|
{{ $t("userHeader.links.community") }}
|
||||||
</h3>
|
</h3>
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
<li v-for="item in navigation.community" :key="item.name">
|
<li v-for="item in navigation.community" :key="item.name">
|
||||||
@ -54,7 +55,7 @@
|
|||||||
<div class="md:grid md:grid-cols-2 md:gap-8">
|
<div class="md:grid md:grid-cols-2 md:gap-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
Documentation
|
{{ $t("footer.documentation") }}
|
||||||
</h3>
|
</h3>
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
<li v-for="item in navigation.documentation" :key="item.name">
|
<li v-for="item in navigation.documentation" :key="item.name">
|
||||||
@ -67,7 +68,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-10 md:mt-0">
|
<div class="mt-10 md:mt-0">
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
|
{{ $t("footer.about") }}
|
||||||
|
</h3>
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
<li v-for="item in navigation.about" :key="item.name">
|
<li v-for="item in navigation.about" :key="item.name">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@ -87,43 +90,44 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
|
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const navigation = {
|
const navigation = {
|
||||||
games: [
|
games: [
|
||||||
{ name: "Newly Added", href: "#" },
|
{ name: t("store.recentlyAdded"), href: "#" },
|
||||||
{ name: "New Releases", href: "#" },
|
{ name: t("store.recentlyReleased"), href: "#" },
|
||||||
{ name: "Top Sellers", href: "#" },
|
{ name: t("footer.topSellers"), href: "#" },
|
||||||
{ name: "Find a Game", href: "#" },
|
{ name: t("footer.findGame"), href: "#" },
|
||||||
],
|
],
|
||||||
community: [
|
community: [
|
||||||
{ name: "Friends", href: "#" },
|
{ name: t("common.friends"), href: "#" },
|
||||||
{ name: "Groups", href: "#" },
|
{ name: t("common.groups"), href: "#" },
|
||||||
{ name: "Servers", href: "#" },
|
{ name: t("common.servers"), href: "#" },
|
||||||
],
|
],
|
||||||
documentation: [
|
documentation: [
|
||||||
{ name: "API", href: "https://api.droposs.org/" },
|
{ name: t("footer.api"), href: "https://api.droposs.org/" },
|
||||||
{
|
{
|
||||||
name: "Server Docs",
|
name: t("footer.docs.server"),
|
||||||
href: "https://wiki.droposs.org/guides/quickstart.html",
|
href: "https://wiki.droposs.org/guides/quickstart.html",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Client Docs",
|
name: t("footer.docs.client"),
|
||||||
href: "https://wiki.droposs.org/guides/client.html",
|
href: "https://wiki.droposs.org/guides/client.html",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
about: [
|
about: [
|
||||||
{ name: "About Drop", href: "https://droposs.org/" },
|
{ name: t("footer.aboutDrop"), href: "https://droposs.org/" },
|
||||||
{ name: "Features", href: "https://droposs.org/features" },
|
{ name: t("footer.features"), href: "https://droposs.org/features" },
|
||||||
{ name: "FAQ", href: "https://droposs.org/faq" },
|
{ name: t("footer.faq"), href: "https://droposs.org/faq" },
|
||||||
],
|
],
|
||||||
social: [
|
social: [
|
||||||
{
|
{
|
||||||
name: "GitHub",
|
name: t("footer.social.github"),
|
||||||
href: "https://github.com/Drop-OSS",
|
href: "https://github.com/Drop-OSS",
|
||||||
icon: IconsGithubLogo,
|
icon: IconsGithubLogo,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Discord",
|
name: t("footer.social.discord"),
|
||||||
href: "https://discord.gg/NHx46XKJWA",
|
href: "https://discord.gg/NHx46XKJWA",
|
||||||
icon: IconsDiscordLogo,
|
icon: IconsDiscordLogo,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -54,6 +54,7 @@
|
|||||||
</transition>
|
</transition>
|
||||||
</Menu>
|
</Menu>
|
||||||
</li>
|
</li>
|
||||||
|
<UserHeaderSelectLang />
|
||||||
<UserHeaderUserWidget />
|
<UserHeaderUserWidget />
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
@ -76,7 +77,7 @@
|
|||||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||||
@click="sidebarOpen = true"
|
@click="sidebarOpen = true"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Open sidebar</span>
|
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
|
||||||
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
|
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -125,7 +126,9 @@
|
|||||||
class="-m-2.5 p-2.5"
|
class="-m-2.5 p-2.5"
|
||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Close sidebar</span>
|
<span class="sr-only">{{
|
||||||
|
$t("userHeader.closeSidebar")
|
||||||
|
}}</span>
|
||||||
<XMarkIcon class="h-6 w-6 text-zinc-400" aria-hidden="true" />
|
<XMarkIcon class="h-6 w-6 text-zinc-400" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -172,6 +175,11 @@
|
|||||||
<BellIcon class="h-5" />
|
<BellIcon class="h-5" />
|
||||||
</UserHeaderWidget>
|
</UserHeaderWidget>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="w-full">
|
||||||
|
<UserHeaderWidget class="w-full">
|
||||||
|
<UserHeaderSelectLang />
|
||||||
|
</UserHeaderWidget>
|
||||||
|
</li>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -198,28 +206,29 @@ import { Bars3Icon } from "@heroicons/vue/24/outline";
|
|||||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const homepageURL = "/store";
|
const homepageURL = "/store";
|
||||||
const navigation: Array<NavigationItem> = [
|
const navigation: Array<NavigationItem> = [
|
||||||
{
|
{
|
||||||
prefix: "/store",
|
prefix: "/store",
|
||||||
route: "/store",
|
route: "/store",
|
||||||
label: "Store",
|
label: t("store.title"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prefix: "/library",
|
prefix: "/library",
|
||||||
route: "/library",
|
route: "/library",
|
||||||
label: "Library",
|
label: t("userHeader.links.library"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prefix: "/community",
|
prefix: "/community",
|
||||||
route: "/community",
|
route: "/community",
|
||||||
label: "Community",
|
label: t("userHeader.links.community"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prefix: "/news",
|
prefix: "/news",
|
||||||
route: "/news",
|
route: "/news",
|
||||||
label: "News",
|
label: t("userHeader.links.news"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
>
|
>
|
||||||
<div class="ml-4 mt-2">
|
<div class="ml-4 mt-2">
|
||||||
<h3 class="text-base font-semibold text-zinc-100 text-sm">
|
<h3 class="text-base font-semibold text-zinc-100 text-sm">
|
||||||
Unread notifications
|
{{ $t("account.notifications.unread") }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4 mt-2 shrink-0">
|
<div class="ml-4 mt-2 shrink-0">
|
||||||
@ -15,7 +15,15 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="text-sm text-zinc-400"
|
class="text-sm text-zinc-400"
|
||||||
>
|
>
|
||||||
View all →
|
<i18n-t
|
||||||
|
keypath="account.notifications.all"
|
||||||
|
tag="span"
|
||||||
|
scope="global"
|
||||||
|
>
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -32,7 +40,7 @@
|
|||||||
v-if="props.notifications.length == 0"
|
v-if="props.notifications.length == 0"
|
||||||
class="text-sm text-zinc-400 p-3 text-center w-full"
|
class="text-sm text-zinc-400 p-3 text-center w-full"
|
||||||
>
|
>
|
||||||
No notifications
|
{{ $t("account.notifications.none") }}
|
||||||
</div>
|
</div>
|
||||||
</PanelWidget>
|
</PanelWidget>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
78
components/UserHeader/SelectLang.vue
Normal file
78
components/UserHeader/SelectLang.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
|
||||||
|
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
|
||||||
|
|
||||||
|
const { locales, locale: currLocale, setLocale } = useI18n();
|
||||||
|
|
||||||
|
function localToEmoji(local: string): string {
|
||||||
|
switch (local) {
|
||||||
|
case "en":
|
||||||
|
case "en-gb":
|
||||||
|
case "en-ca":
|
||||||
|
case "en-au":
|
||||||
|
case "en-us": {
|
||||||
|
return "🇺🇸";
|
||||||
|
}
|
||||||
|
case "en-pirate": {
|
||||||
|
return "🏴☠️";
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return "❓";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Menu as="div" class="relative inline-block">
|
||||||
|
<MenuButton>
|
||||||
|
<UserHeaderWidget>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center text-zinc-300 hover:text-white h-5"
|
||||||
|
>
|
||||||
|
<EmojiText :emoji="localToEmoji(currLocale)" />
|
||||||
|
<!-- <span class="ml-2 text-sm font-bold">{{ locale }}</span> -->
|
||||||
|
<ChevronDownIcon class="ml-3 h-4" />
|
||||||
|
</div>
|
||||||
|
</UserHeaderWidget>
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<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 top-10 z-50 w-56 origin-top-right focus:outline-none shadow-md"
|
||||||
|
>
|
||||||
|
<PanelWidget class="flex-col gap-y-2">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MenuItem
|
||||||
|
v-for="locale in locales"
|
||||||
|
:key="locale.code"
|
||||||
|
hydrate-on-visible
|
||||||
|
as="div"
|
||||||
|
>
|
||||||
|
<button @click="setLocale(locale.code)">
|
||||||
|
<EmojiText :emoji="localToEmoji(locale.code)" />
|
||||||
|
{{ locale.name }}
|
||||||
|
</button>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
</PanelWidget>
|
||||||
|
</MenuItems>
|
||||||
|
</transition>
|
||||||
|
</Menu>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
img.emoji {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
margin: 0 0.05em 0 0.1em;
|
||||||
|
vertical-align: -0.1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -46,29 +46,28 @@
|
|||||||
hydrate-on-visible
|
hydrate-on-visible
|
||||||
as="div"
|
as="div"
|
||||||
>
|
>
|
||||||
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
|
<NuxtLink
|
||||||
<button
|
:to="nav.route"
|
||||||
:href="nav.route"
|
|
||||||
:class="[
|
:class="[
|
||||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
||||||
'w-full text-left transition block px-4 py-2 text-sm',
|
'w-full text-left transition block px-4 py-2 text-sm',
|
||||||
]"
|
]"
|
||||||
@click="() => navigateTo(nav.route, close)"
|
@click="close"
|
||||||
>
|
>
|
||||||
{{ nav.label }}
|
{{ nav.label }}
|
||||||
</button>
|
</NuxtLink>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem v-slot="{ active }" hydrate-on-visible as="div">
|
<MenuItem v-slot="{ active, close }" hydrate-on-visible as="div">
|
||||||
<!-- TODO: think this would work better as a NuxtLink instead of a button -->
|
<NuxtLink
|
||||||
<a
|
to="/auth/signout"
|
||||||
:class="[
|
:class="[
|
||||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
|
||||||
'w-full text-left transition block px-4 py-2 text-sm',
|
'w-full text-left transition block px-4 py-2 text-sm',
|
||||||
]"
|
]"
|
||||||
href="/auth/signout"
|
@click="close"
|
||||||
>
|
>
|
||||||
Signout
|
{{ $t("auth.signout") }}
|
||||||
</a>
|
</NuxtLink>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
</PanelWidget>
|
</PanelWidget>
|
||||||
@ -84,17 +83,18 @@ import { useObject } from "~/composables/objects";
|
|||||||
import type { NavigationItem } from "~/composables/types";
|
import type { NavigationItem } from "~/composables/types";
|
||||||
|
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const navigation: NavigationItem[] = [
|
const navigation: NavigationItem[] = [
|
||||||
user.value?.admin
|
user.value?.admin
|
||||||
? {
|
? {
|
||||||
label: "Admin Dashboard",
|
label: t("userHeader.profile.admin"),
|
||||||
route: "/admin",
|
route: "/admin",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
{
|
{
|
||||||
label: "Account settings",
|
label: t("userHeader.profile.settings"),
|
||||||
route: "/account",
|
route: "/account",
|
||||||
prefix: "",
|
prefix: "",
|
||||||
},
|
},
|
||||||
|
|||||||
31
error.vue
31
error.vue
@ -8,13 +8,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const statusCode = props.error?.statusCode;
|
const statusCode = props.error?.statusCode;
|
||||||
const message =
|
const message =
|
||||||
props.error?.statusMessage ||
|
props.error?.message || props.error?.statusMessage || t("errors.unknown");
|
||||||
props.error?.message ||
|
|
||||||
"An unknown error occurred.";
|
|
||||||
const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
|
const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn() {
|
||||||
@ -24,7 +23,7 @@ async function signIn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: `${statusCode ?? message} | Drop`,
|
title: t("errors.pageTitle", [statusCode ?? message]),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
@ -51,7 +50,7 @@ if (import.meta.client) {
|
|||||||
<h1
|
<h1
|
||||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||||
>
|
>
|
||||||
Oh no!
|
{{ $t("errors.ohNo") }}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
v-if="message"
|
v-if="message"
|
||||||
@ -60,24 +59,32 @@ if (import.meta.client) {
|
|||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||||
An error occurred while responding to your request. If you believe
|
{{ $t("errors.occurred") }}
|
||||||
this to be a bug, please report it. Try signing in and see if it
|
|
||||||
resolves the issue.
|
|
||||||
</p>
|
</p>
|
||||||
|
<!-- <p>{{ error. }}</p> -->
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<!-- full app reload to fix errors -->
|
<!-- full app reload to fix errors -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="user && !showSignIn"
|
v-if="user && !showSignIn"
|
||||||
to="/"
|
to="/"
|
||||||
class="text-sm font-semibold leading-7 text-blue-600"
|
class="text-sm font-semibold leading-7 text-blue-600"
|
||||||
><span aria-hidden="true">←</span> Back to home</NuxtLink
|
|
||||||
>
|
>
|
||||||
|
<i18n-t keypath="errors.backHome" tag="span" scope="global">
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="text-sm font-semibold leading-7 text-blue-600"
|
class="text-sm font-semibold leading-7 text-blue-600"
|
||||||
@click="signIn"
|
@click="signIn"
|
||||||
>
|
>
|
||||||
Sign in <span aria-hidden="true">→</span>
|
<i18n-t keypath="errors.signIn" tag="span" scope="global">
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -87,7 +94,7 @@ if (import.meta.client) {
|
|||||||
<nav
|
<nav
|
||||||
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
|
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
|
||||||
>
|
>
|
||||||
<NuxtLink href="/docs">Documentation</NuxtLink>
|
<NuxtLink href="/docs">{{ $t("footer.documentation") }}</NuxtLink>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 2 2"
|
viewBox="0 0 2 2"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -96,7 +103,7 @@ if (import.meta.client) {
|
|||||||
<circle cx="1" cy="1" r="1" />
|
<circle cx="1" cy="1" r="1" />
|
||||||
</svg>
|
</svg>
|
||||||
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
|
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
|
||||||
Support Discord
|
{{ $t("errors.support") }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,32 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import withNuxt from "./.nuxt/eslint.config.mjs";
|
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||||
|
import vueI18n from "@intlify/eslint-plugin-vue-i18n";
|
||||||
|
|
||||||
export default withNuxt([eslintConfigPrettier]);
|
export default withNuxt([
|
||||||
|
eslintConfigPrettier,
|
||||||
|
|
||||||
|
// vue-i18n plugin
|
||||||
|
...vueI18n.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Optional.
|
||||||
|
"@intlify/vue-i18n/no-dynamic-keys": "error",
|
||||||
|
"@intlify/vue-i18n/no-unused-keys": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
extensions: [".js", ".vue", ".ts"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
"vue-i18n": {
|
||||||
|
localeDir: "./i18n/locales/*.{json,json5,ts,js}", // extension is glob formatting!
|
||||||
|
|
||||||
|
// Specify the version of `vue-i18n` you are using.
|
||||||
|
// If not specified, the message will be parsed twice.
|
||||||
|
messageSyntaxVersion: "^11.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
27
i18n/i18n.config.ts
Normal file
27
i18n/i18n.config.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export default defineI18nConfig(() => {
|
||||||
|
const defaultDateTimeFormat = {
|
||||||
|
short: {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
long: {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
weekday: "short",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// https://i18n.nuxtjs.org/docs/guide/locale-fallback
|
||||||
|
fallbackLocale: "en-us",
|
||||||
|
// https://vue-i18n.intlify.dev/guide/essentials/datetime.html
|
||||||
|
datetimeFormats: {
|
||||||
|
"en-us": defaultDateTimeFormat,
|
||||||
|
"en-pirate": defaultDateTimeFormat,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
25
i18n/localeDetector.ts
Normal file
25
i18n/localeDetector.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// https://i18n.nuxtjs.org/docs/guide/server-side-translations
|
||||||
|
|
||||||
|
// Detect based on query, cookie, header
|
||||||
|
export default defineI18nLocaleDetector((event, config) => {
|
||||||
|
// try to get locale from query
|
||||||
|
const query = tryQueryLocale(event, { lang: "" }); // disable locale default value with `lang` option
|
||||||
|
if (query) {
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to get locale from cookie
|
||||||
|
const cookie = tryCookieLocale(event, { lang: "", name: "i18n_redirected" }); // disable locale default value with `lang` option
|
||||||
|
if (cookie) {
|
||||||
|
return cookie.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to get locale from header (`accept-header`)
|
||||||
|
const header = tryHeaderLocale(event, { lang: "" }); // disable locale default value with `lang` option
|
||||||
|
if (header) {
|
||||||
|
return header.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the locale cannot be resolved up to this point, it is resolved with the value `defaultLocale` of the locale config passed to the function
|
||||||
|
return config.defaultLocale;
|
||||||
|
});
|
||||||
3
i18n/locales/en_pirate.json
Normal file
3
i18n/locales/en_pirate.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"welcome": "Arr matey, Welcome!"
|
||||||
|
}
|
||||||
351
i18n/locales/en_us.json
Normal file
351
i18n/locales/en_us.json
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
{
|
||||||
|
"account": {
|
||||||
|
"devices": {
|
||||||
|
"capabilities": "Capabilities",
|
||||||
|
"lastConnected": "Last Connected",
|
||||||
|
"noDevices": "No devices connected to your account.",
|
||||||
|
"platform": "Platform",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"subheader": "Manage the devices authorized to access your Drop account.",
|
||||||
|
"title": "Devices"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"all": "View all {arrow}",
|
||||||
|
"desc": "View and manage your notifications.",
|
||||||
|
"markAllAsRead": "Mark all as read",
|
||||||
|
"markAsRead": "Mark as read",
|
||||||
|
"none": "No notifications",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"title": "Notifications",
|
||||||
|
"unread": "Unread Notifications"
|
||||||
|
},
|
||||||
|
"settings": "Account Settings",
|
||||||
|
"title": "Account"
|
||||||
|
},
|
||||||
|
"actions": "Actions",
|
||||||
|
"adminTitle": "Admin Dashboard | Drop",
|
||||||
|
"adminTitleTemplate": "{0} | Admin | Drop",
|
||||||
|
"auth": {
|
||||||
|
"callback": {
|
||||||
|
"authClient": "Authorize client?",
|
||||||
|
"authorize": "Authorize",
|
||||||
|
"authorizedClient": "Drop has successfully authorized the client. You may now close this window.",
|
||||||
|
"issues": "Having issues?",
|
||||||
|
"learn": "Learn more {arrow}",
|
||||||
|
"paste": "Paste this code into the client to continue:",
|
||||||
|
"permWarning": "Accepting this request will allow \"{name}\" on \"{platform}\" to:",
|
||||||
|
"requestedAccess": "\"{name}\" has requested access to your Drop account.",
|
||||||
|
"success": "Successful!"
|
||||||
|
},
|
||||||
|
"confirmPassword": "Confirm @:auth.password",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"register": {
|
||||||
|
"confirmPasswordFormat": "Must be the same as above",
|
||||||
|
"emailFormat": "Must be in the format user{'@'}example.com",
|
||||||
|
"passwordFormat": "Must be 14 or more characters",
|
||||||
|
"subheader": "Fill in your details below to create your account.",
|
||||||
|
"title": "Create your Drop account",
|
||||||
|
"usernameFormat": "Must be 5 or more characters, and lowercase"
|
||||||
|
},
|
||||||
|
"signin": {
|
||||||
|
"externalProvider": "Sign in with external provider {arrow}",
|
||||||
|
"forgot": "Forgot password?",
|
||||||
|
"noAccount": "Don't have an account? Ask an admin to create one for you.",
|
||||||
|
"or": "OR",
|
||||||
|
"pageTitle": "Sign in to Drop",
|
||||||
|
"rememberMe": "Remember me",
|
||||||
|
"signin": "Sign in",
|
||||||
|
"title": "Sign in to your account"
|
||||||
|
},
|
||||||
|
"signout": "Signout",
|
||||||
|
"username": "Username"
|
||||||
|
},
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"chars": {
|
||||||
|
"arrow": "→",
|
||||||
|
"arrowBack": "←",
|
||||||
|
"quoted": "\"\"",
|
||||||
|
"srComma": ", {0}"
|
||||||
|
},
|
||||||
|
"close": "Close",
|
||||||
|
"common": {
|
||||||
|
"cannotUndo": "This action cannot be undone.",
|
||||||
|
"date": "Date",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete \"{0}\"?",
|
||||||
|
"friends": "Friends",
|
||||||
|
"groups": "Groups",
|
||||||
|
"noResults": "No results",
|
||||||
|
"servers": "Servers",
|
||||||
|
"tags": "Tags",
|
||||||
|
"today": "Today"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"code": "Code",
|
||||||
|
"codePlaceholder": "code",
|
||||||
|
"heading": "Heading",
|
||||||
|
"headingPlaceholder": "heading",
|
||||||
|
"italic": "Italic",
|
||||||
|
"italicPlaceholder": "italic text",
|
||||||
|
"link": "Link",
|
||||||
|
"linkPlaceholder": "link text",
|
||||||
|
"listItem": "List Item",
|
||||||
|
"listItemPlaceholder": "list item"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"backHome": "{arrow} Back to home",
|
||||||
|
"invalidBody": "Invalid request body: {0}",
|
||||||
|
"inviteRequired": "Invitation required to sign up.",
|
||||||
|
"library": {
|
||||||
|
"add": {
|
||||||
|
"desc": "Drop couldn't add this game to your library: {0}",
|
||||||
|
"title": "Failed to add game to library"
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"create": {
|
||||||
|
"desc": "Drop couldn't create your collection: {0}",
|
||||||
|
"title": "Failed to create collection"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"delete": {
|
||||||
|
"desc": "Drop couldn't add delete this source: {0}",
|
||||||
|
"title": "Failed to delete library source"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"news": {
|
||||||
|
"article": {
|
||||||
|
"delete": {
|
||||||
|
"desc": "Drop couldn't delete this article: {0}",
|
||||||
|
"title": "Failed to delete article"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"occurred": "An error occurred while responding to your request. If you believe this to be a bug, please report it. Try signing in and see if it resolves the issue.",
|
||||||
|
"ohNo": "Oh no!",
|
||||||
|
"pageTitle": "{0} | Drop",
|
||||||
|
"revokeClient": "Failed to revoke client",
|
||||||
|
"revokeClientFull": "Failed to revoke client {0}",
|
||||||
|
"signIn": "Sign in {arrow}",
|
||||||
|
"support": "Support Discord",
|
||||||
|
"unknown": "An unknown error occurred",
|
||||||
|
"version": {
|
||||||
|
"delete": {
|
||||||
|
"desc": "Drop encountered an error while deleting the version: {error}",
|
||||||
|
"title": "There an error while deleting the version"
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"desc": "Drop encountered an error while updating the version: {error}",
|
||||||
|
"title": "There an error while updating the version order"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"about": "About",
|
||||||
|
"aboutDrop": "About Drop",
|
||||||
|
"api": "API",
|
||||||
|
"docs": {
|
||||||
|
"client": "Client Docs",
|
||||||
|
"server": "Server Docs"
|
||||||
|
},
|
||||||
|
"documentation": "Documentation",
|
||||||
|
"faq": "FAQ",
|
||||||
|
"features": "Features",
|
||||||
|
"findGame": "Find a Game",
|
||||||
|
"footer": "Footer",
|
||||||
|
"games": "Games",
|
||||||
|
"social": {
|
||||||
|
"discord": "Discord",
|
||||||
|
"github": "GitHub"
|
||||||
|
},
|
||||||
|
"topSellers": "Top Sellers"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"admin": {
|
||||||
|
"admin": "Admin",
|
||||||
|
"meta": "Meta",
|
||||||
|
"tasks": "Tasks",
|
||||||
|
"users": "Users"
|
||||||
|
},
|
||||||
|
"back": "Back",
|
||||||
|
"openSidebar": "Open sidebar"
|
||||||
|
},
|
||||||
|
"highest": "highest",
|
||||||
|
"home": "Home",
|
||||||
|
"library": {
|
||||||
|
"addGames": "All Games",
|
||||||
|
"addToLib": "Add to Library",
|
||||||
|
"admin": {
|
||||||
|
"detectedGame": "Drop has detected you have new games to import.",
|
||||||
|
"detectedVersion": "Drop has detected you have new verions of this game to import.",
|
||||||
|
"gameLibrary": "Game Library",
|
||||||
|
"import": {
|
||||||
|
"import": "Import",
|
||||||
|
"link": "Import {arrow}",
|
||||||
|
"loading": "Loading game results...",
|
||||||
|
"search": "Search",
|
||||||
|
"searchPlaceholder": "Fallout 4",
|
||||||
|
"selectDir": "Please select a directory...",
|
||||||
|
"selectGame": "Select game to import",
|
||||||
|
"selectGamePlaceholder": "Please select a game...",
|
||||||
|
"selectGameSearch": "Select game",
|
||||||
|
"selectPlatform": "Please select a platform...",
|
||||||
|
"version": {
|
||||||
|
"advancedOptions": "Advanced options",
|
||||||
|
"import": "Import version",
|
||||||
|
"installDir": "(install_dir)/",
|
||||||
|
"launchCmd": "Launch executable/command",
|
||||||
|
"launchDesc": "Executable to launch the game",
|
||||||
|
"launchPlaceholder": "game.exe",
|
||||||
|
"loadingVersion": "Loading version metadata...",
|
||||||
|
"noAdv": "No advanced options for this configuration.",
|
||||||
|
"noVersions": "No versions to import",
|
||||||
|
"platform": "Version platform",
|
||||||
|
"setupCmd": "Setup executable/command",
|
||||||
|
"setupDesc": "Ran once when the game is installed",
|
||||||
|
"setupMode": "Setup mode",
|
||||||
|
"setupModeDesc": "When enabled, this version does not have a launch command, and simply runs the executable on the user's computer. Useful for games that only distribute installer and not portable files.",
|
||||||
|
"setupPlaceholder": "setup.exe",
|
||||||
|
"umuLauncherId": "UMU Launcher ID",
|
||||||
|
"umuOverride": "Override UMU Launcher Game ID",
|
||||||
|
"umuOverrideDesc": "By default, Drop uses a non-ID when launching with UMU Launcher. In order to get the right patches for some games, you may have to manually set this field.",
|
||||||
|
"updateMode": "Update mode",
|
||||||
|
"updateModeDesc": "When enabled, these files will be installed on top of (overwriting) the previous version's. If multiple \"update modes\" are chained together, they are applied in order.",
|
||||||
|
"version": "Select version to import"
|
||||||
|
},
|
||||||
|
"withoutMetadata": "Import without metadata"
|
||||||
|
},
|
||||||
|
"metadataProvider": "Metadata provider",
|
||||||
|
"noGames": "No games imported",
|
||||||
|
"noVersions": "You have no versions of this game available.",
|
||||||
|
"noVersionsAdded": "no versions added",
|
||||||
|
"openInMetadata": "Open in Metadata",
|
||||||
|
"openLibrary": "Open with Library {arrow}",
|
||||||
|
"openMetadata": "Open with Metadata {arrow}",
|
||||||
|
"openStore": "Open in Store",
|
||||||
|
"shortDesc": "Short Description",
|
||||||
|
"sources": {
|
||||||
|
"create": "Create source",
|
||||||
|
"createDesc": "Drop will use this source to access your game library, and make them available.",
|
||||||
|
"desc": "Configure your library sources, where Drop will look for new games and versions to import.",
|
||||||
|
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
|
||||||
|
"fsPath": "Path",
|
||||||
|
"fsPathDesc": "An absolute path to your game library.",
|
||||||
|
"fsPathPlaceholder": "/mnt/games",
|
||||||
|
"link": "Sources {arrow}",
|
||||||
|
"nameDesc": "The name of your source, for reference.",
|
||||||
|
"namePlaceholder": "My New Source",
|
||||||
|
"sources": "Library Sources",
|
||||||
|
"typeDesc": "The type of your source. Changes the required options.",
|
||||||
|
"working": "Working?"
|
||||||
|
},
|
||||||
|
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
|
||||||
|
"title": "Libraries",
|
||||||
|
"versionPriority": "Version priority"
|
||||||
|
},
|
||||||
|
"back": "Back to Library",
|
||||||
|
"collection": {
|
||||||
|
"addToNew": "Add to new collection",
|
||||||
|
"collections": "Collections",
|
||||||
|
"create": "Create Collection",
|
||||||
|
"createDesc": "Collections can used to organise your games and find them more easily, especially if you have a large library.",
|
||||||
|
"delete": "Delete Collection",
|
||||||
|
"namePlaceholder": "Collection name",
|
||||||
|
"noCollections": "No collections",
|
||||||
|
"notFound": "Collection not found",
|
||||||
|
"subheader": "Add a new collection to organize your games",
|
||||||
|
"title": "Collection"
|
||||||
|
},
|
||||||
|
"gameCount": "{0} games | {0} game | {0} games",
|
||||||
|
"inLib": "In Library",
|
||||||
|
"launcherOpen": "Open in Launcher",
|
||||||
|
"noGames": "No games in library",
|
||||||
|
"notFound": "Game not found",
|
||||||
|
"search": "Search library...",
|
||||||
|
"subheader": "Organize your games into collections for easy access, and access all your games."
|
||||||
|
},
|
||||||
|
"lowest": "lowest",
|
||||||
|
"name": "Name",
|
||||||
|
"news": {
|
||||||
|
"article": {
|
||||||
|
"add": "Add",
|
||||||
|
"content": "Content (Markdown)",
|
||||||
|
"create": "Create New Article",
|
||||||
|
"editor": "Editor",
|
||||||
|
"editorGuide": "Use the shortcuts above or write Markdown directly. Supports **bold**, *italic*, [links](url), and more.",
|
||||||
|
"new": "New article",
|
||||||
|
"preview": "Preview",
|
||||||
|
"shortDesc": "Short description",
|
||||||
|
"submit": "Submit",
|
||||||
|
"tagPlaceholder": "Add a tag...",
|
||||||
|
"titles": "Title",
|
||||||
|
"uploadCover": "Upload cover image"
|
||||||
|
},
|
||||||
|
"back": "Back to News",
|
||||||
|
"checkLater": "Check back later for updates.",
|
||||||
|
"delete": "Delete Article",
|
||||||
|
"filter": {
|
||||||
|
"all": "All time",
|
||||||
|
"month": "This month",
|
||||||
|
"week": "This week",
|
||||||
|
"year": "This year"
|
||||||
|
},
|
||||||
|
"none": "No articles",
|
||||||
|
"notFound": "Article not found",
|
||||||
|
"search": "Search articles",
|
||||||
|
"searchPlaceholder": "Search articles...",
|
||||||
|
"subheader": "Stay up to date with the latest updates and announcements.",
|
||||||
|
"title": "Latest News"
|
||||||
|
},
|
||||||
|
"options": "Options",
|
||||||
|
"save": "Save",
|
||||||
|
"security": "Security",
|
||||||
|
"settings": "Settings",
|
||||||
|
"store": {
|
||||||
|
"commingSoon": "coming soon",
|
||||||
|
"exploreMore": "Explore more {arrow}",
|
||||||
|
"images": "Game Images",
|
||||||
|
"lookAt": "Check it out",
|
||||||
|
"noGame": "no game",
|
||||||
|
"noImages": "No images",
|
||||||
|
"openAdminDashboard": "Open in Admin Dashboard",
|
||||||
|
"platform": "Platform | Platform | Platforms",
|
||||||
|
"rating": "Rating",
|
||||||
|
"readLess": "Click to read less",
|
||||||
|
"readMore": "Click to read more",
|
||||||
|
"recentlyAdded": "Recently Added",
|
||||||
|
"recentlyReleased": "Recently released",
|
||||||
|
"recentlyUpdated": "Recently Updated",
|
||||||
|
"released": "Released",
|
||||||
|
"reviews": "({0} Reviews)",
|
||||||
|
"title": "Store",
|
||||||
|
"view": "View in Store"
|
||||||
|
},
|
||||||
|
"type": "Type",
|
||||||
|
"upload": "Upload",
|
||||||
|
"uploadFile": "Upload file",
|
||||||
|
"userHeader": {
|
||||||
|
"closeSidebar": "Close sidebar",
|
||||||
|
"links": {
|
||||||
|
"community": "Community",
|
||||||
|
"library": "Library",
|
||||||
|
"news": "News"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"admin": "Admin Dashboard",
|
||||||
|
"settings": "Account settings"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"welcome": "American, Welcome!"
|
||||||
|
}
|
||||||
@ -42,7 +42,9 @@
|
|||||||
class="-m-2.5 p-2.5"
|
class="-m-2.5 p-2.5"
|
||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Close sidebar</span>
|
<span class="sr-only">{{
|
||||||
|
$t("userHeader.closeSidebar")
|
||||||
|
}}</span>
|
||||||
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -96,7 +98,7 @@
|
|||||||
<DropLogo class="h-8 w-auto" />
|
<DropLogo class="h-8 w-auto" />
|
||||||
<span
|
<span
|
||||||
class="mt-1 bg-blue-400 px-1 py-0.5 rounded-md text-xs font-bold font-display"
|
class="mt-1 bg-blue-400 px-1 py-0.5 rounded-md text-xs font-bold font-display"
|
||||||
>Admin</span
|
>{{ $t("header.admin.admin") }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<nav class="mt-8">
|
<nav class="mt-8">
|
||||||
@ -131,7 +133,7 @@
|
|||||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||||
@click="sidebarOpen = true"
|
@click="sidebarOpen = true"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Open sidebar</span>
|
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
|
||||||
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
|
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -162,40 +164,49 @@ import {
|
|||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
DocumentIcon,
|
DocumentIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
|
RectangleStackIcon,
|
||||||
} from "@heroicons/vue/24/outline";
|
} from "@heroicons/vue/24/outline";
|
||||||
import type { NavigationItem } from "~/composables/types";
|
import type { NavigationItem } from "~/composables/types";
|
||||||
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
|
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
|
||||||
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
|
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
|
||||||
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
import { XMarkIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const navigation: Array<NavigationItem & { icon: Component }> = [
|
const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||||
{ label: "Home", route: "/admin", prefix: "/admin", icon: HomeIcon },
|
{ label: t("home"), route: "/admin", prefix: "/admin", icon: HomeIcon },
|
||||||
{
|
{
|
||||||
label: "Library",
|
label: t("userHeader.links.library"),
|
||||||
route: "/admin/library",
|
route: "/admin/library",
|
||||||
prefix: "/admin/library",
|
prefix: "/admin/library",
|
||||||
icon: ServerStackIcon,
|
icon: ServerStackIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Meta",
|
label: t("header.admin.meta"),
|
||||||
route: "/admin/metadata",
|
route: "/admin/metadata",
|
||||||
prefix: "/admin/metadata",
|
prefix: "/admin/metadata",
|
||||||
icon: DocumentIcon,
|
icon: DocumentIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Users",
|
label: t("header.admin.users"),
|
||||||
route: "/admin/users",
|
route: "/admin/users",
|
||||||
prefix: "/admin/users",
|
prefix: "/admin/users",
|
||||||
icon: UserGroupIcon,
|
icon: UserGroupIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: t("header.admin.tasks"),
|
||||||
|
route: "/admin/task",
|
||||||
|
prefix: "/admin/task",
|
||||||
|
icon: RectangleStackIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("settings"),
|
||||||
route: "/admin/settings",
|
route: "/admin/settings",
|
||||||
prefix: "/admin/settings",
|
prefix: "/admin/settings",
|
||||||
icon: Cog6ToothIcon,
|
icon: Cog6ToothIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Back",
|
label: t("header.back"),
|
||||||
route: "/store",
|
route: "/store",
|
||||||
prefix: ".",
|
prefix: ".",
|
||||||
icon: ArrowLeftIcon,
|
icon: ArrowLeftIcon,
|
||||||
@ -221,7 +232,7 @@ useHead({
|
|||||||
},
|
},
|
||||||
link: [],
|
link: [],
|
||||||
titleTemplate(title) {
|
titleTemplate(title) {
|
||||||
return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`;
|
return title ? t("adminTitleTemplate", [title]) : t("adminTitle");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -23,6 +23,7 @@ export default defineNuxtConfig({
|
|||||||
// "@nuxt/image",
|
// "@nuxt/image",
|
||||||
"@nuxt/fonts",
|
"@nuxt/fonts",
|
||||||
"@nuxt/eslint",
|
"@nuxt/eslint",
|
||||||
|
"@nuxtjs/i18n",
|
||||||
],
|
],
|
||||||
|
|
||||||
// Nuxt-only config
|
// Nuxt-only config
|
||||||
@ -42,6 +43,7 @@ export default defineNuxtConfig({
|
|||||||
experimental: {
|
experimental: {
|
||||||
buildCache: true,
|
buildCache: true,
|
||||||
viewTransition: true,
|
viewTransition: true,
|
||||||
|
componentIslands: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// future: {
|
// future: {
|
||||||
@ -89,7 +91,7 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
scheduledTasks: {
|
scheduledTasks: {
|
||||||
"0 * * * *": ["cleanup:invitations", "cleanup:sessions"],
|
"0 * * * *": ["dailyTasks"],
|
||||||
},
|
},
|
||||||
|
|
||||||
storage: {
|
storage: {
|
||||||
@ -123,6 +125,27 @@ export default defineNuxtConfig({
|
|||||||
prefix: "Vue",
|
prefix: "Vue",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: "en-us",
|
||||||
|
strategy: "no_prefix",
|
||||||
|
experimental: {
|
||||||
|
localeDetector: "localeDetector.ts",
|
||||||
|
},
|
||||||
|
detectBrowserLanguage: {
|
||||||
|
useCookie: true,
|
||||||
|
cookieKey: "drop_i18n_redirected",
|
||||||
|
fallbackLocale: "en-us",
|
||||||
|
},
|
||||||
|
locales: [
|
||||||
|
{ code: "en-us", name: "English", file: "en_us.json" },
|
||||||
|
{
|
||||||
|
code: "en-pirate",
|
||||||
|
name: "English (Pirate)",
|
||||||
|
file: "en_pirate.json",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
security: {
|
security: {
|
||||||
headers: {
|
headers: {
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nuxt-app",
|
"name": "nuxt-app",
|
||||||
"version": "0.1.0.beta",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -16,12 +16,14 @@
|
|||||||
"lint:fix": "eslint . --fix && prettier --write --list-different ."
|
"lint:fix": "eslint . --fix && prettier --write --list-different ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@discordapp/twemoji": "^15.1.0",
|
||||||
"@drop-oss/droplet": "^1.3.1",
|
"@drop-oss/droplet": "^1.3.1",
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@lobomfz/prismark": "0.0.3",
|
"@lobomfz/prismark": "0.0.3",
|
||||||
"@nuxt/fonts": "^0.11.0",
|
"@nuxt/fonts": "^0.11.0",
|
||||||
"@nuxt/image": "^1.10.0",
|
"@nuxt/image": "^1.10.0",
|
||||||
|
"@nuxtjs/i18n": "^9.5.5",
|
||||||
"@prisma/client": "^6.7.0",
|
"@prisma/client": "^6.7.0",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
@ -35,9 +37,10 @@
|
|||||||
"jdenticon": "^3.3.0",
|
"jdenticon": "^3.3.0",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
"micromark": "^4.0.1",
|
"micromark": "^4.0.1",
|
||||||
"nuxt": "^3.16.2",
|
"nuxt": "^3.17.4",
|
||||||
"nuxt-security": "2.2.0",
|
"nuxt-security": "2.2.0",
|
||||||
"prisma": "^6.7.0",
|
"prisma": "^6.7.0",
|
||||||
|
"sanitize-filename": "^1.6.3",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"stream-mime-type": "^2.0.0",
|
"stream-mime-type": "^2.0.0",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
@ -49,6 +52,7 @@
|
|||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@intlify/eslint-plugin-vue-i18n": "^4.0.1",
|
||||||
"@nuxt/eslint": "^1.3.0",
|
"@nuxt/eslint": "^1.3.0",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
|||||||
@ -42,7 +42,9 @@
|
|||||||
class="-m-2.5 p-2.5"
|
class="-m-2.5 p-2.5"
|
||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Close sidebar</span>
|
<span class="sr-only">{{
|
||||||
|
$t("userHeader.closeSidebar")
|
||||||
|
}}</span>
|
||||||
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -73,13 +75,13 @@
|
|||||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||||
@click="sidebarOpen = true"
|
@click="sidebarOpen = true"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Open sidebar</span>
|
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
|
||||||
<Bars3Icon class="size-6" aria-hidden="true" />
|
<Bars3Icon class="size-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
|
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
|
||||||
>
|
>
|
||||||
Account
|
{{ $t("account.title") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -100,6 +102,7 @@ import {
|
|||||||
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
|
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
|
|
||||||
router.afterEach(() => {
|
router.afterEach(() => {
|
||||||
@ -107,6 +110,6 @@ router.afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Account",
|
title: t("account.title"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -4,12 +4,12 @@
|
|||||||
<h2
|
<h2
|
||||||
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||||
>
|
>
|
||||||
Devices
|
{{ $t("account.devices.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||||
>
|
>
|
||||||
Manage the devices authorized to access your Drop account.
|
{{ $t("account.devices.subheader") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -24,28 +24,28 @@
|
|||||||
scope="col"
|
scope="col"
|
||||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
|
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
|
||||||
>
|
>
|
||||||
Name
|
{{ $t("name") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||||
>
|
>
|
||||||
Platform
|
{{ $t("account.devices.platform") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||||
>
|
>
|
||||||
Capabilities
|
{{ $t("account.devices.capabilities") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||||
>
|
>
|
||||||
Last Connected
|
{{ $t("account.devices.lastConnected") }}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">{{ $t("actions") }}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -80,7 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||||
{{ DateTime.fromISO(client.lastConnected).toRelative() }}
|
<RelativeTime :date="client.lastConnected" />
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
|
||||||
@ -89,13 +89,16 @@
|
|||||||
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"
|
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
|
||||||
@click="() => revokeClientWrapper(client.id)"
|
@click="() => revokeClientWrapper(client.id)"
|
||||||
>
|
>
|
||||||
Revoke<span class="sr-only">, {{ client.name }}</span>
|
{{ $t("account.devices.revoke") }}
|
||||||
|
<span class="sr-only">
|
||||||
|
{{ $t("chars.srComma", [client.name]) }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="clients.length === 0">
|
<tr v-if="clients.length === 0">
|
||||||
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
|
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
|
||||||
No devices connected to your account.
|
{{ $t("account.devices.noDevices") }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -107,16 +110,25 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CheckIcon } from "@heroicons/vue/24/outline";
|
import { CheckIcon } from "@heroicons/vue/24/outline";
|
||||||
import { DateTime } from "luxon";
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore pending https://github.com/nitrojs/nitro/issues/2758
|
// @ts-ignore pending https://github.com/nitrojs/nitro/issues/2758
|
||||||
const clients = ref(await $dropFetch("/api/v1/user/client"));
|
const clients = ref(await $dropFetch("/api/v1/user/client"));
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
async function revokeClient(id: string) {
|
async function revokeClient(id: string) {
|
||||||
await $dropFetch(`/api/v1/user/client/${id}`, { method: "DELETE" });
|
await $dropFetch(`/api/v1/user/client/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clients.value.push({
|
||||||
|
// id: "example-client",
|
||||||
|
// userId: "example-user",
|
||||||
|
// name: "Example Client",
|
||||||
|
// platform: "Windows",
|
||||||
|
// capabilities: ["TrackPlaytime"],
|
||||||
|
// lastConnected: new Date().toISOString(),
|
||||||
|
// });
|
||||||
|
|
||||||
function revokeClientWrapper(id: string) {
|
function revokeClientWrapper(id: string) {
|
||||||
revokeClient(id)
|
revokeClient(id)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@ -127,8 +139,8 @@ function revokeClientWrapper(id: string) {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "Failed to revoke client",
|
title: t("errors.revokeClient"),
|
||||||
description: `Failed to revoke client: ${e}`,
|
description: t("errors.revokeClientFull", String(e)),
|
||||||
},
|
},
|
||||||
(_, c) => c(),
|
(_, c) => c(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<h2
|
<h2
|
||||||
class="text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
class="text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
|
||||||
>
|
>
|
||||||
Notifications
|
{{ $t("account.notifications.notifications") }}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
:disabled="notifications.length === 0"
|
:disabled="notifications.length === 0"
|
||||||
@ -13,13 +13,13 @@
|
|||||||
@click="markAllAsRead"
|
@click="markAllAsRead"
|
||||||
>
|
>
|
||||||
<CheckIcon class="size-4" />
|
<CheckIcon class="size-4" />
|
||||||
Mark all as read
|
{{ $t("account.notifications.markAllAsRead") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
|
||||||
>
|
>
|
||||||
View and manage your notifications.
|
{{ $t("account.notifications.desc") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2">
|
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2">
|
||||||
<span class="text-xs text-zinc-500">
|
<span class="text-xs text-zinc-500">
|
||||||
{{ DateTime.fromISO(notification.created).toRelative() }}
|
<RelativeTime :date="notification.created" />
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="!notification.read"
|
v-if="!notification.read"
|
||||||
@ -61,7 +61,7 @@
|
|||||||
@click="markAsRead(notification.id)"
|
@click="markAsRead(notification.id)"
|
||||||
>
|
>
|
||||||
<CheckIcon class="size-3" />
|
<CheckIcon class="size-3" />
|
||||||
Mark as read
|
{{ $t("account.notifications.markAsRead") }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -69,7 +69,7 @@
|
|||||||
@click="deleteNotification(notification.id)"
|
@click="deleteNotification(notification.id)"
|
||||||
>
|
>
|
||||||
<TrashIcon class="size-3" />
|
<TrashIcon class="size-3" />
|
||||||
Delete
|
{{ $t("delete") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -80,7 +80,9 @@
|
|||||||
v-if="notifications.length === 0"
|
v-if="notifications.length === 0"
|
||||||
class="rounded-xl border border-zinc-800 bg-zinc-900 p-8 text-center"
|
class="rounded-xl border border-zinc-800 bg-zinc-900 p-8 text-center"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-zinc-400">No notifications</p>
|
<p class="text-sm text-zinc-400">
|
||||||
|
{{ $t("account.notifications.none") }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -88,7 +90,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import type { Notification } from "~/prisma/client";
|
import type { Notification } from "~/prisma/client";
|
||||||
import type { SerializeObject } from "nitropack";
|
import type { SerializeObject } from "nitropack";
|
||||||
|
|
||||||
@ -96,8 +97,10 @@ definePageMeta({
|
|||||||
layout: "default",
|
layout: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Notifications",
|
title: t("account.notifications.title"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch notifications
|
// Fetch notifications
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
:model-value="currentlySelectedVersion"
|
:model-value="currentlySelectedVersion"
|
||||||
@update:model-value="(value) => updateCurrentlySelectedVersion(value)"
|
@update:model-value="(value) => updateCurrentlySelectedVersion(value)"
|
||||||
>
|
>
|
||||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{
|
||||||
>Select version to import</ListboxLabel
|
$t("library.admin.import.version.version")
|
||||||
>
|
}}</ListboxLabel>
|
||||||
<div class="relative mt-2">
|
<div class="relative mt-2">
|
||||||
<ListboxButton
|
<ListboxButton
|
||||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||||
@ -15,9 +15,9 @@
|
|||||||
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
|
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
|
||||||
versions[currentlySelectedVersion]
|
versions[currentlySelectedVersion]
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else class="block truncate text-zinc-600"
|
<span v-else class="block truncate text-zinc-600">{{
|
||||||
>Please select a directory...</span
|
$t("library.admin.import.selectDir")
|
||||||
>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||||
>
|
>
|
||||||
@ -79,17 +79,20 @@
|
|||||||
<label
|
<label
|
||||||
for="startup"
|
for="startup"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Setup executable/command</label
|
>{{ $t("library.admin.import.version.setupCmd") }}</label
|
||||||
>
|
>
|
||||||
<p class="text-zinc-400 text-xs">Ran once when the game is installed</p>
|
<p class="text-zinc-400 text-xs">
|
||||||
|
{{ $t("library.admin.import.version.setupDesc") }}
|
||||||
|
</p>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div
|
<div
|
||||||
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||||
>(install_dir)/</span
|
|
||||||
>
|
>
|
||||||
|
{{ $t("library.admin.import.version.installDir") }}
|
||||||
|
</span>
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
:value="versionSettings.setup"
|
:value="versionSettings.setup"
|
||||||
@ -99,7 +102,9 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||||
:placeholder="'setup.exe'"
|
:placeholder="
|
||||||
|
$t('library.admin.import.version.setupPlaceholder')
|
||||||
|
"
|
||||||
@change="setupProcessQuery = $event.target.value"
|
@change="setupProcessQuery = $event.target.value"
|
||||||
@blur="setupProcessQuery = ''"
|
@blur="setupProcessQuery = ''"
|
||||||
/>
|
/>
|
||||||
@ -171,7 +176,7 @@
|
|||||||
<span
|
<span
|
||||||
:class="['block truncate', selected && 'font-semibold']"
|
:class="['block truncate', selected && 'font-semibold']"
|
||||||
>
|
>
|
||||||
"{{ setupProcessQuery }}"
|
{{ $t("chars.quoted", { text: setupProcessQuery }) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@ -206,14 +211,11 @@
|
|||||||
as="span"
|
as="span"
|
||||||
class="text-sm font-medium leading-6 text-zinc-100"
|
class="text-sm font-medium leading-6 text-zinc-100"
|
||||||
passive
|
passive
|
||||||
>Setup mode</SwitchLabel
|
>{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
|
||||||
>
|
|
||||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
|
||||||
>When enabled, this version does not have a launch command, and
|
|
||||||
simply runs the executable on the user's computer. Useful for games
|
|
||||||
that only distribute installer and not portable
|
|
||||||
files.</SwitchDescription
|
|
||||||
>
|
>
|
||||||
|
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
|
||||||
|
$t("library.admin.import.version.setupModeDesc")
|
||||||
|
}}</SwitchDescription>
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
v-model="versionSettings.onlySetup"
|
v-model="versionSettings.onlySetup"
|
||||||
@ -235,16 +237,18 @@
|
|||||||
<label
|
<label
|
||||||
for="startup"
|
for="startup"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Launch executable/command</label
|
>{{ $t("library.admin.import.version.launchCmd") }}</label
|
||||||
>
|
>
|
||||||
<p class="text-zinc-400 text-xs">Executable to launch the game</p>
|
<p class="text-zinc-400 text-xs">
|
||||||
|
{{ $t("library.admin.import.version.launchDesc") }}
|
||||||
|
</p>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div
|
<div
|
||||||
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
|
||||||
>(install_dir)/</span
|
>{{ $t("library.admin.import.version.installDir") }}</span
|
||||||
>
|
>
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
@ -255,7 +259,9 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||||
:placeholder="'game.exe'"
|
:placeholder="
|
||||||
|
$t('library.admin.import.version.launchPlaceholder')
|
||||||
|
"
|
||||||
@change="launchProcessQuery = $event.target.value"
|
@change="launchProcessQuery = $event.target.value"
|
||||||
@blur="launchProcessQuery = ''"
|
@blur="launchProcessQuery = ''"
|
||||||
/>
|
/>
|
||||||
@ -327,7 +333,7 @@
|
|||||||
<span
|
<span
|
||||||
:class="['block truncate', selected && 'font-semibold']"
|
:class="['block truncate', selected && 'font-semibold']"
|
||||||
>
|
>
|
||||||
"{{ launchProcessQuery }}"
|
{{ $t("chars.quoted", { text: launchProcessQuery }) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@ -361,7 +367,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlatformSelector v-model="versionSettings.platform">
|
<PlatformSelector v-model="versionSettings.platform">
|
||||||
Version platform
|
{{ $t("library.admin.import.version.platform") }}
|
||||||
</PlatformSelector>
|
</PlatformSelector>
|
||||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||||
<span class="flex flex-grow flex-col">
|
<span class="flex flex-grow flex-col">
|
||||||
@ -369,13 +375,12 @@
|
|||||||
as="span"
|
as="span"
|
||||||
class="text-sm font-medium leading-6 text-zinc-100"
|
class="text-sm font-medium leading-6 text-zinc-100"
|
||||||
passive
|
passive
|
||||||
>Update mode</SwitchLabel
|
|
||||||
>
|
|
||||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
|
||||||
>When enabled, these files will be installed on top of (overwriting)
|
|
||||||
the previous version's. If multiple "update modes" are chained
|
|
||||||
together, they are applied in order.</SwitchDescription
|
|
||||||
>
|
>
|
||||||
|
{{ $t("library.admin.import.version.updateMode") }}
|
||||||
|
</SwitchLabel>
|
||||||
|
<SwitchDescription as="span" class="text-sm text-zinc-400">
|
||||||
|
{{ $t("library.admin.import.version.updateModeDesc") }}
|
||||||
|
</SwitchDescription>
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
v-model="versionSettings.delta"
|
v-model="versionSettings.delta"
|
||||||
@ -398,7 +403,9 @@
|
|||||||
<DisclosureButton
|
<DisclosureButton
|
||||||
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
|
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
|
||||||
>
|
>
|
||||||
<span class="text-base/7 font-semibold">Advanced options</span>
|
<span class="text-base/7 font-semibold">
|
||||||
|
{{ $t("library.admin.import.version.advancedOptions") }}
|
||||||
|
</span>
|
||||||
<span class="ml-6 flex h-7 items-center">
|
<span class="ml-6 flex h-7 items-center">
|
||||||
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
|
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
|
||||||
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
|
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
|
||||||
@ -420,13 +427,12 @@
|
|||||||
as="span"
|
as="span"
|
||||||
class="text-sm font-medium leading-6 text-zinc-100"
|
class="text-sm font-medium leading-6 text-zinc-100"
|
||||||
passive
|
passive
|
||||||
>Override UMU Launcher Game ID</SwitchLabel
|
|
||||||
>
|
|
||||||
<SwitchDescription as="span" class="text-sm text-zinc-400"
|
|
||||||
>By default, Drop uses a non-ID when launching with UMU
|
|
||||||
Launcher. In order to get the right patches for some games,
|
|
||||||
you may have to manually set this field.</SwitchDescription
|
|
||||||
>
|
>
|
||||||
|
{{ $t("library.admin.import.version.umuOverride") }}
|
||||||
|
</SwitchLabel>
|
||||||
|
<SwitchDescription as="span" class="text-sm text-zinc-400">
|
||||||
|
{{ $t("library.admin.import.version.umuOverrideDesc") }}
|
||||||
|
</SwitchDescription>
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
v-model="umuIdEnabled"
|
v-model="umuIdEnabled"
|
||||||
@ -448,8 +454,9 @@
|
|||||||
<label
|
<label
|
||||||
for="umu-id"
|
for="umu-id"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>UMU Launcher ID</label
|
|
||||||
>
|
>
|
||||||
|
{{ $t("library.admin.import.version.umuLauncherId") }}
|
||||||
|
</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<input
|
<input
|
||||||
id="umu-id"
|
id="umu-id"
|
||||||
@ -467,7 +474,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-zinc-400">
|
<div v-else class="text-zinc-400">
|
||||||
No advanced options for this configuration.
|
{{ $t("library.admin.import.version.noAdv") }}
|
||||||
</div>
|
</div>
|
||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
@ -477,7 +484,7 @@
|
|||||||
:loading="importLoading"
|
:loading="importLoading"
|
||||||
@click="startImport_wrapper"
|
@click="startImport_wrapper"
|
||||||
>
|
>
|
||||||
Import
|
{{ $t("library.admin.import.import") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
|
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
@ -497,7 +504,7 @@
|
|||||||
role="status"
|
role="status"
|
||||||
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||||
>
|
>
|
||||||
Loading version metadata...
|
{{ $t("library.admin.import.version.loadingVersion") }}
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="w-6 h-6 text-transparent animate-spin fill-white"
|
class="w-6 h-6 text-transparent animate-spin fill-white"
|
||||||
@ -514,7 +521,6 @@
|
|||||||
fill="currentFill"
|
fill="currentFill"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -548,7 +554,7 @@ definePageMeta({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const gameId = route.params.id.toString();
|
const gameId = route.params.id.toString();
|
||||||
const versions = await $dropFetch(
|
const versions = await $dropFetch(
|
||||||
@ -661,7 +667,7 @@ function startImport_wrapper() {
|
|||||||
importLoading.value = true;
|
importLoading.value = true;
|
||||||
startImport()
|
startImport()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
importError.value = error.statusMessage ?? "An unknown error occurred.";
|
importError.value = error.statusMessage ?? t("errors.unknown");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
importLoading.value = false;
|
importLoading.value = false;
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm 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-blue-600"
|
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm 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-blue-600"
|
||||||
>
|
>
|
||||||
Open in Metadata
|
{{ $t("library.admin.openInMetadata") }}
|
||||||
<ArrowTopRightOnSquareIcon
|
<ArrowTopRightOnSquareIcon
|
||||||
class="-mr-0.5 h-7 w-7 p-1"
|
class="-mr-0.5 h-7 w-7 p-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -40,7 +40,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm 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-blue-600"
|
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm 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-blue-600"
|
||||||
>
|
>
|
||||||
Open in Store
|
{{ $t("library.admin.openStore") }}
|
||||||
<ArrowTopRightOnSquareIcon
|
<ArrowTopRightOnSquareIcon
|
||||||
class="-mr-0.5 h-7 w-7 p-1"
|
class="-mr-0.5 h-7 w-7 p-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -59,7 +59,7 @@
|
|||||||
<h3
|
<h3
|
||||||
class="text-base font-semibold font-display leading-6 text-zinc-100"
|
class="text-base font-semibold font-display leading-6 text-zinc-100"
|
||||||
>
|
>
|
||||||
Version priority
|
{{ $t("library.admin.versionPriority") }}
|
||||||
|
|
||||||
<!-- import games button -->
|
<!-- import games button -->
|
||||||
|
|
||||||
@ -80,8 +80,8 @@
|
|||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
unimportedVersions.length > 0
|
unimportedVersions.length > 0
|
||||||
? "Import version"
|
? $t("library.admin.import.version.import")
|
||||||
: "No versions to import"
|
: $t("library.admin.import.version.noVersions")
|
||||||
}}
|
}}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</h3>
|
</h3>
|
||||||
@ -89,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-center w-full text-sm text-zinc-600">
|
<div class="mt-4 text-center w-full text-sm text-zinc-600">
|
||||||
lowest
|
{{ $t("lowest") }}
|
||||||
</div>
|
</div>
|
||||||
<draggable
|
<draggable
|
||||||
:list="game.versions"
|
:list="game.versions"
|
||||||
@ -105,7 +105,11 @@
|
|||||||
{{ item.versionName }}
|
{{ item.versionName }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-400">
|
<div class="text-zinc-400">
|
||||||
{{ item.delta ? "Upgrade mode" : "" }}
|
{{
|
||||||
|
item.delta
|
||||||
|
? $t("library.admin.import.version.updateMode")
|
||||||
|
: ""
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-x-2">
|
<div class="inline-flex items-center gap-x-2">
|
||||||
<component
|
<component
|
||||||
@ -126,10 +130,10 @@
|
|||||||
v-if="game.versions.length == 0"
|
v-if="game.versions.length == 0"
|
||||||
class="text-center font-bold text-zinc-400 my-3"
|
class="text-center font-bold text-zinc-400 my-3"
|
||||||
>
|
>
|
||||||
no versions added
|
{{ $t("library.admin.noVersionsAdded") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-center w-full text-sm text-zinc-600">
|
<div class="mt-2 text-center w-full text-sm text-zinc-600">
|
||||||
highest
|
{{ $t("highest") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -151,6 +155,8 @@ definePageMeta({
|
|||||||
layout: "admin",
|
layout: "admin",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// TODO implement UI for this
|
// TODO implement UI for this
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -174,12 +180,12 @@ async function updateVersionOrder() {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "There an error while updating the version order",
|
title: t("errors.version.order.title"),
|
||||||
description: `Drop encountered an error while updating the version: ${
|
description: t("errors.version.order.desc", {
|
||||||
// @ts-expect-error attempt to get statusMessage on error
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
e?.statusMessage ?? "An unknown error occurred"
|
error: e?.statusMessage ?? t("errors.unknown"),
|
||||||
}`,
|
}),
|
||||||
buttonText: "Close",
|
buttonText: t("close"),
|
||||||
},
|
},
|
||||||
(e, c) => c(),
|
(e, c) => c(),
|
||||||
);
|
);
|
||||||
@ -203,12 +209,12 @@ async function deleteVersion(versionName: string) {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "There an error while deleting the version",
|
title: t("errors.version.delete.title"),
|
||||||
description: `Drop encountered an error while deleting the version: ${
|
description: t("errors.version.delete.desc", {
|
||||||
// @ts-expect-error attempt to get statusMessage on error
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
e?.statusMessage ?? "An unknown error occurred"
|
error: e?.statusMessage ?? t("errors.unknown"),
|
||||||
}`,
|
}),
|
||||||
buttonText: "Close",
|
buttonText: t("close"),
|
||||||
},
|
},
|
||||||
(e, c) => c(),
|
(e, c) => c(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
:model="currentlySelectedGame"
|
:model="currentlySelectedGame"
|
||||||
@update:model-value="(value) => updateSelectedGame_wrapper(value)"
|
@update:model-value="(value) => updateSelectedGame_wrapper(value)"
|
||||||
>
|
>
|
||||||
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
|
||||||
>Select game to import</ListboxLabel
|
{{ $t("library.admin.import.selectGame") }}
|
||||||
>
|
</ListboxLabel>
|
||||||
<div class="relative mt-2">
|
<div class="relative mt-2">
|
||||||
<ListboxButton
|
<ListboxButton
|
||||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||||
@ -15,9 +15,9 @@
|
|||||||
<span v-if="currentlySelectedGame != -1" class="block truncate">{{
|
<span v-if="currentlySelectedGame != -1" class="block truncate">{{
|
||||||
games.unimportedGames[currentlySelectedGame].game
|
games.unimportedGames[currentlySelectedGame].game
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else class="block truncate text-zinc-400"
|
<span v-else class="block truncate text-zinc-400">{{
|
||||||
>Please select a directory...</span
|
$t("library.admin.import.selectDir")
|
||||||
>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||||
>
|
>
|
||||||
@ -80,7 +80,8 @@
|
|||||||
class="w-fit"
|
class="w-fit"
|
||||||
:loading="importLoading"
|
:loading="importLoading"
|
||||||
@click="() => importGame_wrapper(false)"
|
@click="() => importGame_wrapper(false)"
|
||||||
>Import without metadata
|
>
|
||||||
|
{{ $t("library.admin.import.withoutMetadata") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -89,7 +90,7 @@
|
|||||||
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold"
|
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold"
|
||||||
>
|
>
|
||||||
<div class="h-[1px] grow bg-zinc-800" />
|
<div class="h-[1px] grow bg-zinc-800" />
|
||||||
OR
|
{{ $t("auth.signin.or") }}
|
||||||
<div class="h-[1px] grow bg-zinc-800" />
|
<div class="h-[1px] grow bg-zinc-800" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -100,7 +101,7 @@
|
|||||||
<label
|
<label
|
||||||
for="searchTerm"
|
for="searchTerm"
|
||||||
class="block text-sm/6 font-medium text-zinc-100"
|
class="block text-sm/6 font-medium text-zinc-100"
|
||||||
>Search</label
|
>{{ $t("library.admin.import.search") }}</label
|
||||||
>
|
>
|
||||||
<div class="mt-2 flex">
|
<div class="mt-2 flex">
|
||||||
<div class="-mr-px grid grow grid-cols-1 focus-within:relative">
|
<div class="-mr-px grid grow grid-cols-1 focus-within:relative">
|
||||||
@ -110,7 +111,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="searchTerm"
|
name="searchTerm"
|
||||||
class="col-start-1 row-start-1 block w-full rounded-l-md bg-zinc-950 py-1.5 px-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
class="col-start-1 row-start-1 block w-full rounded-l-md bg-zinc-950 py-1.5 px-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
|
||||||
placeholder="John Smith"
|
:placeholder="$t('library.admin.import.searchPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
@ -123,7 +124,7 @@
|
|||||||
class="-ml-0.5 size-4 text-gray-400"
|
class="-ml-0.5 size-4 text-gray-400"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
Search
|
{{ $t("library.admin.import.search") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -135,7 +136,7 @@
|
|||||||
>
|
>
|
||||||
<ListboxLabel
|
<ListboxLabel
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Select game</ListboxLabel
|
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
|
||||||
>
|
>
|
||||||
<div class="relative mt-2">
|
<div class="relative mt-2">
|
||||||
<ListboxButton
|
<ListboxButton
|
||||||
@ -145,9 +146,9 @@
|
|||||||
v-if="currentlySelectedMetadata != -1"
|
v-if="currentlySelectedMetadata != -1"
|
||||||
:game="metadataResults[currentlySelectedMetadata]"
|
:game="metadataResults[currentlySelectedMetadata]"
|
||||||
/>
|
/>
|
||||||
<span v-else class="block truncate text-zinc-600"
|
<span v-else class="block truncate text-zinc-600">
|
||||||
>Please select a game...</span
|
{{ $t("library.admin.import.selectGamePlaceholder") }}
|
||||||
>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||||
>
|
>
|
||||||
@ -191,7 +192,7 @@
|
|||||||
role="status"
|
role="status"
|
||||||
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||||
>
|
>
|
||||||
Loading game results...
|
{{ $t("library.admin.import.loading") }}
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="w-6 h-6 text-transparent animate-spin fill-white"
|
class="w-6 h-6 text-transparent animate-spin fill-white"
|
||||||
@ -208,7 +209,6 @@
|
|||||||
fill="currentFill"
|
fill="currentFill"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -233,7 +233,8 @@
|
|||||||
:loading="importLoading"
|
:loading="importLoading"
|
||||||
:disabled="currentlySelectedMetadata === -1"
|
:disabled="currentlySelectedMetadata === -1"
|
||||||
@click="() => importGame_wrapper()"
|
@click="() => importGame_wrapper()"
|
||||||
>Import
|
>
|
||||||
|
{{ $t("library.admin.import.import") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -274,6 +275,8 @@ definePageMeta({
|
|||||||
layout: "admin",
|
layout: "admin",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const games = await $dropFetch("/api/v1/admin/import/game");
|
const games = await $dropFetch("/api/v1/admin/import/game");
|
||||||
const currentlySelectedGame = ref(-1);
|
const currentlySelectedGame = ref(-1);
|
||||||
const gameSearchResultsLoading = ref(false);
|
const gameSearchResultsLoading = ref(false);
|
||||||
@ -308,8 +311,7 @@ function updateSelectedGame_wrapper(value: number) {
|
|||||||
gameSearchResultsLoading.value = true;
|
gameSearchResultsLoading.value = true;
|
||||||
updateSelectedGame(value)
|
updateSelectedGame(value)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
gameSearchResultsError.value =
|
gameSearchResultsError.value = error.statusMessage || t("errors.unknown");
|
||||||
error.statusMessage || "An unknown error occurred";
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
gameSearchResultsLoading.value = false;
|
gameSearchResultsLoading.value = false;
|
||||||
@ -348,7 +350,7 @@ function importGame_wrapper(metadata = true) {
|
|||||||
importError.value = undefined;
|
importError.value = undefined;
|
||||||
importGame(metadata)
|
importGame(metadata)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
importError.value = error?.statusMessage || "An unknown error occurred.";
|
importError.value = error?.statusMessage || t("errors.unknown");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
importLoading.value = false;
|
importLoading.value = false;
|
||||||
|
|||||||
@ -2,11 +2,11 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-base font-semibold text-zinc-100">Game Library</h1>
|
<h1 class="text-base font-semibold text-zinc-100">
|
||||||
|
{{ $t("library.admin.gameLibrary") }}
|
||||||
|
</h1>
|
||||||
<p class="mt-2 text-sm text-zinc-400">
|
<p class="mt-2 text-sm text-zinc-400">
|
||||||
As you add folders to your library sources, Drop will detect it and
|
{{ $t("library.admin.subheader") }}
|
||||||
prompt you to import it. Each game needs to be imported before you can
|
|
||||||
import a version.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||||
@ -14,7 +14,11 @@
|
|||||||
to="/admin/library/sources"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
Sources →
|
<i18n-t keypath="library.admin.sources.link" tag="span">
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -28,15 +32,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-3 flex-1 md:flex md:justify-between">
|
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||||
<p class="text-sm text-blue-400">
|
<p class="text-sm text-blue-400">
|
||||||
Drop has detected you have new games to import.
|
{{ $t("library.admin.detectedGame") }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
href="/admin/library/import"
|
href="/admin/library/import"
|
||||||
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||||
>
|
>
|
||||||
Import
|
<i18n-t keypath="library.admin.import.link" tag="span">
|
||||||
<span aria-hidden="true"> →</span>
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -49,7 +56,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="search"
|
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"
|
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="Search library..."
|
:placeholder="$t('library.search')"
|
||||||
/>
|
/>
|
||||||
<MagnifyingGlassIcon
|
<MagnifyingGlassIcon
|
||||||
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
|
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
|
||||||
@ -80,30 +87,40 @@
|
|||||||
>
|
>
|
||||||
</h3>
|
</h3>
|
||||||
<dl class="mt-1 flex flex-col justify-between">
|
<dl class="mt-1 flex flex-col justify-between">
|
||||||
<dt class="sr-only">Short Description</dt>
|
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
|
||||||
<dd class="text-sm text-zinc-400">
|
<dd class="text-sm text-zinc-400">
|
||||||
{{ game.mShortDescription }}
|
{{ game.mShortDescription }}
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="sr-only">Metadata provider</dt>
|
<dt class="sr-only">
|
||||||
|
{{ $t("library.admin.metadataProvider") }}
|
||||||
|
</dt>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="mt-4 flex flex-col gap-y-1">
|
<div class="mt-4 flex flex-col gap-y-1">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:href="`/admin/library/${game.id}`"
|
:href="`/admin/library/${game.id}`"
|
||||||
class="w-fit rounded-md bg-blue-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm 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"
|
class="w-fit rounded-md bg-blue-600 px-2.5 py-1.5 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"
|
||||||
>
|
>
|
||||||
Open with Library →
|
<i18n-t keypath="library.admin.openLibrary" tag="span">
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:href="`/admin/metadata/games/${game.id}`"
|
:href="`/admin/metadata/games/${game.id}`"
|
||||||
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm 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"
|
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold 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"
|
||||||
>
|
>
|
||||||
Open with Metadata →
|
<i18n-t keypath="library.admin.openMetadata" tag="span">
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
|
||||||
@click="() => deleteGame(game.id)"
|
@click="() => deleteGame(game.id)"
|
||||||
>
|
>
|
||||||
Delete
|
{{ $t("delete") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -122,15 +139,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-3 flex-1 md:flex md:justify-between">
|
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||||
<p class="text-sm text-blue-400">
|
<p class="text-sm text-blue-400">
|
||||||
Drop has detected you have new verions of this game to import.
|
{{ $t("library.admin.detectedVersion") }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:href="`/admin/library/${game.id}/import`"
|
:href="`/admin/library/${game.id}/import`"
|
||||||
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||||
>
|
>
|
||||||
Import
|
<i18n-t keypath="library.admin.import.link" tag="span">
|
||||||
<span aria-hidden="true"> →</span>
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -149,7 +169,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<h3 class="text-sm font-medium text-yellow-600">
|
<h3 class="text-sm font-medium text-yellow-600">
|
||||||
You have no versions of this game available.
|
{{ $t("library.admin.noVersions") }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -160,13 +180,13 @@
|
|||||||
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
|
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
|
||||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||||
>
|
>
|
||||||
No results
|
{{ $t("common.noResults") }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
|
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
|
||||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
|
||||||
>
|
>
|
||||||
No games imported
|
{{ $t("library.admin.noGames") }}
|
||||||
</p>
|
</p>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -176,12 +196,15 @@
|
|||||||
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid";
|
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid";
|
||||||
import { InformationCircleIcon } from "@heroicons/vue/20/solid";
|
import { InformationCircleIcon } from "@heroicons/vue/20/solid";
|
||||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Libraries",
|
title: t("library.admin.title"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-base font-semibold text-zinc-100">Library Sources</h1>
|
<h1 class="text-base font-semibold text-zinc-100">
|
||||||
|
{{ $t("library.admin.sources.sources") }}
|
||||||
|
</h1>
|
||||||
<p class="mt-2 text-sm text-zinc-400">
|
<p class="mt-2 text-sm text-zinc-400">
|
||||||
Configure your library sources, where Drop will look for new games and
|
{{ $t("library.admin.sources.desc") }}
|
||||||
versions to import.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||||
@ -13,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"
|
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)"
|
@click="() => (actionSourceOpen = true)"
|
||||||
>
|
>
|
||||||
Create
|
{{ $t("create") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -27,28 +28,28 @@
|
|||||||
scope="col"
|
scope="col"
|
||||||
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
|
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
|
||||||
>
|
>
|
||||||
Name
|
{{ $t("name") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||||
>
|
>
|
||||||
Type
|
{{ $t("type") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||||
>
|
>
|
||||||
Working?
|
{{ $t("library.admin.sources.working") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
|
||||||
>
|
>
|
||||||
Options
|
{{ $t("options") }}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">{{ $t("edit") }}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -83,14 +84,20 @@
|
|||||||
class="text-blue-500 hover:text-blue-400"
|
class="text-blue-500 hover:text-blue-400"
|
||||||
@click="() => edit(sourceIdx)"
|
@click="() => edit(sourceIdx)"
|
||||||
>
|
>
|
||||||
Edit<span class="sr-only">, {{ source.name }}</span>
|
{{ $t("edit") }}
|
||||||
|
<span class="sr-only">
|
||||||
|
{{ $t("chars.srComma", [source.name]) }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="text-red-500 hover:text-red-400"
|
class="text-red-500 hover:text-red-400"
|
||||||
@click="() => deleteSource(sourceIdx)"
|
@click="() => deleteSource(sourceIdx)"
|
||||||
>
|
>
|
||||||
Delete<span class="sr-only">, {{ source.name }}</span>
|
{{ $t("delete") }}
|
||||||
|
<span class="sr-only">
|
||||||
|
{{ $t("chars.srComma", [source.name]) }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -104,11 +111,10 @@
|
|||||||
<template #default>
|
<template #default>
|
||||||
<div>
|
<div>
|
||||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||||
Create source
|
{{ $t("library.admin.sources.create") }}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<p class="mt-1 text-zinc-400 text-sm">
|
<p class="mt-1 text-zinc-400 text-sm">
|
||||||
Drop will use this source to access your game library, and make them
|
{{ $t("library.admin.sources.createDesc") }}
|
||||||
available.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
@ -119,10 +125,10 @@
|
|||||||
<label
|
<label
|
||||||
for="name"
|
for="name"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Name</label
|
>{{ $t("name") }}</label
|
||||||
>
|
>
|
||||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||||
The name of your source, for reference.
|
{{ $t("library.admin.sources.nameDesc") }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<input
|
<input
|
||||||
@ -131,21 +137,23 @@
|
|||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="name"
|
autocomplete="name"
|
||||||
placeholder="My New Source"
|
:placeholder="$t('library.admin.sources.namePlaceholder')"
|
||||||
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="createMode">
|
<div v-if="createMode">
|
||||||
<label class="block text-sm font-medium leading-6 text-zinc-100"
|
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
|
||||||
>Type</label
|
$t("type")
|
||||||
>
|
}}</label>
|
||||||
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
<p class="text-zinc-400 block text-xs font-medium leading-6">
|
||||||
The type of your source. Changes the required options.
|
{{ $t("library.admin.sources.typeDesc") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<RadioGroup v-model="currentSourceOption" class="mt-2">
|
<RadioGroup v-model="currentSourceOption" class="mt-2">
|
||||||
<RadioGroupLabel class="sr-only">Type</RadioGroupLabel>
|
<RadioGroupLabel class="sr-only">{{
|
||||||
|
$t("type")
|
||||||
|
}}</RadioGroupLabel>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<RadioGroupOption
|
<RadioGroupOption
|
||||||
v-for="[source, metadata] in optionsMetadataIter"
|
v-for="[source, metadata] in optionsMetadataIter"
|
||||||
@ -220,7 +228,7 @@
|
|||||||
class="w-full sm:w-fit"
|
class="w-full sm:w-fit"
|
||||||
@click="() => performActionSource_wrapper()"
|
@click="() => performActionSource_wrapper()"
|
||||||
>
|
>
|
||||||
{{ createMode ? "Create" : "Save" }}
|
{{ createMode ? $t("create") : $t("save") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
ref="cancelButtonRef"
|
ref="cancelButtonRef"
|
||||||
@ -233,7 +241,7 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
Cancel
|
{{ $t("cancel") }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
@ -268,6 +276,8 @@ definePageMeta({
|
|||||||
layout: "admin",
|
layout: "admin",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const sources = ref(
|
const sources = ref(
|
||||||
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources"),
|
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources"),
|
||||||
);
|
);
|
||||||
@ -293,8 +303,7 @@ const optionsMetadata: {
|
|||||||
};
|
};
|
||||||
} = {
|
} = {
|
||||||
Filesystem: {
|
Filesystem: {
|
||||||
description:
|
description: t("library.admin.sources.fsDesc"),
|
||||||
"Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
|
|
||||||
icon: DocumentIcon,
|
icon: DocumentIcon,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -367,9 +376,11 @@ async function deleteSource(index: number) {
|
|||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "Failed to delete library source",
|
title: t("errors.library.source.delete.title"),
|
||||||
// @ts-expect-error attempt to display statusMessage on error
|
description: t("errors.library.source.delete.desc", [
|
||||||
description: `Drop couldn't add delete this source: ${e?.statusMessage}`,
|
// @ts-expect-error attempt to display statusMessage on error
|
||||||
|
e?.statusMessage ?? t("errors.unknown"),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
(_, c) => c(),
|
(_, c) => c(),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
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"
|
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)"
|
@click="() => (showEditCoreMetadata = true)"
|
||||||
>
|
>
|
||||||
Edit <PencilIcon class="size-4" />
|
{{ $t("edit") }} <PencilIcon class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -597,7 +597,7 @@ watch(descriptionHTML, (_v) => {
|
|||||||
title: "Failed to update game description",
|
title: "Failed to update game description",
|
||||||
description: `Drop failed to update the game description: ${
|
description: `Drop failed to update the game description: ${
|
||||||
// @ts-expect-error attempt to get statusMessage on error
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
e?.statusMessage ?? "An unknown error occurred."
|
e?.statusMessage ?? t("errors.unknown")
|
||||||
}`,
|
}`,
|
||||||
buttonText: "Close",
|
buttonText: "Close",
|
||||||
},
|
},
|
||||||
@ -642,7 +642,7 @@ async function updateBannerImage(id: string) {
|
|||||||
title: "There an error while updating the banner image",
|
title: "There an error while updating the banner image",
|
||||||
description: `Drop encountered an error while updating the banner image: ${
|
description: `Drop encountered an error while updating the banner image: ${
|
||||||
// @ts-expect-error attempt to get statusMessage on error
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
e?.statusMessage ?? "An unknown error occurred"
|
e?.statusMessage ?? t("errors.unknown")
|
||||||
}`,
|
}`,
|
||||||
buttonText: "Close",
|
buttonText: "Close",
|
||||||
},
|
},
|
||||||
@ -670,7 +670,7 @@ async function updateCoverImage(id: string) {
|
|||||||
title: "There an error while updating the cover image",
|
title: "There an error while updating the cover image",
|
||||||
description: `Drop encountered an error while updating the cover image: ${
|
description: `Drop encountered an error while updating the cover image: ${
|
||||||
// @ts-expect-error attempt to get statusMessage on error
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
e?.statusMessage ?? "An unknown error occurred"
|
e?.statusMessage ?? t("errors.unknown")
|
||||||
}`,
|
}`,
|
||||||
buttonText: "Close",
|
buttonText: "Close",
|
||||||
},
|
},
|
||||||
@ -700,7 +700,7 @@ async function deleteImage(id: string) {
|
|||||||
title: "There an error while deleting the image",
|
title: "There an error while deleting the image",
|
||||||
description: `Drop encountered an error while deleting the image: ${
|
description: `Drop encountered an error while deleting the image: ${
|
||||||
// @ts-expect-error attempt to get statusMessage on error
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
e?.statusMessage ?? "An unknown error occurred"
|
e?.statusMessage ?? t("errors.unknown")
|
||||||
}`,
|
}`,
|
||||||
buttonText: "Close",
|
buttonText: "Close",
|
||||||
},
|
},
|
||||||
@ -743,7 +743,7 @@ async function updateImageCarousel() {
|
|||||||
title: "There an error while updating the image carousel",
|
title: "There an error while updating the image carousel",
|
||||||
description: `Drop encountered an error while updating image carousel: ${
|
description: `Drop encountered an error while updating image carousel: ${
|
||||||
// @ts-expect-error attempt to get statusMessage on error
|
// @ts-expect-error attempt to get statusMessage on error
|
||||||
e?.statusMessage ?? "An unknown error occurred"
|
e?.statusMessage ?? t("errors.unknown")
|
||||||
}`,
|
}`,
|
||||||
buttonText: "Close",
|
buttonText: "Close",
|
||||||
},
|
},
|
||||||
|
|||||||
12
pages/admin/settings/index.vue
Normal file
12
pages/admin/settings/index.vue
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-gray-100">Todo page</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
useHead({
|
||||||
|
title: "Settings",
|
||||||
|
});
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: "admin",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
12
pages/admin/task/index.vue
Normal file
12
pages/admin/task/index.vue
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-gray-100">Todo page</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
useHead({
|
||||||
|
title: "Tasks",
|
||||||
|
});
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: "admin",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -2,7 +2,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-base font-semibold text-zinc-100">Users</h1>
|
<h1 class="text-base font-semibold text-zinc-100">
|
||||||
|
{{ $t("header.admin.users") }}
|
||||||
|
</h1>
|
||||||
<p class="mt-2 text-sm text-zinc-400">
|
<p class="mt-2 text-sm text-zinc-400">
|
||||||
Manage the users on your Drop instance, and configure your
|
Manage the users on your Drop instance, and configure your
|
||||||
authentication methods.
|
authentication methods.
|
||||||
|
|||||||
@ -9,10 +9,10 @@
|
|||||||
<h2
|
<h2
|
||||||
class="mt-4 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
|
class="mt-4 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
|
||||||
>
|
>
|
||||||
Create your Drop account
|
{{ $t("auth.register.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-1 text-sm leading-6 text-zinc-400">
|
<p class="mt-1 text-sm leading-6 text-zinc-400">
|
||||||
Fill in your details below to create your account.
|
{{ $t("auth.register.subheader") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -23,7 +23,7 @@
|
|||||||
<label
|
<label
|
||||||
for="display-name"
|
for="display-name"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Display Name</label
|
>{{ $t("auth.displayName") }}</label
|
||||||
>
|
>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<input
|
<input
|
||||||
@ -43,7 +43,7 @@
|
|||||||
<label
|
<label
|
||||||
for="email"
|
for="email"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Email address</label
|
>{{ $t("auth.email") }}</label
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
:class="[
|
:class="[
|
||||||
@ -51,7 +51,7 @@
|
|||||||
'block text-xs font-medium leading-6',
|
'block text-xs font-medium leading-6',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
Must be in the format user@example.com
|
{{ $t("auth.register.emailFormat") }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<input
|
<input
|
||||||
@ -74,7 +74,7 @@
|
|||||||
<label
|
<label
|
||||||
for="username"
|
for="username"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Username</label
|
>{{ $t("auth.username") }}</label
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
:class="[
|
:class="[
|
||||||
@ -82,7 +82,7 @@
|
|||||||
'block text-xs font-medium leading-6',
|
'block text-xs font-medium leading-6',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
Must be 5 or more characters, and lowercase
|
{{ $t("auth.register.usernameFormat") }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<input
|
<input
|
||||||
@ -105,7 +105,7 @@
|
|||||||
<label
|
<label
|
||||||
for="password"
|
for="password"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Password</label
|
>{{ $t("auth.password") }}</label
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
:class="[
|
:class="[
|
||||||
@ -113,7 +113,7 @@
|
|||||||
'block text-xs font-medium leading-6',
|
'block text-xs font-medium leading-6',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
Must be 14 or more characters
|
{{ $t("auth.register.passwordFormat") }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<input
|
<input
|
||||||
@ -132,7 +132,7 @@
|
|||||||
<label
|
<label
|
||||||
for="confirm-password"
|
for="confirm-password"
|
||||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
>Confirm Password</label
|
>{{ $t("auth.confirmPassword") }}</label
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
:class="[
|
:class="[
|
||||||
@ -140,7 +140,7 @@
|
|||||||
'block text-xs font-medium leading-6',
|
'block text-xs font-medium leading-6',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
Must be the same as above
|
{{ $t("auth.register.confirmPasswordFormat") }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<input
|
<input
|
||||||
@ -157,7 +157,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<LoadingButton type="submit" :loading="loading" class="w-full">
|
<LoadingButton type="submit" :loading="loading" class="w-full">
|
||||||
Create
|
{{ $t("create") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -195,13 +195,15 @@
|
|||||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const invitationId = route.query.id?.toString();
|
const invitationId = route.query.id?.toString();
|
||||||
if (!invitationId)
|
if (!invitationId)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "Invitation required to sign up.",
|
statusMessage: t("errors.inviteRequired"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invitation = await $dropFetch(
|
const invitation = await $dropFetch(
|
||||||
@ -265,7 +267,7 @@ function register_wrapper() {
|
|||||||
router.push("/auth/signin");
|
router.push("/auth/signin");
|
||||||
})
|
})
|
||||||
.catch((response) => {
|
.catch((response) => {
|
||||||
const message = response.statusMessage || "An unknown error occurred";
|
const message = response.statusMessage || t("errors.unknown");
|
||||||
error.value = message;
|
error.value = message;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@ -278,6 +280,6 @@ definePageMeta({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Create your Drop account",
|
title: t("auth.register.title"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -9,10 +9,10 @@
|
|||||||
<h2
|
<h2
|
||||||
class="mt-8 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
|
class="mt-8 text-2xl font-bold font-display leading-9 tracking-tight text-zinc-100"
|
||||||
>
|
>
|
||||||
Sign in to your account
|
{{ $t("auth.signin.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-sm leading-6 text-zinc-400">
|
<p class="mt-2 text-sm leading-6 text-zinc-400">
|
||||||
Don't have an account? Ask an admin to create one for you.
|
{{ $t("auth.signin.noAccount") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -24,7 +24,7 @@
|
|||||||
class="py-4 flex flex-row items-center justify-center gap-x-4 font-bold text-sm text-zinc-600"
|
class="py-4 flex flex-row items-center justify-center gap-x-4 font-bold text-sm text-zinc-600"
|
||||||
>
|
>
|
||||||
<span class="h-[1px] grow bg-zinc-600" />
|
<span class="h-[1px] grow bg-zinc-600" />
|
||||||
OR
|
{{ $t("auth.signin.or") }}
|
||||||
<span class="h-[1px] grow bg-zinc-600" />
|
<span class="h-[1px] grow bg-zinc-600" />
|
||||||
</div>
|
</div>
|
||||||
<AuthOpenID v-if="enabledAuths.includes('OpenID' as AuthMec)" />
|
<AuthOpenID v-if="enabledAuths.includes('OpenID' as AuthMec)" />
|
||||||
@ -46,6 +46,7 @@
|
|||||||
import type { AuthMec } from "~/prisma/client";
|
import type { AuthMec } from "~/prisma/client";
|
||||||
import DropLogo from "~/components/DropLogo.vue";
|
import DropLogo from "~/components/DropLogo.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const enabledAuths = await $dropFetch("/api/v1/auth");
|
const enabledAuths = await $dropFetch("/api/v1/auth");
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@ -53,6 +54,6 @@ definePageMeta({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Sign in to Drop",
|
title: t("auth.signin.pageTitle"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -7,12 +7,11 @@
|
|||||||
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
|
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
|
||||||
<div class="mt-3 text-center sm:mt-5">
|
<div class="mt-3 text-center sm:mt-5">
|
||||||
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
||||||
Successful!
|
{{ $t("auth.callback.success") }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<p class="mx-auto text-sm text-zinc-400 max-w-sm">
|
<p class="mx-auto text-sm text-zinc-400 max-w-sm">
|
||||||
Drop has successfully authorized the client. You may now close this
|
{{ $t("auth.callback.authorizedClient") }}
|
||||||
window.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Disclosure v-slot="{ open }" as="div" class="mt-8">
|
<Disclosure v-slot="{ open }" as="div" class="mt-8">
|
||||||
@ -20,7 +19,9 @@
|
|||||||
<DisclosureButton
|
<DisclosureButton
|
||||||
class="pb-2 flex w-full items-start justify-between text-left text-zinc-400"
|
class="pb-2 flex w-full items-start justify-between text-left text-zinc-400"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-semibold">Having issues?</span>
|
<span class="text-sm font-semibold">
|
||||||
|
{{ $t("auth.callback.issues") }}
|
||||||
|
</span>
|
||||||
<span class="ml-6 flex h-7 items-center">
|
<span class="ml-6 flex h-7 items-center">
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon
|
||||||
v-if="!open"
|
v-if="!open"
|
||||||
@ -33,7 +34,7 @@
|
|||||||
</dt>
|
</dt>
|
||||||
<DisclosurePanel as="dd" class="mt-2">
|
<DisclosurePanel as="dd" class="mt-2">
|
||||||
<p class="text-zinc-100 font-semibold text-sm mb-3">
|
<p class="text-zinc-100 font-semibold text-sm mb-3">
|
||||||
Paste this code into the client to continue:
|
{{ $t("auth.callback.paste") }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
class="max-w-sm text-nowrap overflow-x-auto text-sm bg-zinc-950/50 p-3 text-zinc-300 w-fit mx-auto rounded-xl"
|
class="max-w-sm text-nowrap overflow-x-auto text-sm bg-zinc-950/50 p-3 text-zinc-300 w-fit mx-auto rounded-xl"
|
||||||
@ -55,10 +56,10 @@
|
|||||||
<h1
|
<h1
|
||||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||||
>
|
>
|
||||||
Authorize client?
|
{{ $t("auth.callback.authClient") }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||||
"{{ clientData.name }}" has requested access to your Drop account.
|
{{ $t("auth.callback.requestedAccess", { name: clientData.name }) }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
action="/api/v1/client/callback"
|
action="/api/v1/client/callback"
|
||||||
@ -70,7 +71,7 @@
|
|||||||
class="rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
class="rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
@click="() => authorize_wrapper()"
|
@click="() => authorize_wrapper()"
|
||||||
>
|
>
|
||||||
Authorize
|
{{ $t("auth.callback.authorize") }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="error" class="mt-5 rounded-md bg-red-600/10 p-4">
|
<div v-if="error" class="mt-5 rounded-md bg-red-600/10 p-4">
|
||||||
@ -93,9 +94,12 @@
|
|||||||
<p
|
<p
|
||||||
class="mt-6 font-semibold font-display text-lg leading-8 text-zinc-100"
|
class="mt-6 font-semibold font-display text-lg leading-8 text-zinc-100"
|
||||||
>
|
>
|
||||||
Accepting this request will allow "{{ clientData.name }}" on "{{
|
{{
|
||||||
clientData.platform
|
$t("auth.callback.permWarning", {
|
||||||
}}" to:
|
name: clientData.name,
|
||||||
|
platform: clientData.platform,
|
||||||
|
})
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 max-w-2xl sm:mt-12 lg:mt-14">
|
<div class="mt-8 max-w-2xl sm:mt-12 lg:mt-14">
|
||||||
@ -123,8 +127,17 @@
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
:href="feature.href"
|
:href="feature.href"
|
||||||
class="text-sm font-semibold leading-6 text-blue-600"
|
class="text-sm font-semibold leading-6 text-blue-600"
|
||||||
>Learn more <span aria-hidden="true">→</span></NuxtLink
|
|
||||||
>
|
>
|
||||||
|
<i18n-t
|
||||||
|
keypath="auth.callback.learn"
|
||||||
|
tag="span"
|
||||||
|
scope="global"
|
||||||
|
>
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
pages/community.vue
Normal file
8
pages/community.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-gray-100">Todo page</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
useHead({
|
||||||
|
title: "Settings",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -42,7 +42,9 @@
|
|||||||
class="-m-2.5 p-2.5"
|
class="-m-2.5 p-2.5"
|
||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Close sidebar</span>
|
<span class="sr-only">{{
|
||||||
|
$t("userHeader.closeSidebar")
|
||||||
|
}}</span>
|
||||||
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -73,13 +75,13 @@
|
|||||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||||
@click="sidebarOpen = true"
|
@click="sidebarOpen = true"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Open sidebar</span>
|
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
|
||||||
<Bars3Icon class="size-6" aria-hidden="true" />
|
<Bars3Icon class="size-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
|
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
|
||||||
>
|
>
|
||||||
Library
|
{{ $t("userHeader.links.library") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -99,6 +101,7 @@ import {
|
|||||||
} from "@headlessui/vue";
|
} from "@headlessui/vue";
|
||||||
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
|
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
|
|
||||||
@ -107,7 +110,7 @@ router.afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Library",
|
title: t("userHeader.links.library"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -7,14 +7,14 @@
|
|||||||
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
Back to Library
|
{{ $t("library.back") }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
||||||
{{ collection?.name }}
|
{{ collection?.name }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-zinc-400">
|
<p class="mt-2 text-zinc-400">
|
||||||
{{ collection?.entries?.length || 0 }} games
|
{{ $t("library.gameCount", collection?.entries?.length || 0) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -37,15 +37,19 @@ import { ArrowLeftIcon } from "@heroicons/vue/20/solid";
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const collections = await useCollections();
|
const collections = await useCollections();
|
||||||
|
const { t } = useI18n();
|
||||||
const collection = computed(() =>
|
const collection = computed(() =>
|
||||||
collections.value.find((e) => e.id == route.params.id),
|
collections.value.find((e) => e.id == route.params.id),
|
||||||
);
|
);
|
||||||
if (collection.value === undefined) {
|
if (collection.value === undefined) {
|
||||||
throw createError({ statusCode: 404, statusMessage: "Collection not found" });
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: t("library.collection.notFound"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: collection.value?.name || "Collection",
|
title: collection.value?.name || t("library.collection.title"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,7 @@
|
|||||||
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
Back to Library
|
{{ $t("library.back") }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -44,7 +44,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-blue-500/25 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-blue-500/25 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||||
>
|
>
|
||||||
Open in Launcher
|
{{ $t("library.launcherOpen") }}
|
||||||
<ArrowTopRightOnSquareIcon
|
<ArrowTopRightOnSquareIcon
|
||||||
class="-mr-0.5 h-5 w-5"
|
class="-mr-0.5 h-5 w-5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -57,7 +57,7 @@
|
|||||||
:to="`/store/${game.id}`"
|
: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"
|
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"
|
||||||
>
|
>
|
||||||
View in Store
|
{{ $t("store.view") }}
|
||||||
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
@ -69,7 +69,7 @@
|
|||||||
<div class="col-start-1 lg:col-start-2 space-y-6">
|
<div class="col-start-1 lg:col-start-2 space-y-6">
|
||||||
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
|
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
|
||||||
<h2 class="text-xl font-display font-semibold text-zinc-100 mb-4">
|
<h2 class="text-xl font-display font-semibold text-zinc-100 mb-4">
|
||||||
Game Images
|
{{ $t("store.images") }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<VueCarousel :items-to-show="1">
|
<VueCarousel :items-to-show="1">
|
||||||
@ -86,7 +86,7 @@
|
|||||||
<div
|
<div
|
||||||
class="h-48 lg:h-96 aspect-[1/2] flex items-center justify-center text-zinc-700 font-bold font-display"
|
class="h-48 lg:h-96 aspect-[1/2] flex items-center justify-center text-zinc-700 font-bold font-display"
|
||||||
>
|
>
|
||||||
No images
|
{{ $t("store.noImages") }}
|
||||||
</div>
|
</div>
|
||||||
</VueSlide>
|
</VueSlide>
|
||||||
|
|
||||||
@ -121,13 +121,15 @@ import {
|
|||||||
} from "@heroicons/vue/20/solid";
|
} from "@heroicons/vue/20/solid";
|
||||||
import { micromark } from "micromark";
|
import { micromark } from "micromark";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const id = route.params.id.toString();
|
const id = route.params.id.toString();
|
||||||
|
|
||||||
const { game: rawGame } = await $dropFetch(`/api/v1/games/${id}`);
|
const { game: rawGame } = await $dropFetch(`/api/v1/games/${id}`);
|
||||||
const game = computed(() => {
|
const game = computed(() => {
|
||||||
if (!rawGame) {
|
if (!rawGame) {
|
||||||
throw createError({ statusCode: 404, message: "Game not found" });
|
throw createError({ statusCode: 404, message: t("library.notFound") });
|
||||||
}
|
}
|
||||||
return rawGame;
|
return rawGame;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex flex-col gap-y-8">
|
<div class="flex flex-col gap-y-8">
|
||||||
<div class="max-w-2xl">
|
<div class="max-w-2xl">
|
||||||
<h2 class="text-2xl font-bold font-display text-zinc-100">Library</h2>
|
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
||||||
|
{{ $t("userHeader.links.library") }}
|
||||||
|
</h2>
|
||||||
<p class="mt-2 text-zinc-400">
|
<p class="mt-2 text-zinc-400">
|
||||||
Organize your games into collections for easy access, and access all
|
{{ $t("library.subheader") }}
|
||||||
your games.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -29,7 +30,7 @@
|
|||||||
{{ collection.name }}
|
{{ collection.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 text-sm text-zinc-400">
|
<p class="mt-1 text-sm text-zinc-400">
|
||||||
{{ collection.entries.length }} game(s)
|
{{ $t("library.gameCount", [collection.entries.length]) }}
|
||||||
</p>
|
</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
@ -60,11 +61,11 @@
|
|||||||
<h3
|
<h3
|
||||||
class="text-lg font-semibold text-zinc-400 group-hover:text-zinc-300"
|
class="text-lg font-semibold text-zinc-400 group-hover:text-zinc-300"
|
||||||
>
|
>
|
||||||
Create Collection
|
{{ $t("library.collection.create") }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-zinc-500 group-hover:text-zinc-400">
|
<p class="mt-1 text-sm text-zinc-500 group-hover:text-zinc-400">
|
||||||
Add a new collection to organize your games
|
{{ $t("library.collection.subheader") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -73,7 +74,9 @@
|
|||||||
|
|
||||||
<!-- game library grid -->
|
<!-- game library grid -->
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-zinc-100 text-xl font-bold font-display">All Games</h1>
|
<h1 class="text-zinc-100 text-xl font-bold font-display">
|
||||||
|
{{ $t("library.addGames") }}
|
||||||
|
</h1>
|
||||||
<div class="mt-4 flex flex-row flex-wrap justify-left gap-4">
|
<div class="mt-4 flex flex-row flex-wrap justify-left gap-4">
|
||||||
<GamePanel
|
<GamePanel
|
||||||
v-for="game in games"
|
v-for="game in games"
|
||||||
@ -99,11 +102,12 @@ const collectionCreateOpen = ref(false);
|
|||||||
|
|
||||||
const currentlyDeleting = ref<Collection | undefined>();
|
const currentlyDeleting = ref<Collection | undefined>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const library = await useLibrary();
|
const library = await useLibrary();
|
||||||
const games = library.value.entries.map((e) => e.game);
|
const games = library.value.entries.map((e) => e.game);
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Home",
|
title: t("userHeader.links.library"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,9 @@
|
|||||||
class="-m-2.5 p-2.5"
|
class="-m-2.5 p-2.5"
|
||||||
@click="sidebarOpen = false"
|
@click="sidebarOpen = false"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Close sidebar</span>
|
<span class="sr-only">
|
||||||
|
{{ $t("userHeader.closeSidebar") }}
|
||||||
|
</span>
|
||||||
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -73,13 +75,13 @@
|
|||||||
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
|
||||||
@click="sidebarOpen = true"
|
@click="sidebarOpen = true"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Open sidebar</span>
|
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
|
||||||
<Bars3Icon class="size-6" aria-hidden="true" />
|
<Bars3Icon class="size-6" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
|
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
|
||||||
>
|
>
|
||||||
News
|
{{ $t("userHeader.links.news") }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -106,6 +108,7 @@ if (!news.value) {
|
|||||||
console.log("fetched news");
|
console.log("fetched news");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
|
|
||||||
@ -114,7 +117,7 @@ router.afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "News",
|
title: t("userHeader.links.news"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
class="px-2 py-1 rounded bg-zinc-900/80 backdrop-blur-sm transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
class="px-2 py-1 rounded bg-zinc-900/80 backdrop-blur-sm transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
Back to News
|
{{ $t("news.back") }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -37,7 +37,7 @@
|
|||||||
@click="() => (currentlyDeleting = article)"
|
@click="() => (currentlyDeleting = article)"
|
||||||
>
|
>
|
||||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
Delete Article
|
{{ $t("news.delete") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -91,6 +91,7 @@ const route = useRoute();
|
|||||||
const currentlyDeleting = ref();
|
const currentlyDeleting = ref();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const news = useNews();
|
const news = useNews();
|
||||||
|
const { t } = useI18n();
|
||||||
if (!news.value) {
|
if (!news.value) {
|
||||||
news.value = await fetchNews();
|
news.value = await fetchNews();
|
||||||
}
|
}
|
||||||
@ -100,7 +101,7 @@ const article = computed(() =>
|
|||||||
if (!article.value)
|
if (!article.value)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: "Article not found",
|
statusMessage: t("news.notFound"),
|
||||||
fatal: true,
|
fatal: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,10 @@
|
|||||||
<div class="flex flex-col gap-y-4">
|
<div class="flex flex-col gap-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
||||||
Latest News
|
{{ $t("news.title") }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-zinc-400">
|
<p class="mt-2 text-zinc-400">
|
||||||
Stay up to date with the latest updates and announcements.
|
{{ $t("news.subheader") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -49,7 +49,7 @@
|
|||||||
:datetime="article.publishedAt"
|
:datetime="article.publishedAt"
|
||||||
class="text-sm text-zinc-400"
|
class="text-sm text-zinc-400"
|
||||||
>
|
>
|
||||||
{{ formatDate(article.publishedAt) }}
|
{{ $d(new Date(article.publishedAt), "short") }}
|
||||||
</time>
|
</time>
|
||||||
<span class="text-sm text-blue-400">{{
|
<span class="text-sm text-blue-400">{{
|
||||||
article.author?.displayName ?? "System"
|
article.author?.displayName ?? "System"
|
||||||
@ -73,8 +73,10 @@
|
|||||||
|
|
||||||
<div v-if="articles?.length === 0" class="text-center py-12">
|
<div v-if="articles?.length === 0" class="text-center py-12">
|
||||||
<DocumentIcon class="mx-auto h-12 w-12 text-zinc-400" />
|
<DocumentIcon class="mx-auto h-12 w-12 text-zinc-400" />
|
||||||
<h3 class="mt-2 text-sm font-semibold text-zinc-100">No articles</h3>
|
<h3 class="mt-2 text-sm font-semibold text-zinc-100">
|
||||||
<p class="mt-1 text-sm text-zinc-500">Check back later for updates.</p>
|
{{ $t("news.none") }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-zinc-500">{{ $t("news.checkLater") }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -84,6 +86,8 @@ import { DocumentIcon } from "@heroicons/vue/24/outline";
|
|||||||
import type { Article } from "~/prisma/client";
|
import type { Article } from "~/prisma/client";
|
||||||
import type { SerializeObject } from "nitropack/types";
|
import type { SerializeObject } from "nitropack/types";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { articles } = defineProps<{
|
const { articles } = defineProps<{
|
||||||
articles: SerializeObject<
|
articles: SerializeObject<
|
||||||
Article & {
|
Article & {
|
||||||
@ -93,16 +97,8 @@ const { articles } = defineProps<{
|
|||||||
>[];
|
>[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
|
||||||
return new Date(date).toLocaleDateString("en-AU", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "News",
|
title: t("userHeader.links.news"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -44,7 +44,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 duration-200 hover:scale-105 active:scale-95"
|
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 duration-200 hover:scale-105 active:scale-95"
|
||||||
>
|
>
|
||||||
Open in Admin Dashboard
|
{{ $t("store.openAdminDashboard") }}
|
||||||
<ArrowTopRightOnSquareIcon
|
<ArrowTopRightOnSquareIcon
|
||||||
class="-mr-0.5 h-7 w-7 p-1"
|
class="-mr-0.5 h-7 w-7 p-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -56,19 +56,19 @@
|
|||||||
<td
|
<td
|
||||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||||
>
|
>
|
||||||
Released
|
{{ $t("store.released") }}
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
|
||||||
{{
|
<time datetime="game.mReleased">
|
||||||
DateTime.fromISO(game.mReleased).toFormat("d MMMM, yyyy")
|
{{ $d(new Date(game.mReleased), "short") }}
|
||||||
}}
|
</time>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||||
>
|
>
|
||||||
Platform(s)
|
{{ $t("store.platform", platforms.length) }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
|
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
|
||||||
@ -82,7 +82,7 @@
|
|||||||
<span
|
<span
|
||||||
v-if="platforms.length == 0"
|
v-if="platforms.length == 0"
|
||||||
class="font-semibold text-blue-600"
|
class="font-semibold text-blue-600"
|
||||||
>coming soon</span
|
>{{ $t("store.commingSoon") }}</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -90,7 +90,7 @@
|
|||||||
<td
|
<td
|
||||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||||
>
|
>
|
||||||
Rating
|
{{ $t("store.rating") }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="whitespace-nowrap flex flex-row items-center gap-x-1 px-3 py-4 text-sm text-zinc-400"
|
class="whitespace-nowrap flex flex-row items-center gap-x-1 px-3 py-4 text-sm text-zinc-400"
|
||||||
@ -103,9 +103,9 @@
|
|||||||
'w-4 h-4',
|
'w-4 h-4',
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
<span class="text-zinc-600"
|
<span class="text-zinc-600">{{
|
||||||
>({{ rating._sum.mReviewCount ?? 0 }} reviews)</span
|
$t("store.reviews", [rating._sum.mReviewCount ?? 0])
|
||||||
>
|
}}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -131,7 +131,7 @@
|
|||||||
<div
|
<div
|
||||||
class="h-48 lg:h-96 aspect-[1/2] flex items-center justify-center text-zinc-700 font-bold font-display"
|
class="h-48 lg:h-96 aspect-[1/2] flex items-center justify-center text-zinc-700 font-bold font-display"
|
||||||
>
|
>
|
||||||
No images
|
{{ $t("store.noImages") }}
|
||||||
</div>
|
</div>
|
||||||
</VueSlide>
|
</VueSlide>
|
||||||
|
|
||||||
@ -162,7 +162,9 @@
|
|||||||
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
|
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
|
||||||
<span
|
<span
|
||||||
class="uppercase text-sm font-semibold font-display text-zinc-600"
|
class="uppercase text-sm font-semibold font-display text-zinc-600"
|
||||||
>Click to read {{ showPreview ? "more" : "less" }}</span
|
>{{
|
||||||
|
showPreview ? $t("store.readMore") : $t("store.readLess")
|
||||||
|
}}</span
|
||||||
>
|
>
|
||||||
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
|
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
|
||||||
</button>
|
</button>
|
||||||
@ -177,7 +179,6 @@
|
|||||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||||
import { StarIcon } from "@heroicons/vue/24/solid";
|
import { StarIcon } from "@heroicons/vue/24/solid";
|
||||||
import { micromark } from "micromark";
|
import { micromark } from "micromark";
|
||||||
import { DateTime } from "luxon";
|
|
||||||
import type { PlatformClient } from "~/composables/types";
|
import type { PlatformClient } from "~/composables/types";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full flex flex-col overflow-x-hidden">
|
<div class="w-full flex flex-col overflow-x-hidden">
|
||||||
|
<DevOnly
|
||||||
|
><h1 class="text-gray-100">{{ $t("welcome") }}</h1>
|
||||||
|
</DevOnly>
|
||||||
|
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
<VueCarousel
|
<VueCarousel
|
||||||
v-if="recent.length > 0"
|
v-if="recent.length > 0"
|
||||||
@ -24,7 +28,7 @@
|
|||||||
>
|
>
|
||||||
<div class="relative text-center">
|
<div class="relative text-center">
|
||||||
<h3 class="text-base/7 font-semibold text-blue-300">
|
<h3 class="text-base/7 font-semibold text-blue-300">
|
||||||
Recently added
|
{{ $t("store.recentlyAdded") }}
|
||||||
</h3>
|
</h3>
|
||||||
<h2
|
<h2
|
||||||
class="text-3xl font-bold tracking-tight text-white sm:text-5xl"
|
class="text-3xl font-bold tracking-tight text-white sm:text-5xl"
|
||||||
@ -41,7 +45,7 @@
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
:href="`/store/${game.id}`"
|
:href="`/store/${game.id}`"
|
||||||
class="block w-full rounded-md border border-transparent bg-white px-8 py-3 text-base font-medium text-gray-900 hover:bg-gray-100 sm:w-auto duration-200 hover:scale-105"
|
class="block w-full rounded-md border border-transparent bg-white px-8 py-3 text-base font-medium text-gray-900 hover:bg-gray-100 sm:w-auto duration-200 hover:scale-105"
|
||||||
>Check it out</NuxtLink
|
>{{ $t("store.lookAt") }}</NuxtLink
|
||||||
>
|
>
|
||||||
<AddLibraryButton :game-id="game.id" />
|
<AddLibraryButton :game-id="game.id" />
|
||||||
</div>
|
</div>
|
||||||
@ -62,18 +66,22 @@
|
|||||||
<h2
|
<h2
|
||||||
class="uppercase text-xl font-bold tracking-tight text-zinc-700 sm:text-3xl"
|
class="uppercase text-xl font-bold tracking-tight text-zinc-700 sm:text-3xl"
|
||||||
>
|
>
|
||||||
no game
|
{{ $t("store.noGame") }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- new releases -->
|
<!-- new releases -->
|
||||||
<div class="px-4 sm:px-12 py-4">
|
<div class="px-4 sm:px-12 py-4">
|
||||||
<h1 class="text-zinc-100 text-2xl font-bold font-display">
|
<h1 class="text-zinc-100 text-2xl font-bold font-display">
|
||||||
Recently released
|
{{ $t("store.recentlyReleased") }}
|
||||||
</h1>
|
</h1>
|
||||||
<NuxtLink class="text-blue-600 font-semibold"
|
<NuxtLink class="text-blue-600 font-semibold">
|
||||||
>Explore more →</NuxtLink
|
<i18n-t keypath="store.exploreMore" tag="span">
|
||||||
>
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</NuxtLink>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<GameCarousel :items="released" :min="12" />
|
<GameCarousel :items="released" :min="12" />
|
||||||
</div>
|
</div>
|
||||||
@ -82,11 +90,15 @@
|
|||||||
<!-- recently updated -->
|
<!-- recently updated -->
|
||||||
<div class="px-4 sm:px-12 py-4" hydrate-on-visible>
|
<div class="px-4 sm:px-12 py-4" hydrate-on-visible>
|
||||||
<h1 class="text-zinc-100 text-2xl font-bold font-display">
|
<h1 class="text-zinc-100 text-2xl font-bold font-display">
|
||||||
Recently updated
|
{{ $t("store.recentlyUpdated") }}
|
||||||
</h1>
|
</h1>
|
||||||
<NuxtLink class="text-blue-600 font-semibold"
|
<NuxtLink class="text-blue-600 font-semibold">
|
||||||
>Explore more →</NuxtLink
|
<i18n-t keypath="store.exploreMore" tag="span">
|
||||||
>
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</NuxtLink>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<GameCarousel :items="updated" :min="12" />
|
<GameCarousel :items="updated" :min="12" />
|
||||||
</div>
|
</div>
|
||||||
@ -102,7 +114,9 @@ const released = await $dropFetch("/api/v1/store/released");
|
|||||||
// const developers = await $dropFetch("/api/v1/store/developers");
|
// const developers = await $dropFetch("/api/v1/store/developers");
|
||||||
// const publishers = await $dropFetch("/api/v1/store/publishers");
|
// const publishers = await $dropFetch("/api/v1/store/publishers");
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "Store",
|
title: t("store.title"),
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
5
plugins/error-handler.ts
Normal file
5
plugins/error-handler.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
|
||||||
|
console.error(error, instance, info);
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import prisma from "~/server/internal/db/database";
|
import prisma from "~/server/internal/db/database";
|
||||||
|
import taskHandler from "~/server/internal/tasks";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const query = getQuery(h3);
|
const query = getQuery(h3);
|
||||||
@ -8,8 +9,7 @@ export default defineEventHandler(async (h3) => {
|
|||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "id required in fetching invitation",
|
statusMessage: "id required in fetching invitation",
|
||||||
});
|
});
|
||||||
|
taskHandler.runTaskGroupByName("cleanup:invitations");
|
||||||
await runTask("cleanup:invitations");
|
|
||||||
|
|
||||||
const invitation = await prisma.invitation.findUnique({ where: { id: id } });
|
const invitation = await prisma.invitation.findUnique({ where: { id: id } });
|
||||||
if (!invitation)
|
if (!invitation)
|
||||||
|
|||||||
45
server/api/v1/emojis/[id]/index.get.ts
Normal file
45
server/api/v1/emojis/[id]/index.get.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import path from "path";
|
||||||
|
import module from "module";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import sanitize from "sanitize-filename";
|
||||||
|
|
||||||
|
import aclManager from "~/server/internal/acls";
|
||||||
|
|
||||||
|
const twemojiJson = module.findPackageJSON(
|
||||||
|
"@discordapp/twemoji",
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
||||||
|
if (!userId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!twemojiJson)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Failed to resolve emoji package",
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsafeId = getRouterParam(h3, "id");
|
||||||
|
if (!unsafeId)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||||
|
|
||||||
|
const svgPath = path.join(
|
||||||
|
path.dirname(twemojiJson),
|
||||||
|
"dist",
|
||||||
|
"svg",
|
||||||
|
sanitize(unsafeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
setHeader(
|
||||||
|
h3,
|
||||||
|
"Cache-Control",
|
||||||
|
// 7 days
|
||||||
|
"public, max-age=604800, s-maxage=604800",
|
||||||
|
);
|
||||||
|
setHeader(h3, "Content-Type", "image/svg+xml");
|
||||||
|
return await fs.readFile(svgPath);
|
||||||
|
});
|
||||||
@ -1,12 +1,15 @@
|
|||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import objectHandler from "~/server/internal/objects";
|
import objectHandler from "~/server/internal/objects";
|
||||||
|
import sanitize from "sanitize-filename";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const id = getRouterParam(h3, "id");
|
const unsafeId = getRouterParam(h3, "id");
|
||||||
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
if (!unsafeId)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||||
|
|
||||||
const userId = await aclManager.getUserIdACL(h3, ["object:delete"]);
|
const userId = await aclManager.getUserIdACL(h3, ["object:delete"]);
|
||||||
|
|
||||||
|
const id = sanitize(unsafeId);
|
||||||
const result = await objectHandler.deleteWithPermission(id, userId);
|
const result = await objectHandler.deleteWithPermission(id, userId);
|
||||||
return { success: result };
|
return { success: result };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import objectHandler from "~/server/internal/objects";
|
import objectHandler from "~/server/internal/objects";
|
||||||
|
import sanitize from "sanitize-filename";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const id = getRouterParam(h3, "id");
|
const unsafeId = getRouterParam(h3, "id");
|
||||||
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
if (!unsafeId)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||||
|
|
||||||
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
||||||
|
|
||||||
|
const id = sanitize(unsafeId);
|
||||||
const object = await objectHandler.fetchWithPermissions(id, userId);
|
const object = await objectHandler.fetchWithPermissions(id, userId);
|
||||||
if (!object)
|
if (!object)
|
||||||
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import objectHandler from "~/server/internal/objects";
|
import objectHandler from "~/server/internal/objects";
|
||||||
|
import sanitize from "sanitize-filename";
|
||||||
|
|
||||||
// this request method is purely used by the browser to check if etag values are still valid
|
// this request method is purely used by the browser to check if etag values are still valid
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const id = getRouterParam(h3, "id");
|
const unsafeId = getRouterParam(h3, "id");
|
||||||
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
if (!unsafeId)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||||
|
|
||||||
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
||||||
|
|
||||||
|
const id = sanitize(unsafeId);
|
||||||
const object = await objectHandler.fetchWithPermissions(id, userId);
|
const object = await objectHandler.fetchWithPermissions(id, userId);
|
||||||
if (!object)
|
if (!object)
|
||||||
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import objectHandler from "~/server/internal/objects";
|
import objectHandler from "~/server/internal/objects";
|
||||||
|
import sanitize from "sanitize-filename";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const id = getRouterParam(h3, "id");
|
const unsafeId = getRouterParam(h3, "id");
|
||||||
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
if (!unsafeId)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||||
|
|
||||||
const body = await readRawBody(h3, "binary");
|
const body = await readRawBody(h3, "binary");
|
||||||
if (!body)
|
if (!body)
|
||||||
@ -15,6 +17,7 @@ export default defineEventHandler(async (h3) => {
|
|||||||
const userId = await aclManager.getUserIdACL(h3, ["object:update"]);
|
const userId = await aclManager.getUserIdACL(h3, ["object:update"]);
|
||||||
const buffer = Buffer.from(body);
|
const buffer = Buffer.from(body);
|
||||||
|
|
||||||
|
const id = sanitize(unsafeId);
|
||||||
const result = await objectHandler.writeWithPermissions(
|
const result = await objectHandler.writeWithPermissions(
|
||||||
id,
|
id,
|
||||||
async () => buffer,
|
async () => buffer,
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
// get a specific screenshot
|
// get a specific screenshot
|
||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import screenshotManager from "~/server/internal/screenshots";
|
import screenshotManager from "~/server/internal/screenshots";
|
||||||
|
import sanitize from "sanitize-filename";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const userId = await aclManager.getUserIdACL(h3, ["screenshots:delete"]);
|
const userId = await aclManager.getUserIdACL(h3, ["screenshots:delete"]);
|
||||||
if (!userId) throw createError({ statusCode: 403 });
|
if (!userId) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
const screenshotId = getRouterParam(h3, "id");
|
const unsafeId = getRouterParam(h3, "id");
|
||||||
if (!screenshotId)
|
if (!unsafeId)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "Missing screenshot ID",
|
statusMessage: "Missing screenshot ID",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const screenshotId = sanitize(unsafeId);
|
||||||
const result = await screenshotManager.get(screenshotId);
|
const result = await screenshotManager.get(screenshotId);
|
||||||
if (!result)
|
if (!result)
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
// get a specific screenshot
|
// get a specific screenshot
|
||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import screenshotManager from "~/server/internal/screenshots";
|
import screenshotManager from "~/server/internal/screenshots";
|
||||||
|
import sanitize from "sanitize-filename";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]);
|
const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]);
|
||||||
if (!userId) throw createError({ statusCode: 403 });
|
if (!userId) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
const screenshotId = getRouterParam(h3, "id");
|
const unsafeId = getRouterParam(h3, "id");
|
||||||
if (!screenshotId)
|
if (!unsafeId)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "Missing screenshot ID",
|
statusMessage: "Missing screenshot ID",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await screenshotManager.get(screenshotId);
|
const result = await screenshotManager.get(sanitize(unsafeId));
|
||||||
if (!result)
|
if (!result)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
|
|||||||
@ -23,15 +23,19 @@ export async function readDropValidatedBody<T>(
|
|||||||
try {
|
try {
|
||||||
return validate(_body);
|
return validate(_body);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const t = await useTranslation(event);
|
||||||
|
|
||||||
if (e instanceof ArkErrors) {
|
if (e instanceof ArkErrors) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: `Invalid request body: ${e.summary}`,
|
statusMessage: t("errors.invalidBody", [e.summary]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: `Invalid request body: ${e}`,
|
statusMessage: t("errors.invalidBody", [
|
||||||
|
e instanceof Error ? e.message : `${e}`,
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
server/h3.d.ts
vendored
4
server/h3.d.ts
vendored
@ -1,5 +1 @@
|
|||||||
export type MinimumRequestObject = { headers: Headers };
|
export type MinimumRequestObject = { headers: Headers };
|
||||||
|
|
||||||
export type TaskReturn<T = unknown> =
|
|
||||||
| { success: true; data: T; error?: never }
|
|
||||||
| { success: false; data?: never; error: { message: string } };
|
|
||||||
|
|||||||
@ -5,11 +5,7 @@ class SystemConfig {
|
|||||||
private dropVersion;
|
private dropVersion;
|
||||||
private gitRef;
|
private gitRef;
|
||||||
|
|
||||||
private checkForUpdates =
|
private checkForUpdates = getUpdateCheckConfig();
|
||||||
process.env.CHECK_FOR_UPDATES !== undefined &&
|
|
||||||
process.env.CHECK_FOR_UPDATES.toLocaleLowerCase() === "true"
|
|
||||||
? true
|
|
||||||
: false;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// get drop version and git ref from nuxt config
|
// get drop version and git ref from nuxt config
|
||||||
@ -40,3 +36,26 @@ class SystemConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const systemConfig = new SystemConfig();
|
export const systemConfig = new SystemConfig();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the configuration for checking updates based on various conditions
|
||||||
|
* @returns true if updates should be checked, false otherwise.
|
||||||
|
*/
|
||||||
|
function getUpdateCheckConfig(): boolean {
|
||||||
|
const envCheckUpdates = process.env.CHECK_FOR_UPDATES;
|
||||||
|
|
||||||
|
// Check environment variable
|
||||||
|
if (envCheckUpdates !== undefined) {
|
||||||
|
// if explicitly set to true or false, return that value
|
||||||
|
if (envCheckUpdates.toLocaleLowerCase() === "true") {
|
||||||
|
return true;
|
||||||
|
} else if (envCheckUpdates.toLocaleLowerCase() === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (process.env.NODE_ENV === "production") {
|
||||||
|
// default to true in production
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@ -218,6 +218,7 @@ class LibraryManager {
|
|||||||
|
|
||||||
taskHandler.create({
|
taskHandler.create({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
|
taskGroup: "import:game",
|
||||||
name: `Importing version ${versionName} for ${game.mName}`,
|
name: `Importing version ${versionName} for ${game.mName}`,
|
||||||
acls: ["system:import:version:read"],
|
acls: ["system:import:version:read"],
|
||||||
async run({ progress, log }) {
|
async run({ progress, log }) {
|
||||||
|
|||||||
19
server/internal/tasks/group.ts
Normal file
19
server/internal/tasks/group.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export const taskGroups = {
|
||||||
|
"cleanup:invitations": {
|
||||||
|
concurrency: false,
|
||||||
|
},
|
||||||
|
"cleanup:objects": {
|
||||||
|
concurrency: false,
|
||||||
|
},
|
||||||
|
"cleanup:sessions": {
|
||||||
|
concurrency: false,
|
||||||
|
},
|
||||||
|
"check:update": {
|
||||||
|
concurrency: false,
|
||||||
|
},
|
||||||
|
"import:game": {
|
||||||
|
concurrency: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type TaskGroup = keyof typeof taskGroups;
|
||||||
@ -2,34 +2,86 @@ import droplet from "@drop-oss/droplet";
|
|||||||
import type { MinimumRequestObject } from "~/server/h3";
|
import type { MinimumRequestObject } from "~/server/h3";
|
||||||
import aclManager from "../acls";
|
import aclManager from "../acls";
|
||||||
|
|
||||||
|
import cleanupInvites from "./registry/invitations";
|
||||||
|
import cleanupSessions from "./registry/sessions";
|
||||||
|
import checkUpdate from "./registry/update";
|
||||||
|
import cleanupObjects from "./registry/objects";
|
||||||
|
import { taskGroups, type TaskGroup } from "./group";
|
||||||
|
|
||||||
|
// a task that has been run
|
||||||
|
type FinishedTask = {
|
||||||
|
success: boolean;
|
||||||
|
progress: number;
|
||||||
|
log: string[];
|
||||||
|
error: { title: string; description: string } | undefined;
|
||||||
|
name: string;
|
||||||
|
taskGroup: TaskGroup;
|
||||||
|
acls: string[];
|
||||||
|
|
||||||
|
// ISO timestamp of when the task started
|
||||||
|
startTime: string;
|
||||||
|
// ISO timestamp of when the task ended
|
||||||
|
endTime: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// a currently running task in the pool
|
||||||
|
type TaskPoolEntry = FinishedTask & {
|
||||||
|
clients: Map<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The TaskHandler setups up two-way connections to web clients and manages the state for them
|
* The TaskHandler setups up two-way connections to web clients and manages the state for them
|
||||||
* This allows long-running tasks (like game imports and such) to report progress, success and error states
|
* This allows long-running tasks (like game imports and such) to report progress, success and error states
|
||||||
* easily without re-inventing the wheel every time.
|
* easily without re-inventing the wheel every time.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type TaskRegistryEntry = {
|
|
||||||
success: boolean;
|
|
||||||
progress: number;
|
|
||||||
log: string[];
|
|
||||||
error: { title: string; description: string } | undefined;
|
|
||||||
clients: Map<string, boolean>;
|
|
||||||
name: string;
|
|
||||||
acls: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
class TaskHandler {
|
class TaskHandler {
|
||||||
// TODO: make these maps, using objects like this has performance impacts
|
// registry of schedualed tasks to be created
|
||||||
// https://typescript-eslint.io/rules/no-dynamic-delete/
|
private scheduledTasks: Map<TaskGroup, () => Task> = new Map();
|
||||||
private taskRegistry = new Map<string, TaskRegistryEntry>();
|
// list of all finished tasks
|
||||||
|
private finishedTasks: Map<string, FinishedTask> = new Map();
|
||||||
|
|
||||||
|
// list of all currently running tasks
|
||||||
|
private taskPool = new Map<string, TaskPoolEntry>();
|
||||||
|
// list of all clients currently connected to tasks
|
||||||
private clientRegistry = new Map<string, PeerImpl>();
|
private clientRegistry = new Map<string, PeerImpl>();
|
||||||
startTasks: (() => void)[] = [];
|
|
||||||
|
constructor() {
|
||||||
|
// register the cleanup invitations task
|
||||||
|
this.saveScheduledTask(cleanupInvites);
|
||||||
|
this.saveScheduledTask(cleanupSessions);
|
||||||
|
this.saveScheduledTask(checkUpdate);
|
||||||
|
this.saveScheduledTask(cleanupObjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves scheduled task to the registry
|
||||||
|
* @param createTask
|
||||||
|
*/
|
||||||
|
private saveScheduledTask(task: DropTask) {
|
||||||
|
this.scheduledTasks.set(task.taskGroup, task.build);
|
||||||
|
}
|
||||||
|
|
||||||
create(task: Task) {
|
create(task: Task) {
|
||||||
let updateCollectTimeout: NodeJS.Timeout | undefined;
|
let updateCollectTimeout: NodeJS.Timeout | undefined;
|
||||||
let updateCollectResolves: Array<(value: unknown) => void> = [];
|
let updateCollectResolves: Array<(value: unknown) => void> = [];
|
||||||
let logOffset: number = 0;
|
let logOffset: number = 0;
|
||||||
|
|
||||||
|
// if taskgroup disallows concurrency
|
||||||
|
if (!taskGroups[task.taskGroup].concurrency) {
|
||||||
|
for (const existingTask of this.taskPool.values()) {
|
||||||
|
// if a task is already running, we don't want to start another
|
||||||
|
if (existingTask.taskGroup === task.taskGroup) {
|
||||||
|
// TODO: handle this more gracefully, maybe with a queue? should be configurable
|
||||||
|
console.warn(
|
||||||
|
`Task group ${task.taskGroup} does not allow concurrent tasks. Task ${task.id} will not be started.`,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Task group ${task.taskGroup} does not allow concurrent tasks.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateAllClients = (reset = false) =>
|
const updateAllClients = (reset = false) =>
|
||||||
new Promise((r) => {
|
new Promise((r) => {
|
||||||
if (updateCollectTimeout) {
|
if (updateCollectTimeout) {
|
||||||
@ -37,7 +89,7 @@ class TaskHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateCollectTimeout = setTimeout(() => {
|
updateCollectTimeout = setTimeout(() => {
|
||||||
const taskEntry = this.taskRegistry.get(task.id);
|
const taskEntry = this.taskPool.get(task.id);
|
||||||
if (!taskEntry) return;
|
if (!taskEntry) return;
|
||||||
|
|
||||||
const taskMessage: TaskMessage = {
|
const taskMessage: TaskMessage = {
|
||||||
@ -67,33 +119,37 @@ class TaskHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const progress = (progress: number) => {
|
const progress = (progress: number) => {
|
||||||
const taskEntry = this.taskRegistry.get(task.id);
|
const taskEntry = this.taskPool.get(task.id);
|
||||||
if (!taskEntry) return;
|
if (!taskEntry) return;
|
||||||
taskEntry.progress = progress;
|
taskEntry.progress = progress;
|
||||||
updateAllClients();
|
updateAllClients();
|
||||||
};
|
};
|
||||||
|
|
||||||
const log = (entry: string) => {
|
const log = (entry: string) => {
|
||||||
const taskEntry = this.taskRegistry.get(task.id);
|
const taskEntry = this.taskPool.get(task.id);
|
||||||
if (!taskEntry) return;
|
if (!taskEntry) return;
|
||||||
taskEntry.log.push(entry);
|
taskEntry.log.push(entry);
|
||||||
|
console.log(`[Task ${task.taskGroup}]: ${entry}`);
|
||||||
updateAllClients();
|
updateAllClients();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.taskRegistry.set(task.id, {
|
this.taskPool.set(task.id, {
|
||||||
name: task.name,
|
name: task.name,
|
||||||
|
taskGroup: task.taskGroup,
|
||||||
success: false,
|
success: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
log: [],
|
log: [],
|
||||||
clients: new Map(),
|
clients: new Map(),
|
||||||
acls: task.acls,
|
acls: task.acls,
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
endTime: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateAllClients(true);
|
updateAllClients(true);
|
||||||
|
|
||||||
droplet.callAltThreadFunc(async () => {
|
droplet.callAltThreadFunc(async () => {
|
||||||
const taskEntry = this.taskRegistry.get(task.id);
|
const taskEntry = this.taskPool.get(task.id);
|
||||||
if (!taskEntry) throw new Error("No task entry");
|
if (!taskEntry) throw new Error("No task entry");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -106,13 +162,22 @@ class TaskHandler {
|
|||||||
description: (error as string).toString(),
|
description: (error as string).toString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
taskEntry.endTime = new Date().toISOString();
|
||||||
await updateAllClients();
|
await updateAllClients();
|
||||||
|
|
||||||
for (const clientId of taskEntry.clients.keys()) {
|
for (const clientId of taskEntry.clients.keys()) {
|
||||||
if (!this.clientRegistry.get(clientId)) continue;
|
if (!this.clientRegistry.get(clientId)) continue;
|
||||||
this.disconnect(clientId, task.id);
|
this.disconnect(clientId, task.id);
|
||||||
}
|
}
|
||||||
this.taskRegistry.delete(task.id);
|
|
||||||
|
// so we can drop the clients from the task entry
|
||||||
|
const { clients, ...copied } = taskEntry;
|
||||||
|
this.finishedTasks.set(task.id, {
|
||||||
|
...copied,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.taskPool.delete(task.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +187,7 @@ class TaskHandler {
|
|||||||
peer: PeerImpl,
|
peer: PeerImpl,
|
||||||
request: MinimumRequestObject,
|
request: MinimumRequestObject,
|
||||||
) {
|
) {
|
||||||
const task = this.taskRegistry.get(taskId);
|
const task = this.taskPool.get(taskId);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
peer.send(
|
peer.send(
|
||||||
`error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`,
|
`error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`,
|
||||||
@ -160,8 +225,8 @@ class TaskHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnectAll(id: string) {
|
disconnectAll(id: string) {
|
||||||
for (const taskId of this.taskRegistry.keys()) {
|
for (const taskId of this.taskPool.keys()) {
|
||||||
this.taskRegistry.get(taskId)?.clients.delete(id);
|
this.taskPool.get(taskId)?.clients.delete(id);
|
||||||
this.sendDisconnectEvent(id, taskId);
|
this.sendDisconnectEvent(id, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,13 +234,13 @@ class TaskHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect(id: string, taskId: string) {
|
disconnect(id: string, taskId: string) {
|
||||||
const task = this.taskRegistry.get(taskId);
|
const task = this.taskPool.get(taskId);
|
||||||
if (!task) return false;
|
if (!task) return false;
|
||||||
|
|
||||||
task.clients.delete(id);
|
task.clients.delete(id);
|
||||||
this.sendDisconnectEvent(id, taskId);
|
this.sendDisconnectEvent(id, taskId);
|
||||||
|
|
||||||
const allClientIds = this.taskRegistry
|
const allClientIds = this.taskPool
|
||||||
.values()
|
.values()
|
||||||
.toArray()
|
.toArray()
|
||||||
.map((e) => e.clients.keys().toArray())
|
.map((e) => e.clients.keys().toArray())
|
||||||
@ -187,6 +252,24 @@ class TaskHandler {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runTaskGroupByName(name: TaskGroup) {
|
||||||
|
const task = this.scheduledTasks.get(name);
|
||||||
|
if (!task) {
|
||||||
|
console.warn(`No task found for group ${name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.create(task());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**]
|
||||||
|
* Runs all daily tasks that are scheduled to run once a day.
|
||||||
|
*/
|
||||||
|
triggerDailyTasks() {
|
||||||
|
this.runTaskGroupByName("cleanup:invitations");
|
||||||
|
this.runTaskGroupByName("cleanup:sessions");
|
||||||
|
this.runTaskGroupByName("check:update");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskRunContext = {
|
export type TaskRunContext = {
|
||||||
@ -196,6 +279,7 @@ export type TaskRunContext = {
|
|||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
|
taskGroup: TaskGroup;
|
||||||
name: string;
|
name: string;
|
||||||
run: (context: TaskRunContext) => Promise<void>;
|
run: (context: TaskRunContext) => Promise<void>;
|
||||||
acls: string[];
|
acls: string[];
|
||||||
@ -215,5 +299,33 @@ export type PeerImpl = {
|
|||||||
send: (message: string) => void;
|
send: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface BuildTask {
|
||||||
|
buildId: () => string;
|
||||||
|
taskGroup: TaskGroup;
|
||||||
|
name: string;
|
||||||
|
run: (context: TaskRunContext) => Promise<void>;
|
||||||
|
acls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropTask {
|
||||||
|
taskGroup: TaskGroup;
|
||||||
|
build: () => Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defineDropTask(buildTask: BuildTask): DropTask {
|
||||||
|
// TODO: only let one task with the same taskGroup run at the same time if specified
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskGroup: buildTask.taskGroup,
|
||||||
|
build: () => ({
|
||||||
|
id: buildTask.buildId(),
|
||||||
|
taskGroup: buildTask.taskGroup,
|
||||||
|
name: buildTask.name,
|
||||||
|
run: buildTask.run,
|
||||||
|
acls: buildTask.acls,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const taskHandler = new TaskHandler();
|
export const taskHandler = new TaskHandler();
|
||||||
export default taskHandler;
|
export default taskHandler;
|
||||||
|
|||||||
24
server/internal/tasks/registry/invitations.ts
Normal file
24
server/internal/tasks/registry/invitations.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
import { defineDropTask } from "..";
|
||||||
|
|
||||||
|
export default defineDropTask({
|
||||||
|
buildId: () => `cleanup:invitations:${new Date().toISOString()}`,
|
||||||
|
name: "Cleanup Invitations",
|
||||||
|
acls: [],
|
||||||
|
taskGroup: "cleanup:invitations",
|
||||||
|
async run({ log }) {
|
||||||
|
log("Cleaning invitations");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await prisma.invitation.deleteMany({
|
||||||
|
where: {
|
||||||
|
expires: {
|
||||||
|
lt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
log("Done");
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import prisma from "~/server/internal/db/database";
|
import prisma from "~/server/internal/db/database";
|
||||||
import objectHandler from "~/server/internal/objects";
|
import objectHandler from "~/server/internal/objects";
|
||||||
import type { TaskReturn } from "../../h3";
|
import { defineDropTask } from "..";
|
||||||
|
|
||||||
type FieldReferenceMap = {
|
type FieldReferenceMap = {
|
||||||
[modelName: string]: {
|
[modelName: string]: {
|
||||||
@ -10,36 +10,33 @@ type FieldReferenceMap = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineTask<TaskReturn>({
|
export default defineDropTask({
|
||||||
meta: {
|
buildId: () => `cleanup:objects:${new Date().toISOString()}`,
|
||||||
name: "cleanup:objects",
|
name: "Cleanup Objects",
|
||||||
},
|
acls: [],
|
||||||
async run() {
|
taskGroup: "cleanup:objects",
|
||||||
console.log("[Task cleanup:objects]: Cleaning unreferenced objects");
|
async run({ progress, log }) {
|
||||||
|
log("Cleaning unreferenced objects");
|
||||||
|
|
||||||
// get all objects
|
// get all objects
|
||||||
const objects = await objectHandler.listAll();
|
const objects = await objectHandler.listAll();
|
||||||
console.log(
|
log(`searching for ${objects.length} objects`);
|
||||||
`[Task cleanup:objects]: searching for ${objects.length} objects`,
|
progress(30);
|
||||||
);
|
|
||||||
|
|
||||||
// find unreferenced objects
|
// find unreferenced objects
|
||||||
const refMap = buildRefMap();
|
const refMap = buildRefMap();
|
||||||
console.log("[Task cleanup:objects]: Building reference map");
|
log("Building reference map");
|
||||||
console.log(
|
log(`Found ${Object.keys(refMap).length} models with reference fields`);
|
||||||
`[Task cleanup:objects]: Found ${Object.keys(refMap).length} models with reference fields`,
|
log("Searching for unreferenced objects");
|
||||||
);
|
|
||||||
console.log("[Task cleanup:objects]: Searching for unreferenced objects");
|
|
||||||
const unrefedObjects = await findUnreferencedStrings(objects, refMap);
|
const unrefedObjects = await findUnreferencedStrings(objects, refMap);
|
||||||
console.log(
|
log(`found ${unrefedObjects.length} Unreferenced objects`);
|
||||||
`[Task cleanup:objects]: found ${unrefedObjects.length} Unreferenced objects`,
|
|
||||||
);
|
|
||||||
// console.log(unrefedObjects);
|
// console.log(unrefedObjects);
|
||||||
|
progress(60);
|
||||||
|
|
||||||
// remove objects
|
// remove objects
|
||||||
const deletePromises: Promise<boolean>[] = [];
|
const deletePromises: Promise<boolean>[] = [];
|
||||||
for (const obj of unrefedObjects) {
|
for (const obj of unrefedObjects) {
|
||||||
console.log(`[Task cleanup:objects]: Deleting object ${obj}`);
|
log(`Deleting object ${obj}`);
|
||||||
deletePromises.push(objectHandler.deleteAsSystem(obj));
|
deletePromises.push(objectHandler.deleteAsSystem(obj));
|
||||||
}
|
}
|
||||||
await Promise.all(deletePromises);
|
await Promise.all(deletePromises);
|
||||||
@ -47,13 +44,8 @@ export default defineTask<TaskReturn>({
|
|||||||
// Remove any possible leftover metadata
|
// Remove any possible leftover metadata
|
||||||
objectHandler.cleanupMetadata();
|
objectHandler.cleanupMetadata();
|
||||||
|
|
||||||
console.log("[Task cleanup:objects]: Done");
|
log("Done");
|
||||||
return {
|
progress(100);
|
||||||
result: {
|
|
||||||
success: true,
|
|
||||||
data: unrefedObjects,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
14
server/internal/tasks/registry/sessions.ts
Normal file
14
server/internal/tasks/registry/sessions.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import sessionHandler from "~/server/internal/session";
|
||||||
|
import { defineDropTask } from "..";
|
||||||
|
|
||||||
|
export default defineDropTask({
|
||||||
|
buildId: () => `cleanup:sessions:${new Date().toISOString()}`,
|
||||||
|
name: "Cleanup Sessions",
|
||||||
|
acls: [],
|
||||||
|
taskGroup: "cleanup:sessions",
|
||||||
|
async run({ log }) {
|
||||||
|
log("Cleaning up sessions");
|
||||||
|
await sessionHandler.cleanupSessions();
|
||||||
|
log("Done");
|
||||||
|
},
|
||||||
|
});
|
||||||
98
server/internal/tasks/registry/update.ts
Normal file
98
server/internal/tasks/registry/update.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { type } from "arktype";
|
||||||
|
import * as semver from "semver";
|
||||||
|
import { defineDropTask } from "..";
|
||||||
|
import { systemConfig } from "../../config/sys-conf";
|
||||||
|
import notificationSystem from "../../notifications";
|
||||||
|
|
||||||
|
const latestRelease = type({
|
||||||
|
url: "string", // api url for specific release
|
||||||
|
html_url: "string", // user facing url
|
||||||
|
id: "number", // release id
|
||||||
|
tag_name: "string", // tag used for release
|
||||||
|
name: "string", // release name
|
||||||
|
draft: "boolean",
|
||||||
|
prerelease: "boolean",
|
||||||
|
created_at: "string",
|
||||||
|
published_at: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineDropTask({
|
||||||
|
buildId: () => `check:update:${new Date().toISOString()}`,
|
||||||
|
name: "Check for Update",
|
||||||
|
acls: [],
|
||||||
|
taskGroup: "check:update",
|
||||||
|
async run({ log }) {
|
||||||
|
// TODO: maybe implement some sort of rate limit thing to prevent this from calling github api a bunch in the event of crashloop or whatever?
|
||||||
|
// probably will require custom task scheduler for object cleanup anyway, so something to thing about
|
||||||
|
|
||||||
|
if (!systemConfig.shouldCheckForUpdates()) {
|
||||||
|
log("Update check is disabled by configuration");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Checking for update");
|
||||||
|
|
||||||
|
const currVerStr = systemConfig.getDropVersion();
|
||||||
|
const currVer = semver.coerce(currVerStr);
|
||||||
|
if (currVer === null) {
|
||||||
|
const msg = "Drop provided a invalid semver tag";
|
||||||
|
log(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.github.com/repos/Drop-OSS/drop/releases/latest",
|
||||||
|
);
|
||||||
|
|
||||||
|
// if response failed somehow
|
||||||
|
if (!response.ok) {
|
||||||
|
log(
|
||||||
|
"Failed to check for update " +
|
||||||
|
JSON.stringify({
|
||||||
|
status: response.status,
|
||||||
|
body: response.body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Failed to check for update: ${response.status} ${response.body}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse and validate response
|
||||||
|
const resJson = await response.json();
|
||||||
|
const body = latestRelease(resJson);
|
||||||
|
if (body instanceof type.errors) {
|
||||||
|
log(body.summary);
|
||||||
|
log("GitHub Api response" + JSON.stringify(resJson));
|
||||||
|
throw new Error(
|
||||||
|
`GitHub Api response did not match expected schema: ${body.summary}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse remote version
|
||||||
|
const latestVer = semver.coerce(body.tag_name);
|
||||||
|
if (latestVer === null) {
|
||||||
|
const msg = "Github Api returned invalid semver tag";
|
||||||
|
log(msg);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: handle prerelease identifiers https://github.com/npm/node-semver#prerelease-identifiers
|
||||||
|
// check if is newer version
|
||||||
|
if (semver.gt(latestVer, currVer)) {
|
||||||
|
log("Update available");
|
||||||
|
notificationSystem.systemPush({
|
||||||
|
nonce: `drop-update-available-${currVer}-to-${latestVer}`,
|
||||||
|
title: `Update available to v${latestVer}`,
|
||||||
|
description: `A new version of Drop is available v${latestVer}`,
|
||||||
|
actions: [`View|${body.html_url}`],
|
||||||
|
acls: ["system:notifications:read"],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log("no update available");
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Done");
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,10 +1,4 @@
|
|||||||
export default defineNitroPlugin(async (_nitro) => {
|
export default defineNitroPlugin(async (_nitro) => {
|
||||||
// all tasks we should run on server boot
|
// all tasks we should run on server boot
|
||||||
await Promise.all([
|
await runTask("dailyTasks");
|
||||||
runTask("cleanup:invitations"),
|
|
||||||
runTask("cleanup:sessions"),
|
|
||||||
// TODO: maybe implement some sort of rate limit thing to prevent this from calling github api a bunch in the event of crashloop or whatever?
|
|
||||||
// probably will require custom task scheduler for object cleanup anyway, so something to thing about
|
|
||||||
runTask("check:update"),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,150 +0,0 @@
|
|||||||
import { type } from "arktype";
|
|
||||||
import { systemConfig } from "../../internal/config/sys-conf";
|
|
||||||
import * as semver from "semver";
|
|
||||||
import type { TaskReturn } from "../../h3";
|
|
||||||
import notificationSystem from "../../internal/notifications";
|
|
||||||
|
|
||||||
const latestRelease = type({
|
|
||||||
url: "string", // api url for specific release
|
|
||||||
html_url: "string", // user facing url
|
|
||||||
id: "number", // release id
|
|
||||||
tag_name: "string", // tag used for release
|
|
||||||
name: "string", // release name
|
|
||||||
draft: "boolean",
|
|
||||||
prerelease: "boolean",
|
|
||||||
created_at: "string",
|
|
||||||
published_at: "string",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default defineTask<TaskReturn>({
|
|
||||||
meta: {
|
|
||||||
name: "check:update",
|
|
||||||
},
|
|
||||||
async run() {
|
|
||||||
if (systemConfig.shouldCheckForUpdates()) {
|
|
||||||
console.log("[Task check:update]: Checking for update");
|
|
||||||
|
|
||||||
const currVerStr = systemConfig.getDropVersion();
|
|
||||||
const currVer = semver.coerce(currVerStr);
|
|
||||||
if (currVer === null) {
|
|
||||||
const msg = "Drop provided a invalid semver tag";
|
|
||||||
console.log("[Task check:update]:", msg);
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: msg,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
"https://api.github.com/repos/Drop-OSS/drop/releases/latest",
|
|
||||||
);
|
|
||||||
|
|
||||||
// if response failed somehow
|
|
||||||
if (!response.ok) {
|
|
||||||
console.log("[Task check:update]: Failed to check for update", {
|
|
||||||
status: response.status,
|
|
||||||
body: response.body,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: "" + response.status,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse and validate response
|
|
||||||
const resJson = await response.json();
|
|
||||||
const body = latestRelease(resJson);
|
|
||||||
if (body instanceof type.errors) {
|
|
||||||
console.error(body.summary);
|
|
||||||
console.log("GitHub Api response", resJson);
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: body.summary,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse remote version
|
|
||||||
const latestVer = semver.coerce(body.tag_name);
|
|
||||||
if (latestVer === null) {
|
|
||||||
const msg = "Github Api returned invalid semver tag";
|
|
||||||
console.log("[Task check:update]:", msg);
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: msg,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: handle prerelease identifiers https://github.com/npm/node-semver#prerelease-identifiers
|
|
||||||
// check if is newer version
|
|
||||||
if (semver.gt(latestVer, currVer)) {
|
|
||||||
console.log("[Task check:update]: Update available");
|
|
||||||
notificationSystem.systemPush({
|
|
||||||
nonce: `drop-update-available-${currVer}-to-${latestVer}`,
|
|
||||||
title: `Update available to v${latestVer}`,
|
|
||||||
description: `A new version of Drop is available v${latestVer}`,
|
|
||||||
actions: [`View|${body.html_url}`],
|
|
||||||
acls: ["system:notifications:read"],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log("[Task check:update]: no update available");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[Task check:update]: Done");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
if (typeof e === "string") {
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: e,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else if (e instanceof Error) {
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: e.message,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
success: false,
|
|
||||||
error: {
|
|
||||||
message: "unknown cause, please check console",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
success: true,
|
|
||||||
data: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import prisma from "~/server/internal/db/database";
|
|
||||||
|
|
||||||
export default defineTask({
|
|
||||||
meta: {
|
|
||||||
name: "cleanup:invitations",
|
|
||||||
},
|
|
||||||
async run() {
|
|
||||||
console.log("[Task cleanup:invitations]: Cleaning invitations");
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
await prisma.invitation.deleteMany({
|
|
||||||
where: {
|
|
||||||
expires: {
|
|
||||||
lt: now,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[Task cleanup:invitations]: Done");
|
|
||||||
return { result: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import sessionHandler from "~/server/internal/session";
|
|
||||||
|
|
||||||
export default defineTask({
|
|
||||||
meta: {
|
|
||||||
name: "cleanup:sessions",
|
|
||||||
},
|
|
||||||
async run() {
|
|
||||||
console.log("[Task cleanup:sessions]: Cleaning up sessions");
|
|
||||||
await sessionHandler.cleanupSessions();
|
|
||||||
console.log("[Task cleanup:sessions]: Done");
|
|
||||||
return { result: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
12
server/tasks/dailyTasks.ts
Normal file
12
server/tasks/dailyTasks.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import taskHandler from "~/server/internal/tasks";
|
||||||
|
|
||||||
|
export default defineTask({
|
||||||
|
meta: {
|
||||||
|
name: "dailyTasks",
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
taskHandler.triggerDailyTasks();
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user