Depot API & v4 (#298)

* feat: nginx + torrential basics & services system

* fix: lint + i18n

* fix: update torrential to remove openssl

* feat: add torrential to Docker build

* feat: move to self hosted runner

* fix: move off self-hosted runner

* fix: update nginx.conf

* feat: torrential cache invalidation

* fix: update torrential for cache invalidation

* feat: integrity check task

* fix: lint

* feat: move to version ids

* fix: client fixes and client-side checks

* feat: new depot apis and version id fixes

* feat: update torrential

* feat: droplet bump and remove unsafe update functions

* fix: lint

* feat: v4 featureset: emulators, multi-launch commands

* fix: lint

* fix: mobile ui for game editor

* feat: launch options

* fix: lint

* fix: remove axios, use $fetch

* feat: metadata and task api improvements

* feat: task actions

* fix: slight styling issue

* feat: fix style and lints

* feat: totp backend routes

* feat: oidc groups

* fix: update drop-base

* feat: creation of passkeys & totp

* feat: totp signin

* feat: webauthn mfa/signin

* feat: launch selecting ui

* fix: manually running tasks

* feat: update add company game modal to use new SelectorGame

* feat: executor selector

* fix(docker): update rust to rust nightly for torrential build (#305)

* feat: new version ui

* feat: move package lookup to build time to allow for deno dev

* fix: lint

* feat: localisation cleanup

* feat: apply localisation cleanup

* feat: potential i18n refactor logic

* feat: remove args from commands

* fix: lint

* fix: lockfile

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
This commit is contained in:
DecDuck
2026-01-13 15:32:39 +11:00
committed by GitHub
parent 8ef983304c
commit 63ac2b8ffc
190 changed files with 5848 additions and 2309 deletions

View File

@@ -10,7 +10,7 @@ on:
jobs: jobs:
web: web:
name: Push website Docker image to registry name: Build Docker image
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
packages: write packages: write

3
.gitmodules vendored
View File

@@ -1,3 +1,6 @@
[submodule "drop-base"] [submodule "drop-base"]
path = drop-base path = drop-base
url = https://github.com/Drop-OSS/drop-base.git url = https://github.com/Drop-OSS/drop-base.git
[submodule "torrential"]
path = torrential
url = https://github.com/Drop-OSS/torrential.git

View File

@@ -1,3 +1,5 @@
drop-base/ drop-base/
# file is fully managed by pnpm, no reason to break it # file is fully managed by pnpm, no reason to break it
pnpm-lock.yaml pnpm-lock.yaml
torrential/

5
.prettierrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"jsonRecursiveSort": true,
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
"plugins": ["prettier-plugin-sort-json"]
}

46
.vscode/settings.json vendored
View File

@@ -1,37 +1,39 @@
{ {
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"driver": "PostgreSQL",
"name": "drop",
"database": "drop",
"username": "drop",
"password": "drop"
}
],
// allow autocomplete for ArkType expressions like "string | num" // allow autocomplete for ArkType expressions like "string | num"
"editor.quickSuggestions": { "editor.quickSuggestions": {
"strings": "on" "strings": "on"
}, },
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"],
// i18n Ally settings
"i18n-ally.sortKeys": true,
"i18n-ally.keepFulfilled": true,
"i18n-ally.extract.autoDetect": true, "i18n-ally.extract.autoDetect": true,
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.extract.ignored": [ "i18n-ally.extract.ignored": [
"string >= 14", "string >= 14",
"string.alphanumeric >= 5", "string.alphanumeric >= 5",
"/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}" "/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}"
], ],
"i18n-ally.extract.ignoredByFiles": { "i18n-ally.extract.ignoredByFiles": {
"pages/admin/library/sources/index.vue": ["Filesystem"],
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"], "components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
"pages/admin/library/sources/index.vue": ["Filesystem"],
"server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"] "server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"]
} },
"i18n-ally.keepFulfilled": true,
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
// i18n Ally settings
"i18n-ally.sortKeys": true,
"prisma.pinToPrisma6": true,
"spellchecker.ignoreWordsList": ["mTLS", "Wireguard"],
"sqltools.connections": [
{
"database": "drop",
"driver": "PostgreSQL",
"name": "drop",
"password": "drop",
"port": 5432,
"previewLimit": 50,
"server": "localhost",
"username": "drop"
}
],
"typescript.experimental.useTsgo": true,
// prioritize ArkType's "type" for autoimports
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
} }

View File

@@ -6,46 +6,54 @@ ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
WORKDIR /app WORKDIR /app
# so corepack knows pnpm's version ## so corepack knows pnpm's version
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# prevent prompt to download ## prevent prompt to download
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# setup for offline ## setup for offline
RUN corepack pack RUN corepack pack
# don't call out to network anymore ## don't call out to network anymore
ENV COREPACK_ENABLE_NETWORK=0 ENV COREPACK_ENABLE_NETWORK=0
### Unified deps builder ### INSTALL DEPS ONCE
FROM base AS deps FROM base AS deps
RUN pnpm install --frozen-lockfile --ignore-scripts RUN pnpm install --frozen-lockfile --ignore-scripts
### Build for app ### BUILD TORRENTIAL
FROM rustlang/rust:nightly-alpine AS torrential-build
RUN apk add musl-dev
WORKDIR /build
COPY torrential .
RUN cargo build --release
### BUILD APP
FROM base AS build-system FROM base AS build-system
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
# add git so drop can determine its git ref at build ## add git so drop can determine its git ref at build
RUN apk add --no-cache git RUN apk add --no-cache git
# copy deps and rest of project files ## copy deps and rest of project files
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG BUILD_DROP_VERSION ARG BUILD_DROP_VERSION
ARG BUILD_GIT_REF ARG BUILD_GIT_REF
# build ## build
RUN pnpm run postinstall && pnpm run build RUN pnpm run postinstall && pnpm run build
### create run environment for Drop
# create run environment for Drop
FROM base AS run-system FROM base AS run-system
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1 # RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
RUN apk add --no-cache pnpm 7zip RUN apk add --no-cache pnpm 7zip nginx
RUN pnpm install prisma@6.11.1 RUN pnpm install prisma@6.11.1
# init prisma to download all required files # init prisma to download all required files
RUN pnpm prisma init RUN pnpm prisma init
@@ -54,8 +62,11 @@ COPY --from=build-system /app/prisma.config.ts ./
COPY --from=build-system /app/.output ./app COPY --from=build-system /app/.output ./app
COPY --from=build-system /app/prisma ./prisma COPY --from=build-system /app/prisma ./prisma
COPY --from=build-system /app/build ./startup COPY --from=build-system /app/build ./startup
COPY --from=build-system /app/build/nginx.conf /nginx.conf
COPY --from=torrential-build /build/target/release/torrential /usr/bin/
ENV LIBRARY="/library" ENV LIBRARY="/library"
ENV DATA="/data" ENV DATA="/data"
ENV NGINX_CONFIG="/nginx.conf"
CMD ["sh", "/app/startup/launch.sh"] CMD ["sh", "/app/startup/launch.sh"]

15
app.vue
View File

@@ -29,10 +29,11 @@ await updateUser();
const user = useUser(); const user = useUser();
const apiDetails = await $dropFetch("/api/v1"); const apiDetails = await $dropFetch("/api/v1");
const clientMode = isClientRequest();
const showExternalUrlWarning = ref(false); const showExternalUrlWarning = ref(false);
function checkExternalUrl() { function checkExternalUrl() {
if (!import.meta.client) return; if (!import.meta.client || clientMode) return;
const realOrigin = window.location.origin.trim(); const realOrigin = window.location.origin.trim();
const chosenOrigin = apiDetails.external.trim(); const chosenOrigin = apiDetails.external.trim();
const ignore = window.localStorage.getItem("ignoreExternalUrl"); const ignore = window.localStorage.getItem("ignoreExternalUrl");
@@ -51,15 +52,3 @@ if (user.value?.admin) {
}); });
} }
</script> </script>
<style scoped>
/* You can customise the default animation here. */
::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
}
::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
}
</style>

41
build/nginx.conf Normal file
View File

@@ -0,0 +1,41 @@
worker_processes 1;
events {
worker_connections 1024;
}
pid nginx.pid;
error_log stderr;
daemon off;
http {
default_type application/octet-stream;
sendfile on;
server_tokens off;
access_log nginx_host.access.log;
client_body_temp_path client_body;
fastcgi_temp_path fastcgi_temp;
proxy_temp_path proxy_temp;
scgi_temp_path scgi_temp;
uwsgi_temp_path uwsgi_temp;
server {
listen 8080;
server_name localhost;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /api/v1/depot/ {
proxy_pass http://localhost:5000;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
}

View File

@@ -53,10 +53,17 @@ import type { Component } from "vue";
const notifications = useNotifications(); const notifications = useNotifications();
const { t } = useI18n(); const { t } = useI18n();
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ const navigation: Ref<
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" }, (NavigationItem & { icon: Component; count?: number })[]
> = computed(() => [
{ {
label: t("security"), label: t("account.home.title"),
route: "/account",
icon: HomeIcon,
prefix: "/account",
},
{
label: t("account.security.title"),
route: "/account/security", route: "/account/security",
prefix: "/account/security", prefix: "/account/security",
icon: LockClosedIcon, icon: LockClosedIcon,
@@ -67,6 +74,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
prefix: "/account/devices", prefix: "/account/devices",
icon: DevicePhoneMobileIcon, icon: DevicePhoneMobileIcon,
}, },
{
label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{ {
label: t("account.notifications.notifications"), label: t("account.notifications.notifications"),
route: "/account/notifications", route: "/account/notifications",
@@ -74,19 +87,13 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
icon: BellIcon, icon: BellIcon,
count: notifications.value.length, count: notifications.value.length,
}, },
{
label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{ {
label: t("account.settings"), label: t("account.settings"),
route: "/account/settings", route: "/account/settings",
prefix: "/account/settings", prefix: "/account/settings",
icon: WrenchScrewdriverIcon, icon: WrenchScrewdriverIcon,
}, },
]; ]);
const currentPageIndex = useCurrentNavigationIndex(navigation); const currentPageIndex = useCurrentNavigationIndex(navigation.value);
</script> </script>

View File

@@ -12,7 +12,7 @@
v-model="username" v-model="username"
name="username" name="username"
type="username" type="username"
autocomplete="username" autocomplete="username webauthn"
required required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/> />
@@ -86,25 +86,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid"; import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { UserModel } from "~/prisma/client/models"; import {
startAuthentication,
browserSupportsWebAuthn,
} from "@simplewebauthn/browser";
import type { FetchError } from "ofetch";
const username = ref(""); const username = ref("");
const password = ref(""); const password = ref("");
const rememberMe = ref(false); const rememberMe = ref(false);
const loading = ref(false); const loading = ref(false);
async function passkeyAutofill() {
const silentWebauthnOptions = await $dropFetch("/api/v1/auth/passkey/start", {
method: "POST",
});
const result = await startAuthentication({
optionsJSON: silentWebauthnOptions,
useBrowserAutofill: true,
});
loading.value = true;
await $dropFetch("/api/v1/auth/passkey/finish", {
method: "POST",
body: result,
});
await completeSignin();
}
onMounted(async () => {
if (browserSupportsWebAuthn()) {
try {
await passkeyAutofill();
} catch (response) {
const message =
(response as FetchError).statusMessage || t("errors.unknown");
error.value = message;
} finally {
loading.value = false;
}
}
});
const error = ref<string | undefined>(); const error = ref<string | undefined>();
const route = useRoute();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
function signin_wrapper() { function signin_wrapper() {
loading.value = true; loading.value = true;
signin() signin()
.then(() => {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => { .catch((response) => {
const message = response.statusMessage || t("errors.unknown"); const message = response.statusMessage || t("errors.unknown");
error.value = message; error.value = message;
@@ -115,7 +150,7 @@ function signin_wrapper() {
} }
async function signin() { async function signin() {
await $dropFetch("/api/v1/auth/signin/simple", { const { result } = await $dropFetch("/api/v1/auth/signin/simple", {
method: "POST", method: "POST",
body: { body: {
username: username.value, username: username.value,
@@ -123,7 +158,11 @@ async function signin() {
rememberMe: rememberMe.value, rememberMe: rememberMe.value,
}, },
}); });
const user = useUser(); if (result == "2fa") {
user.value = await $dropFetch<UserModel | null>("/api/v1/user"); router.push({ query: route.query, path: "/auth/mfa" });
return;
}
await completeSignin();
} }
</script> </script>

86
components/CodeInput.vue Normal file
View File

@@ -0,0 +1,86 @@
<template>
<input
v-for="i in length"
ref="codeElements"
:key="i"
v-model="code[i - 1]"
:class="[
size,
'uppercase appearance-none text-center bg-zinc-900 rounded-xl border-zinc-700 focus:border-blue-600 text-bold font-display text-zinc-100',
]"
type="text"
pattern="\d*"
:placeholder="placeholder[i - 1]"
@keydown="(v) => keydown(i - 1, v)"
@input="() => input(i - 1)"
@focusin="() => select(i - 1)"
@paste="(v) => paste(i - 1, v)"
/>
</template>
<script setup lang="ts">
const {
length = 7,
placeholder = "1A2B3C4",
size = "w-16 h-16 text-2xl",
} = defineProps<{
length?: number;
placeholder?: string;
size?: string;
}>();
const emit = defineEmits<{
(e: "complete", code: string): void;
}>();
const codeElements = useTemplateRef("codeElements");
const code = ref<string[]>([]);
function keydown(index: number, event: KeyboardEvent) {
if (event.key === "Backspace" && !code.value[index] && index > 0) {
codeElements.value![index - 1].focus();
}
}
function input(index: number) {
if (codeElements.value === null) return;
const v = code.value[index] ?? "";
if (v.length > 1) code.value[index] = v[0];
if (!(index + 1 >= codeElements.value.length) && v) {
codeElements.value[index + 1].focus();
}
if (!(index - 1 < 0) && !v) {
codeElements.value[index - 1].focus();
}
if (index == length - 1) {
const assembledCode = code.value.join("");
if (assembledCode.length == length) {
complete(assembledCode);
}
}
}
function select(index: number) {
if (!codeElements.value) return;
if (index >= codeElements.value.length) return;
codeElements.value[index].select();
}
function paste(index: number, event: ClipboardEvent) {
const newCode = event.clipboardData!.getData("text/plain");
for (let i = 0; i < newCode.length && i < length; i++) {
code.value[i] = newCode[i];
codeElements.value![i].focus();
if (i + 1 == length) {
complete(code.value.join(""));
}
}
event.preventDefault();
}
async function complete(completedCode: string) {
emit("complete", completedCode);
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div
v-if="executor"
class="flex space-x-4 rounded-md bg-zinc-900/50 px-6 outline -outline-offset-1 outline-white/10 w-fit text-xs font-bold text-zinc-100"
>
<div class="inline-flex gap-x-2 items-center">
<img :src="executor.gameIcon" class="size-6" />
<span>{{ executor.gameName }}</span>
</div>
<div class="flex items-center">
<svg
class="h-full w-6 shrink-0 text-white/10"
viewBox="0 0 24 44"
preserveAspectRatio="none"
fill="currentColor"
aria-hidden="true"
>
<path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z" />
</svg>
<span class="ml-4">{{ executor.versionName }}</span>
</div>
<div class="flex items-center">
<svg
class="h-full w-6 shrink-0 text-white/10"
viewBox="0 0 24 44"
preserveAspectRatio="none"
fill="currentColor"
aria-hidden="true"
>
<path d="M.293 0l22 22-22 22h1.414l22-22-22-22H.293z" />
</svg>
<span class="ml-4 truncate">{{ executor.launchName }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { ExecutorLaunchObject } from "~/composables/frontend";
defineProps<{ executor: ExecutorLaunchObject }>();
</script>

View File

@@ -1,7 +1,7 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<div v-if="game!"> <div v-if="game!">
<div class="grow flex flex-row gap-y-8"> <div class="grow flex flex-col xl:flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col"> <div class="grow w-full h-full px-6 py-4 flex flex-col">
<div <div
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2" class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
@@ -10,10 +10,12 @@
<!-- icon image --> <!-- icon image -->
<img :src="coreMetadataIconUrl" class="size-20" /> <img :src="coreMetadataIconUrl" class="size-20" />
<div> <div>
<h1 class="text-5xl font-bold font-display text-zinc-100"> <h1
class="text-2xl xl:text-5xl font-bold font-display text-zinc-100"
>
{{ game.mName }} {{ game.mName }}
</h1> </h1>
<p class="mt-1 text-lg text-zinc-400"> <p class="mt-1 text-sm xl:text-lg text-zinc-400">
{{ game.mShortDescription }} {{ game.mShortDescription }}
</p> </p>
</div> </div>
@@ -28,7 +30,7 @@
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" /> <SelectorMultiItem v-model="currentTags" :items="tags" />
<div class="flex flex-col"> <div class="flex flex-col">
<label <label
for="releaseDate" for="releaseDate"
@@ -461,7 +463,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { GameModel, GameTagModel } from "~/prisma/client/models"; import type { GameModel } from "~/prisma/client/models";
import { micromark } from "micromark"; import { micromark } from "micromark";
import { import {
CheckIcon, CheckIcon,
@@ -471,6 +473,7 @@ import {
} from "@heroicons/vue/24/solid"; } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3"; import type { H3Error } from "h3";
import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get";
const showUploadModal = ref(false); const showUploadModal = ref(false);
const showAddCarouselModal = ref(false); const showAddCarouselModal = ref(false);
@@ -478,8 +481,9 @@ const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false); const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true); const mobileShowFinalDescription = ref(true);
type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>; const game = defineModel<SerializeObject<AdminFetchGameType>>({
const game = defineModel<ModelType>() as Ref<ModelType>; required: true,
});
if (!game.value) if (!game.value)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,

View File

@@ -1,97 +1,146 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<div v-if="game && unimportedVersions"> <div v-if="game && unimportedVersions" class="px-4 sm:px-6 lg:px-8 py-8">
<div class="grow flex flex-row gap-y-8"> <div class="sm:flex sm:items-center">
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div> <div class="sm:flex-auto">
<div <h1 class="text-base font-semibold text-white">Versions</h1>
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4" <p class="mt-2 text-sm text-gray-300">
> Versions versions version, versions versions. Versions.
<!-- version manager --> </p>
<div> </div>
<!-- version priority --> <div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<div> <NuxtLink
<div class="border-b border-zinc-800 pb-3"> :href="canImport ? `/admin/library/${game.id}/import` : ''"
<div type="button"
class="flex flex-wrap items-center justify-between sm:flex-nowrap" :class="[
> canImport ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-800/50',
<h3 'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
class="text-base font-semibold font-display leading-6 text-zinc-100" ]"
>
{{
canImport
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="relative min-w-full divide-y divide-white/15">
<thead>
<tr>
<th></th>
<th
scope="col"
class="py-3 pr-3 pl-4 text-left text-xs font-medium tracking-wide text-gray-400 uppercase sm:pl-0"
> >
{{ $t("library.admin.versionPriority") }} Name (ID)
</th>
<!-- import games button --> <th
scope="col"
<NuxtLink class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
:href="canImport ? `/admin/library/${game.id}/import` : ''" >
type="button" Path
:class="[ </th>
canImport <th
? 'bg-blue-600 hover:bg-blue-700' scope="col"
: 'bg-blue-800/50', class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600', >
]" Setup Configurations
> </th>
{{ <th
canImport scope="col"
? $t("library.admin.import.version.import") class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
: $t("library.admin.import.version.noVersions") >
}} Launch Configurations
</NuxtLink> </th>
</h3> <th scope="col" class="py-3 pr-4 pl-3 sm:pr-0">
</div> <span class="sr-only">Edit</span>
</div> </th>
</tr>
<div class="mt-4 text-center w-full text-sm text-zinc-600"> </thead>
{{ $t("lowest") }}
</div>
<draggable <draggable
:list="game.versions" :list="game.versions"
handle=".handle" handle=".handle"
class="mt-2 space-y-4" class="divide-y divide-white/10"
tag="tbody"
@update="() => updateVersionOrder()" @update="() => updateVersionOrder()"
> >
<template <template #item="{ element: version }: { element: VersionType }">
#item="{ element: item }: { element: GameVersionModelWithSize }" <tr :key="version.versionId">
> <td>
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between w-full flex"
>
<div class="text-zinc-100 font-semibold flex-none">
{{ item.versionName }}
</div>
<div
class="text-right text-zinc-400 text-xs font-normal flex-auto pr-4"
>
{{ item.size && formatBytes(item.size) }}
</div>
<div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon <Bars3Icon
class="cursor-move w-6 h-6 text-zinc-400 handle" class="cursor-move w-6 h-6 text-zinc-400 handle"
/> />
<button @click="() => deleteVersion(item.versionName)"> </td>
<TrashIcon class="w-5 h-5 text-red-600" /> <td class="py-4 pr-3 pl-4 sm:pl-0">
<div class="flex flex-col">
<span
class="text-sm font-medium whitespace-nowrap text-white"
>{{ version.displayName ?? version.versionPath }}</span
>
<span class="text-xs text-zinc-500 mono">{{
version.versionId
}}</span>
</div>
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
{{ version.versionPath }}
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
<ul class="space-y-2">
<GameEditorVersionConfig
v-for="config in version.setups"
:key="config.setupId"
:config="config"
/>
<li
v-if="version.setups.length == 0"
class="text-xs uppercase font-display text-zinc-700 font-semibold"
>
No setups configured.
</li>
</ul>
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
<div v-if="version.onlySetup">
Version configured as in setup-only mode.
</div>
<ul v-else class="space-y-2">
<GameEditorVersionConfig
v-for="config in version.launches"
:key="config.launchId"
:config="config"
/>
</ul>
</td>
<td
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0 space-x-2"
>
<!--
<button class="text-blue-400 hover:text-blue-300">
Edit<span class="sr-only"
>,
{{ version.displayName ?? version.versionPath }}</span
>
</button> </button>
</div> -->
</div> <button
</template> class="text-red-400 hover:text-red-300"
@click="() => deleteVersion(version.versionId)"
>
Delete<span class="sr-only"
>,
{{ version.displayName ?? version.versionPath }}</span
>
</button>
</td>
</tr></template
>
</draggable> </draggable>
<div </table>
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -117,12 +166,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { GameModel, GameVersionModel } from "~/prisma/client/models";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3"; import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline"; import { ExclamationCircleIcon, Bars3Icon } from "@heroicons/vue/24/outline";
import { formatBytes } from "~/server/internal/utils/files"; import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get";
// TODO implement UI for this page // TODO implement UI for this page
@@ -136,29 +183,34 @@ const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0, () => hasDeleted.value || props.unimportedVersions.length > 0,
); );
type GameVersionModelWithSize = GameVersionModel & { size: number }; const game = defineModel<SerializeObject<AdminFetchGameType>>({
required: true,
type GameAndVersions = GameModel & { });
versions: GameVersionModelWithSize[];
};
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions>
>;
if (!game.value) if (!game.value)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "Game not provided to editor component", statusMessage: "Game not provided to editor component",
}); });
type VersionType = (typeof game.value.versions)[number];
async function updateVersionOrder() { async function updateVersionOrder() {
try { try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", { const newVersionOrder = await $dropFetch(
method: "PATCH", "/api/v1/admin/game/:id/versions",
body: { {
id: game.value.id, method: "PATCH",
versions: game.value.versions.map((e) => e.versionName), body: {
versions: game.value.versions.map((e) => e.versionId),
},
params: {
id: game.value.id,
},
}, },
}); );
const newVersions = newVersionOrder.map(
(id) => game.value.versions.find((k) => k.versionId == id)!,
);
game.value.versions = newVersions; game.value.versions = newVersions;
} catch (e) { } catch (e) {
createModal( createModal(
@@ -175,17 +227,19 @@ async function updateVersionOrder() {
} }
} }
async function deleteVersion(versionName: string) { async function deleteVersion(versionId: string) {
try { try {
await $dropFetch("/api/v1/admin/game/version", { await $dropFetch("/api/v1/admin/game/:id/versions", {
method: "DELETE", method: "DELETE",
body: { body: {
version: versionId,
},
params: {
id: game.value.id, id: game.value.id,
versionName: versionName,
}, },
}); });
game.value.versions.splice( game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName), game.value.versions.findIndex((e) => e.versionId === versionId),
1, 1,
); );
hasDeleted.value = true; hasDeleted.value = true;

View File

@@ -0,0 +1,58 @@
<template>
<li class="p-3 bg-zinc-800 ring-1 ring-zinc-700 shadow rounded-lg space-y-2">
<div class="flex justify-between">
<h1
v-if="!isSetup(props.config)"
class="font-semibold text-zinc-300 text-md"
>
{{ props.config.name }}
</h1>
<span class="flex items-center">
<component
:is="PLATFORM_ICONS[props.config.platform]"
alt=""
class="size-5 flex-shrink-0 text-blue-600"
/>
<span class="ml-2 block truncate text-zinc-100 text-sm font-bold">{{
props.config.platform
}}</span>
</span>
</div>
<div
class="inline-flex gap-x-1 items-center bg-zinc-950 text-zinc-400 mono rounded-md p-2"
>
<p>{{ props.config.command }}</p>
</div>
<ExecutorWidget
v-if="!isSetup(props.config) && props.config.executor"
:executor="{
launchId: props.config.launchId,
gameName: props.config.executor.gameVersion.game.mName,
gameIcon: useObject(
props.config.executor.gameVersion.game.mIconObjectId,
),
versionName:
props.config.executor.gameVersion.displayName ??
props.config.executor.gameVersion.versionPath,
launchName: props.config.executor.name,
platform: props.config.executor.platform,
}"
/>
</li>
</template>
<script setup lang="ts">
import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get";
const props = defineProps<{
config:
| AdminFetchGameType["versions"][number]["setups"][number]
| AdminFetchGameType["versions"][number]["launches"][number];
}>();
function isSetup(
v: typeof props.config,
): v is AdminFetchGameType["versions"][number]["setups"][number] {
return Object.prototype.hasOwnProperty.call(v, "setupId");
}
</script>

View File

@@ -0,0 +1,253 @@
<template>
<div class="w-full">
<div v-if="needsName" class="mb-2">
<div
class="flex w-full rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<input
id="startup"
v-model="launchConfiguration.name"
type="text"
name="startup"
class="block flex-1 border-0 py-1.5 px-3 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="Launch name"
/>
</div>
</div>
<div class="mb-2">
<div
class="flex w-full rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center gap-x-0.5 pl-3 text-zinc-500 sm:text-sm"
>
<div class="relative">
<InformationCircleIcon class="peer size-4" />
<div
class="z-50 w-64 transition duration-100 opacity-0 shadow peer-hover:opacity-100 absolute left-0 p-2 bg-zinc-900 rounded text-xs text-zinc-300"
>
The installation directory is set as the current directory when
launching. It is not prepended to your command.
</div>
</div>
{{ $t("library.admin.import.version.installDir") }}
</span>
<Combobox
as="div"
:value="launchConfiguration.launch"
nullable
class="w-full"
@update:model-value="(v) => updateLaunchCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 w-full bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="
$t('library.admin.import.version.launchPlaceholder')
"
@change="launchProcessQuery = $event.target.value"
@blur="launchProcessQuery = ''"
/>
<ComboboxButton
v-if="launchFilteredVersionGuesses?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon
class="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-for="guess in launchFilteredVersionGuesses"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<component
:is="PLATFORM_ICONS[guess.platform]"
class="size-5"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="
launchProcessQuery &&
launchConfiguration.launch !== launchProcessQuery
"
v-slot="{ active, selected }"
:value="launchProcessQuery"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ launchProcessQuery }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</div>
</div>
<SelectorPlatform
:model-value="launchConfiguration.platform"
class="mb-2"
@update:model-value="updatePlatform"
>
{{ $t("library.admin.import.version.platform") }}
</SelectorPlatform>
<div>
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
Executor
</h1>
<div class="relative mt-2 space-x-1 inline-flex items-center w-full">
<ExecutorWidget v-if="executor" :executor="executor" />
<div
v-else
class="font-bold uppercase font-display text-zinc-500 text-sm"
>
No executor selected
</div>
<div class="grow" />
<LoadingButton :loading="false" @click="selectLaunchOpen = true"
>Select new executor</LoadingButton
>
<button
:disabled="!executor"
class="transition rounded p-2 bg-zinc-900/30 group hover:enabled:bg-red-600/10 text-zinc-400 hover:enabled:text-red-600 disabled:bg-zinc-900/80 disabled:text-zinc-700"
@click="() => (executor = undefined)"
>
<TrashIcon class="transition size-5" />
</button>
</div>
</div>
<ModalSelectLaunch
v-model="selectLaunchOpen"
class="-mt-2"
:filter-platform="launchConfiguration.platform"
@select="(v) => (executor = v)"
/>
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { InformationCircleIcon, TrashIcon } from "@heroicons/vue/24/outline";
import type { ExecutorLaunchObject } from "~/composables/frontend";
import type { Platform } from "~/prisma/client/enums";
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
const launchProcessQuery = ref("");
const launchConfiguration = defineModel<
Omit<(typeof ImportVersion.infer)["launches"][number], "name"> & {
name?: string;
}
>({ required: true });
const _executorMetadata = ref<ExecutorLaunchObject | undefined>(undefined);
const executor = computed({
get() {
return _executorMetadata.value;
},
set(v) {
_executorMetadata.value = v;
if (v) {
launchConfiguration.value.executorId = v.launchId;
} else {
launchConfiguration.value.executorId = undefined;
}
},
});
function updatePlatform(v: Platform | undefined) {
if (!v) return;
launchConfiguration.value.platform = v;
if (executor.value) {
if (executor.value.platform !== v) {
executor.value = undefined;
}
}
}
const props = defineProps<{
versionGuesses: Array<{ platform: Platform; filename: string }> | undefined;
needsName: boolean;
}>();
const selectLaunchOpen = ref(false);
const launchFilteredVersionGuesses = computed(() =>
props.versionGuesses?.filter((e) =>
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
),
);
function updateLaunchCommand(command: string) {
launchConfiguration.value.launch = command;
if (launchConfiguration.value.platform === undefined) {
const autosetGuess = props.versionGuesses?.find(
(v) => v.filename == command,
);
if (autosetGuess) {
launchConfiguration.value.platform = autosetGuess.platform;
}
}
}
</script>

View File

@@ -11,66 +11,7 @@
</div> </div>
<div class="mt-2"> <div class="mt-2">
<form @submit.prevent="() => addGame()"> <form @submit.prevent="() => addGame()">
<Listbox v-model="currentGame" as="div"> <SelectorGame v-model="currentGame" :search="search" />
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<GameSearchResultWidget
v-if="currentGame"
:game="currentGame"
/>
<span v-else class="block truncate text-zinc-600">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="result in metadataGames"
:key="result.id"
v-slot="{ active }"
as="template"
:value="result"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<GameSearchResultWidget :game="result" />
</li>
</ListboxOption>
<p
v-if="metadataGames.length == 0"
class="w-full text-center p-2 uppercase font-display text-zinc-700 font-bold"
>
{{ $t("library.admin.metadata.companies.addGame.noGames") }}
</p>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="mt-6 flex items-center justify-between gap-3"> <div class="mt-6 flex items-center justify-between gap-3">
<label <label
id="published-label" id="published-label"
@@ -163,18 +104,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import type { GameModel } from "~/prisma/client/models"; import type { GameModel } from "~/prisma/client/models";
import { import { DialogTitle } from "@headlessui/vue";
DialogTitle,
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import { FetchError } from "ofetch"; import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid"; import { XCircleIcon } from "@heroicons/vue/24/solid";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const props = defineProps<{ const props = defineProps<{
companyId: string; companyId: string;
@@ -189,26 +123,11 @@ const emit = defineEmits<{
]; ];
}>(); }>();
const games = await $dropFetch("/api/v1/admin/game");
const metadataGames = computed(() =>
games
.filter((e) => !(props.exclude ?? []).includes(e.id))
.map(
(e) =>
({
id: e.id,
name: e.mName,
icon: useObject(e.mIconObjectId),
description: e.mShortDescription,
}) satisfies Omit<GameMetadataSearchResult, "year">,
),
);
const { t } = useI18n(); const { t } = useI18n();
const open = defineModel<boolean>({ required: true }); const open = defineModel<boolean>({ required: true });
const currentGame = ref<(typeof metadataGames.value)[number]>(); const currentGame = ref<GameMetadataSearchResult>();
const developed = ref(false); const developed = ref(false);
const published = ref(false); const published = ref(false);
const addGameLoading = ref(false); const addGameLoading = ref(false);
@@ -243,4 +162,8 @@ async function addGame() {
open.value = false; open.value = false;
} }
} }
async function search(query: string) {
return await $dropFetch("/api/v1/admin/search/game", { query: { q: query } });
}
</script> </script>

View File

@@ -0,0 +1,229 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<h1 as="h3" class="text-lg font-medium leading-6 text-white">
Pick a launch option
</h1>
<p class="mt-1 text-zinc-400 text-sm">
Select a launch option as an executor for your new launch option.
</p>
<div
v-if="props.filterPlatform"
class="inline-flex items-center mt-2 gap-x-4"
>
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
Only showing launches for:
</h1>
<span class="flex items-center">
<component
:is="PLATFORM_ICONS[props.filterPlatform]"
alt=""
class="size-5 flex-shrink-0 text-blue-600"
/>
<span class="ml-2 block truncate text-zinc-100 text-sm font-bold">{{
props.filterPlatform
}}</span>
</span>
</div>
</div>
<div class="mt-2 space-y-4">
<div
class="inline-flex items-center w-full gap-x-2 text-zinc-100 font-bold"
>
Game:
<SelectorGame
:search="search"
:model-value="game"
class="w-full"
@update:model-value="(value) => updateGame(value)"
/>
</div>
<div
v-if="versions !== undefined && Object.entries(versions).length == 0"
class="text-zinc-300 text-sm font-bold font-display uppercase text-center w-full"
>
No versions imported.
</div>
<div
v-else-if="versions !== undefined"
class="inline-flex items-center w-full gap-x-2 text-zinc-100 font-bold"
>
Version:
<SelectorCombox
:search="
(v) =>
Object.values(versions!)
.filter((k) =>
(k.displayName || k.versionPath)
.toLowerCase()
.includes(v.toLowerCase()),
)
.map((v) => ({
id: v.versionId,
name: v.displayName ?? v.versionPath,
}))
"
:display="(v) => v.name"
:model-value="version"
class="w-full"
@update:model-value="updateVersion"
>
<template #default="{ value }">
{{ value.name }}
</template>
</SelectorCombox>
</div>
<div
v-if="versions && version"
class="inline-flex items-center w-full gap-x-2 text-zinc-100 font-bold"
>
Launch:
<SelectorCombox
:search="
(v) =>
versions![version!.id].launches
.filter(
(k) =>
(k.name || k.command)
.toLowerCase()
.includes(v.toLowerCase()) &&
(props.filterPlatform
? k.platform == props.filterPlatform
: true),
)
.map((v) => ({
id: v.launchId,
...v,
}))
"
:display="(v) => v.name"
:model-value="launchId"
class="w-full"
@update:model-value="(v) => (launchId = v)"
>
<template #default="{ value }">
<div class="flex flex-col">
<span class="text-zinc-300 text-sm">
{{ value.name }}
</span>
<span class="text-zinc-400 text-xs">{{ value.command }}</span>
</div>
</template>
</SelectorCombox>
</div>
</div>
<div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton :loading="false" :disabled="!launchId" @click="submit">
Select
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (open = false)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/outline";
import type { ExecutorLaunchObject } from "~/composables/frontend";
import type { Platform } from "~/prisma/client/enums";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const props = defineProps<{ filterPlatform?: Platform }>();
const open = defineModel<boolean>({ required: true });
const error = ref<string | undefined>();
const game = ref<GameMetadataSearchResult | undefined>(undefined);
const version = ref<{ id: string; name: string } | undefined>(undefined);
const launchId = ref<
{ id: string; name: string; command: string; platform: Platform } | undefined
>(undefined);
const versions = ref<
| {
[key: string]: {
displayName: string | null;
launches: {
launchId: string;
command: string;
name: string;
platform: Platform;
}[];
versionId: string;
versionPath: string;
};
}
| undefined
>(undefined);
const emit = defineEmits<{
select: [data: ExecutorLaunchObject];
}>();
async function search(query: string) {
return await $dropFetch("/api/v1/admin/search/game", { query: { q: query } });
}
function updateGame(value: GameMetadataSearchResult | undefined) {
if (game.value !== value || value == undefined) {
version.value = undefined;
versions.value = undefined;
launchId.value = undefined;
}
game.value = value;
if (game.value) fetchVersions();
}
async function fetchVersions() {
const newVersions = await $dropFetch("/api/v1/admin/game/:id/versions", {
params: { id: game.value!.id },
failTitle: "Failed to fetch versions for launch picker",
});
versions.value = Object.fromEntries(newVersions.map((v) => [v.versionId, v]));
}
function updateVersion(v: typeof version.value) {
if (version.value !== v || v == undefined) {
launchId.value = undefined;
}
version.value = v;
}
function submit() {
emit("select", {
launchId: launchId.value!.id,
gameName: game.value!.name,
gameIcon: game.value!.icon,
versionName: version.value!.name,
launchName: launchId.value!.name,
platform: launchId.value!.platform,
});
open.value = false;
}
watch(open, () => {
game.value = undefined;
updateGame(game.value);
});
</script>

View File

@@ -24,7 +24,6 @@
> >
{{ name }} {{ name }}
</NuxtLink> </NuxtLink>
<!-- todo -->
</div> </div>
</div> </div>
<div class="ml-4 flex shrink-0"> <div class="ml-4 flex shrink-0">

View File

@@ -0,0 +1,91 @@
<template>
<Combobox
as="div"
nullable
:immediate="true"
:model-value="model"
class="bg-zinc-800 rounded"
@update:model-value="updateModelValue"
>
<div class="relative">
<ComboboxInput
:key="model?.id ?? 'off'"
class="block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="Start typing..."
:display-value="(v) => (v ? props.display(v as T) : '')"
@change="query = $event.target.value"
@blur="query = ''"
/>
<ComboboxButton
class="absolute inset-0 right-0 flex items-center justify-end rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
>
<div
v-if="results.length == 0"
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
>
No results.
</div>
<ComboboxOption
v-for="result in results"
v-else
:key="result.id"
v-slot="{ active, selected }"
:value="result"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
]"
>
<span>
<slot :value="result" />
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</template>
<script setup lang="ts" generic="T extends { id: string }">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
const props = defineProps<{
search: (query: string) => T[];
display: (value: T) => string;
}>();
const model = defineModel<T | undefined>();
const query = ref("");
const results = computed(() => props.search(query.value));
function updateModelValue(v: T) {
model.value = v;
}
</script>

View File

@@ -0,0 +1,132 @@
<template>
<Combobox
v-model="currentResult"
as="div"
nullable
class="bg-zinc-800 rounded"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="Start typing..."
:display-value="(game) => (game as GameMetadataSearchResult)?.name"
@change="gameSearchQuery = $event.target.value"
@blur="gameSearchQuery = ''"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
>
<div
v-if="gameSearchQuery.length < 4"
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
>
Type at least 4 characters to get results
</div>
<div
v-else-if="resultsLoading || results === undefined"
class="flex items-center justify-center p-2"
>
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-zinc-100"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
<div
v-else-if="results.length == 0"
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
>
No results.
</div>
<ComboboxOption
v-for="result in results"
v-else
:key="result.id"
v-slot="{ active, selected }"
:value="result"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white outline-none' : 'text-zinc-100',
]"
>
<span>
<GameSearchResultWidget :game="result" />
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const props = defineProps<{
search: (query: string) => Promise<Array<GameMetadataSearchResult>>;
}>();
const currentResult = defineModel<GameMetadataSearchResult | undefined>();
const gameSearchQuery = ref("");
const resultsLoading = ref(false);
const results = ref<Array<GameMetadataSearchResult>>();
let timeout: NodeJS.Timeout | undefined = undefined;
watch(gameSearchQuery, async (v) => {
if (v.length < 4) {
results.value = [];
resultsLoading.value = false;
return;
}
if (timeout) clearTimeout(timeout);
resultsLoading.value = true;
timeout = setTimeout(async () => {
const newResults = await props.search(v);
results.value = newResults.map((v) => ({ ...v, icon: useObject(v.icon) }));
resultsLoading.value = false;
timeout = undefined;
}, 600);
});
</script>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<LanguageSelectorListbox /> <SelectorLanguageListbox />
<NuxtLink <NuxtLink
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm" class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
to="https://translate.droposs.org/engage/drop/" to="https://translate.droposs.org/engage/drop/"

View File

@@ -39,7 +39,7 @@
@blur="search = ''" @blur="search = ''"
/> />
<ComboboxButton <ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden" class="absolute inset-0 flex items-center justify-end rounded-r-md px-2 focus:outline-hidden"
> >
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" /> <ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton> </ComboboxButton>

View File

@@ -32,7 +32,7 @@
class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-950 ring-opacity-5 focus:outline-none sm:text-sm" class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-950 ring-opacity-5 focus:outline-none sm:text-sm"
> >
<ListboxOption <ListboxOption
v-for="[name, value] in Object.entries(values)" v-for="[name, value] in values"
:key="value" :key="value"
v-slot="{ active, selected }" v-slot="{ active, selected }"
as="template" as="template"
@@ -82,10 +82,11 @@ import {
ListboxOptions, ListboxOptions,
} from "@headlessui/vue"; } from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { Platform } from "~/prisma/client/enums";
const model = defineModel<PlatformClient | undefined>(); const model = defineModel<Platform | undefined>();
const typedModel = computed<PlatformClient | null>({ const typedModel = computed<Platform | null>({
get() { get() {
return model.value || null; return model.value || null;
}, },
@@ -95,5 +96,5 @@ const typedModel = computed<PlatformClient | null>({
}, },
}); });
const values = Object.fromEntries(Object.entries(PlatformClient)); const values = Object.entries(Platform);
</script> </script>

View File

@@ -56,18 +56,14 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr v-for="(source, sourceIdx) in sources" :key="source.id">
v-for="(source, sourceIdx) in sources"
:key="source.id"
class="even:bg-zinc-800"
>
<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"
> >
{{ source.name }} {{ source.name }}
</td> </td>
<td <td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center" class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 flex gap-x-1 items-center"
> >
<component <component
:is="optionsMetadata[source.backend].icon" :is="optionsMetadata[source.backend].icon"

View File

@@ -127,7 +127,7 @@
> >
</div> </div>
</div> </div>
<MultiItemSelector <SelectorMultiItem
v-else v-else
v-model="[optionValues[section.param] as any][0]" v-model="[optionValues[section.param] as any][0]"
:items="section.options" :items="section.options"
@@ -189,7 +189,11 @@
> >
{{ option.name }} {{ option.name }}
<span v-if="currentSort === option.param"> <span v-if="currentSort === option.param">
{{ sortOrder === "asc" ? $t("↑") : $t("↓") }} {{
sortOrder === "asc"
? $t("chars.arrowUp")
: $t("chars.arrowDown")
}}
</span> </span>
</button> </button>
</MenuItem> </MenuItem>
@@ -291,7 +295,7 @@
> >
</div> </div>
</div> </div>
<MultiItemSelector <SelectorMultiItem
v-else v-else
v-model="[optionValues[section.param] as any][0]" v-model="[optionValues[section.param] as any][0]"
:items="section.options" :items="section.options"
@@ -304,7 +308,7 @@
<div <div
v-if="games?.length ?? 0 > 0" v-if="games?.length ?? 0 > 0"
ref="product-grid" ref="product-grid"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4" class="col-span-4 grid gap-5 grid-cols-[repeat(auto-fill,minmax(150px,auto))]"
> >
<!-- Your content --> <!-- Your content -->
<GamePanel <GamePanel
@@ -372,7 +376,7 @@ import {
} from "@heroicons/vue/20/solid"; } from "@heroicons/vue/20/solid";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { GameModel, GameTagModel } from "~/prisma/client/models"; import type { GameModel, GameTagModel } from "~/prisma/client/models";
import MultiItemSelector from "./MultiItemSelector.vue"; import { Platform } from "~/prisma/client/enums";
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`); const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const mobileFiltersOpen = ref(false); const mobileFiltersOpen = ref(false);
@@ -424,7 +428,7 @@ const options: Array<StoreFilterOption> = [
name: "Platform", name: "Platform",
param: "platform", param: "platform",
multiple: true, multiple: true,
options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })), options: Object.values(Platform).map((e) => ({ name: e, param: e })),
}, },
...(props.extraOptions ?? []), ...(props.extraOptions ?? []),
]; ];

View File

@@ -29,6 +29,15 @@
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm"> <div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" /> <LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
</div> </div>
<ul v-if="task.actions" class="mt-1 flex flex-row gap-x-2">
<NuxtLink
v-for="[name, link] in task.actions.map((v) => v.split(':'))"
:key="link"
:href="link"
class="text-xs text-zinc-100 bg-blue-900 p-1 rounded"
>{{ name }}</NuxtLink
>
</ul>
<NuxtLink <NuxtLink
type="button" type="button"
:href="`/admin/task/${task.id}`" :href="`/admin/task/${task.id}`"

View File

@@ -10,7 +10,7 @@
{{ $t("drop.desc") }} {{ $t("drop.desc") }}
</p> </p>
<LanguageSelector /> <SelectorLanguage />
<div class="flex space-x-6"> <div class="flex space-x-6">
<NuxtLink <NuxtLink

22
composables/frontend.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
import type {
ComponentCustomOptions as _ComponentCustomOptions,
ComponentCustomProperties as _ComponentCustomProperties,
} from "vue";
import type { Platform } from "~/prisma/client/enums";
declare module "@vue/runtime-core" {
interface ComponentCustomProperties extends _ComponentCustomProperties {
$t: (key: string, ...args: unknown[]) => string;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ComponentCustomOptions extends _ComponentCustomOptions {}
}
export interface ExecutorLaunchObject {
launchId: string;
gameName: string;
gameIcon: string;
versionName: string;
launchName: string;
platform: Platform;
}

View File

@@ -1,8 +1,8 @@
import { IconsLinuxLogo, IconsWindowsLogo, IconsMacLogo } from "#components"; import { IconsLinuxLogo, IconsWindowsLogo, IconsMacLogo } from "#components";
import { PlatformClient } from "./types"; import { Platform } from "~/prisma/client/enums";
export const PLATFORM_ICONS = { export const PLATFORM_ICONS = {
[PlatformClient.Linux]: IconsLinuxLogo, [Platform.Linux]: IconsLinuxLogo,
[PlatformClient.Windows]: IconsWindowsLogo, [Platform.Windows]: IconsWindowsLogo,
[PlatformClient.macOS]: IconsMacLogo, [Platform.macOS]: IconsMacLogo,
}; };

1
composables/kjua.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "kjua";

View File

@@ -16,7 +16,7 @@ interface DropFetch<
O extends NitroFetchOptions<R> = NitroFetchOptions<R>, O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>( >(
request: R, request: R,
opts?: O & { failTitle?: string }, opts?: O & { failTitle?: string; params?: { [key: string]: string } },
): Promise< ): Promise<
// sometimes there is an error, other times there isn't // sometimes there is an error, other times there isn't
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -60,7 +60,7 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
{ {
title: opts.failTitle, title: opts.failTitle,
description: description:
(e as FetchError)?.statusMessage ?? (e as string).toString(), (e as FetchError)?.data?.message ?? (e as string).toString(),
//buttonText: $t("common.close"), //buttonText: $t("common.close"),
}, },
(_, c) => c(), (_, c) => c(),
@@ -89,3 +89,15 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
if (import.meta.server) state.value = data; if (import.meta.server) state.value = data;
return data; return data;
}; };
export function isClientRequest() {
const existingState = useState("clientMode", () => false);
if (import.meta.server) {
const headers = useRequestHeaders(["User-Agent"]);
const calculatedClientRequest =
headers["user-agent"] == "Drop Desktop Client";
existingState.value = calculatedClientRequest;
}
return existingState.value;
}

View File

@@ -52,6 +52,7 @@ websocketHandler.listen((message) => {
progress: 0, progress: 0,
error: undefined, error: undefined,
log: [], log: [],
actions: [],
}; };
state.value.error = { title, description }; state.value.error = { title, description };
break; break;

View File

@@ -11,9 +11,3 @@ export type QuickActionNav = {
notifications?: Ref<number>; notifications?: Ref<number>;
action: () => Promise<void>; action: () => Promise<void>;
}; };
export enum PlatformClient {
Windows = "Windows",
Linux = "Linux",
macOS = "macOS",
}

View File

@@ -11,3 +11,12 @@ export const updateUser = async () => {
user.value = await $dropFetch<UserModel | null>("/api/v1/user"); user.value = await $dropFetch<UserModel | null>("/api/v1/user");
}; };
export async function completeSignin() {
const route = useRoute();
const router = useRouter();
const user = useUser();
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
router.push(route.query.redirect?.toString() ?? "/");
}

View File

@@ -2,8 +2,6 @@ services:
postgres: postgres:
# using alpine image to reduce image size # using alpine image to reduce image size
image: postgres:alpine image: postgres:alpine
ports:
- 5432:5432
healthcheck: healthcheck:
test: pg_isready -d drop -U drop test: pg_isready -d drop -U drop
interval: 30s interval: 30s

1
drop.session.sql Normal file
View File

@@ -0,0 +1 @@
DELETE FROM "Session" WHERE 1=1;

View File

@@ -10,10 +10,10 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const user = useUser();
const statusCode = props.error?.statusCode; const statusCode = props.error?.statusCode;
const message = const message = props.error?.data
props.error?.message || props.error?.statusMessage || t("errors.unknown"); ? JSON.parse(props.error.data as string).message
: props.error.cause || props.error?.statusMessage || t("errors.unknown");
const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false; const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
async function signIn() { async function signIn() {
@@ -49,7 +49,7 @@ if (import.meta.client) {
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8" class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
> >
<div class="max-w-lg"> <div class="max-w-lg">
<p class="text-base font-semibold leading-8 text-blue-600"> <p class="text-base font-semibold leading-8 text-red-600">
{{ error?.statusCode }} {{ error?.statusCode }}
</p> </p>
<h1 <h1
@@ -63,15 +63,16 @@ 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">
{{ $t("errors.occurred") }} {{ $t("errors.occurred") }}
</p> </p>
<!-- <p>{{ error. }}</p> --> <!-- <p>{{ error. }}</p> -->
<div class="mt-10"> <div class="mt-10">
<!-- full app reload to fix errors --> <!-- clearError is inconsistent so reload app to clear erro -->
<NuxtLink <a
v-if="user && !showSignIn" v-if="!showSignIn"
to="/" href="/"
class="text-sm font-semibold leading-7 text-blue-600" class="text-sm font-semibold leading-7 text-blue-600"
> >
<i18n-t keypath="errors.backHome" tag="span" scope="global"> <i18n-t keypath="errors.backHome" tag="span" scope="global">
@@ -79,7 +80,7 @@ if (import.meta.client) {
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span> <span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
</template> </template>
</i18n-t> </i18n-t>
</NuxtLink> </a>
<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"

View File

@@ -341,7 +341,7 @@
"installDir": "(Installationsverzeichnis)/", "installDir": "(Installationsverzeichnis)/",
"launchCmd": "Programm/Befehl starten", "launchCmd": "Programm/Befehl starten",
"launchDesc": "Ausführbare Datei zum starten des Spiels", "launchDesc": "Ausführbare Datei zum starten des Spiels",
"launchPlaceholder": "spiel.exe", "launchPlaceholder": "spiel.exe --args",
"loadingVersion": "Lade Versionsmetadaten…", "loadingVersion": "Lade Versionsmetadaten…",
"noAdv": "Keine erweiterten Optionen für diese Konfiguration.", "noAdv": "Keine erweiterten Optionen für diese Konfiguration.",
"noVersions": "Keine Version zum importieren", "noVersions": "Keine Version zum importieren",
@@ -479,7 +479,6 @@
"search": "Durchsuche Bibliothek…", "search": "Durchsuche Bibliothek…",
"subheader": "Verwalte deine Spiele in Sammlungen für einen einfacheren Zugriff auf alle deine Spiele." "subheader": "Verwalte deine Spiele in Sammlungen für einen einfacheren Zugriff auf alle deine Spiele."
}, },
"lowest": "Niedrigste",
"news": { "news": {
"article": { "article": {
"add": "Hinzufügen", "add": "Hinzufügen",
@@ -512,7 +511,6 @@
"title": "Neueste Neuigkeiten" "title": "Neueste Neuigkeiten"
}, },
"options": "Einstellungen", "options": "Einstellungen",
"security": "Sicherheit",
"selectLanguage": "Sprache auswählen", "selectLanguage": "Sprache auswählen",
"settings": { "settings": {
"admin": { "admin": {

View File

@@ -1,7 +1,4 @@
{ {
"setup": {
"welcome": "G'day."
},
"account": { "account": {
"devices": { "devices": {
"subheader": "Manage the devices authorised to access your Drop account." "subheader": "Manage the devices authorised to access your Drop account."
@@ -19,5 +16,8 @@
"subheader": "Add a new collection to organise your games" "subheader": "Add a new collection to organise your games"
}, },
"subheader": "Organise your games into collections for easy access, and access all your games." "subheader": "Organise your games into collections for easy access, and access all your games."
},
"setup": {
"welcome": "G'day."
} }
} }

View File

@@ -291,7 +291,7 @@
"installDir": "(install_dir)/", "installDir": "(install_dir)/",
"launchCmd": "Launch executable/command, argh!", "launchCmd": "Launch executable/command, argh!",
"launchDesc": "Executable to launch the game, matey!", "launchDesc": "Executable to launch the game, matey!",
"launchPlaceholder": "game.exe, aye!", "launchPlaceholder": "game.exe --args",
"loadingVersion": "Loading version charts…", "loadingVersion": "Loading version charts…",
"noAdv": "No advanced options for this rig, argh.", "noAdv": "No advanced options for this rig, argh.",
"noVersions": "No versions to import, savvy!", "noVersions": "No versions to import, savvy!",
@@ -370,7 +370,6 @@
"search": "Search treasure hoard, ye dog…", "search": "Search treasure hoard, ye dog…",
"subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!" "subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!"
}, },
"lowest": "lowest",
"news": { "news": {
"article": { "article": {
"add": "Add, ye dog!", "add": "Add, ye dog!",
@@ -403,7 +402,6 @@
"title": "Latest News from the High Seas" "title": "Latest News from the High Seas"
}, },
"options": "Options, matey!", "options": "Options, matey!",
"security": "Safety",
"selectLanguage": "Pick yer tongue", "selectLanguage": "Pick yer tongue",
"settings": "Settings", "settings": "Settings",
"store": { "store": {

View File

@@ -9,41 +9,41 @@
"subheader": "Manage the devices authorized to access your Drop account.", "subheader": "Manage the devices authorized to access your Drop account.",
"title": "Devices" "title": "Devices"
}, },
"home": { "title": "Home" },
"notifications": { "notifications": {
"all": "View all {arrow}", "all": "View all {arrow}",
"clear": "Clear notifications",
"desc": "View and manage your notifications.", "desc": "View and manage your notifications.",
"markAllAsRead": "Mark all as read", "markAllAsRead": "Mark all as read",
"clear": "Clear notifications",
"markAsRead": "Mark as read", "markAsRead": "Mark as read",
"none": "No notifications", "none": "No notifications",
"notifications": "Notifications", "notifications": "Notifications",
"title": "Notifications", "title": "Notifications",
"unread": "Unread Notifications" "unread": "Unread Notifications"
}, },
"security": { "title": "Security" },
"settings": "Settings",
"title": "Account Settings",
"token": { "token": {
"title": "API Tokens",
"subheader": "Manage your API tokens, and what they can access.",
"name": "API token name",
"nameDesc": "The name of the token, for reference.",
"namePlaceholder": "My New Token",
"acls": "ACLs/scopes", "acls": "ACLs/scopes",
"aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.", "aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.",
"expiry": "Expiry", "expiry": "Expiry",
"noExpiry": "No expiry",
"revoke": "Revoke",
"noTokens": "No tokens connected to your account.",
"expiryMonth": "A month",
"expiry3Month": "3 months", "expiry3Month": "3 months",
"expiry6Month": "6 months",
"expiryYear": "A year",
"expiry5Year": "5 years", "expiry5Year": "5 years",
"expiry6Month": "6 months",
"expiryMonth": "A month",
"expiryYear": "A year",
"name": "API token name",
"nameDesc": "The name of the token, for reference.",
"namePlaceholder": "My New Token",
"noExpiry": "No expiry",
"noTokens": "No tokens connected to your account.",
"revoke": "Revoke",
"subheader": "Manage your API tokens, and what they can access.",
"success": "Successfully created token.", "success": "Successfully created token.",
"successNote": "Make sure to copy it now, as it won't be shown again." "successNote": "Make sure to copy it now, as it won't be shown again.",
}, "title": "API Tokens"
"settings": "Settings", }
"title": "Account Settings"
}, },
"actions": "Actions", "actions": "Actions",
"add": "Add", "add": "Add",
@@ -94,7 +94,8 @@
"chars": { "chars": {
"arrow": "→", "arrow": "→",
"arrowBack": "←", "arrowBack": "←",
"quoted": "\"\"", "arrowDown": "",
"arrowUp": "↑",
"srComma": ", {0}" "srComma": ", {0}"
}, },
"common": { "common": {
@@ -109,7 +110,9 @@
"friends": "Friends", "friends": "Friends",
"groups": "Groups", "groups": "Groups",
"insert": "Insert", "insert": "Insert",
"labelValueColon": "{label}: {value}",
"name": "Name", "name": "Name",
"noData": "No data",
"noResults": "No results", "noResults": "No results",
"noSelected": "No items selected.", "noSelected": "No items selected.",
"remove": "Remove", "remove": "Remove",
@@ -118,9 +121,7 @@
"servers": "Servers", "servers": "Servers",
"srLoading": "Loading…", "srLoading": "Loading…",
"tags": "Tags", "tags": "Tags",
"today": "Today", "today": "Today"
"labelValueColon": "{label}: {value}",
"noData": "No data"
}, },
"delete": "Delete", "delete": "Delete",
"drop": { "drop": {
@@ -156,9 +157,7 @@
"invalidPassState": "Invalid password state. Please contact the server administrator.", "invalidPassState": "Invalid password state. Please contact the server administrator.",
"invalidUserOrPass": "Invalid username or password.", "invalidUserOrPass": "Invalid username or password.",
"inviteIdRequired": "id required in fetching invitation", "inviteIdRequired": "id required in fetching invitation",
"method": { "method": { "signinDisabled": "Sign in method not enabled" },
"signinDisabled": "Sign in method not enabled"
},
"usernameTaken": "Username already taken." "usernameTaken": "Username already taken."
}, },
"backHome": "{arrow} Back to home", "backHome": "{arrow} Back to home",
@@ -246,33 +245,28 @@
"footer": { "footer": {
"about": "About", "about": "About",
"aboutDrop": "About Drop", "aboutDrop": "About Drop",
"api": "API documentation",
"comparison": "Comparison", "comparison": "Comparison",
"docs": { "docs": { "client": "Client Docs", "server": "Server Docs" },
"client": "Client Docs",
"server": "Server Docs"
},
"documentation": "Documentation", "documentation": "Documentation",
"findGame": "Find a Game", "findGame": "Find a Game",
"footer": "Footer", "footer": "Footer",
"games": "Games", "games": "Games",
"social": { "social": { "discord": "Discord", "github": "GitHub" },
"discord": "Discord",
"github": "GitHub"
},
"topSellers": "Top Sellers", "topSellers": "Top Sellers",
"version": "Drop {version} {gitRef}" "version": "Drop {version} {gitRef}"
}, },
"header": { "header": {
"admin": { "admin": {
"admin": "Admin", "admin": "Admin",
"metadata": "Meta",
"settings": {
"title": "Settings",
"store": "Store",
"tokens": "API tokens"
},
"home": "Home", "home": "Home",
"library": "Library", "library": "Library",
"metadata": "Meta",
"settings": {
"store": "Store",
"title": "Settings",
"tokens": "API tokens"
},
"tasks": "Tasks", "tasks": "Tasks",
"users": "Users" "users": "Users"
}, },
@@ -280,23 +274,22 @@
"openSidebar": "Open sidebar" "openSidebar": "Open sidebar"
}, },
"helpUsTranslate": "Help us translate Drop {arrow}", "helpUsTranslate": "Help us translate Drop {arrow}",
"highest": "highest",
"home": { "home": {
"admin": { "admin": {
"title": "Home",
"subheader": "Instance summary",
"games": "Games",
"librarySources": "Library sources",
"version": "Version",
"activeInactiveUsers": "Active/inactive users", "activeInactiveUsers": "Active/inactive users",
"activeUsers": "Active users", "activeUsers": "Active users",
"inactiveUsers": "Inactive users", "allVersionsCombined": "All versions combined",
"goToUsers": "Go to users",
"users": "Users",
"biggestGamesToDownload": "Biggest games to download",
"latestVersionOnly": "Latest version only",
"biggestGamesOnServer": "Biggest games on server", "biggestGamesOnServer": "Biggest games on server",
"allVersionsCombined": "All versions combined" "biggestGamesToDownload": "Biggest games to download",
"games": "Games",
"goToUsers": "Go to users",
"inactiveUsers": "Inactive users",
"latestVersionOnly": "Latest version only",
"librarySources": "Library sources",
"subheader": "Instance summary",
"title": "Home",
"users": "Users",
"version": "Version"
} }
}, },
"library": { "library": {
@@ -339,44 +332,40 @@
"selectGameSearch": "Select game", "selectGameSearch": "Select game",
"selectPlatform": "Please select a platform…", "selectPlatform": "Please select a platform…",
"version": { "version": {
"advancedOptions": "Advanced options",
"import": "Import version", "import": "Import version",
"installDir": "(install_dir)/", "installDir": "(install_dir)/",
"launchCmd": "Launch executable/command", "launchCmd": "Launch executable/command",
"launchDesc": "Executable to launch the game", "launchDesc": "Executable to launch the game",
"launchPlaceholder": "game.exe", "launchPlaceholder": "game.exe --args",
"loadingVersion": "Loading version metadata…", "loadingVersion": "Loading version metadata…",
"noAdv": "No advanced options for this configuration.", "noLaunches": "No launch configurations added.",
"noSetups": "No setup configurations added.",
"noVersions": "No versions to import", "noVersions": "No versions to import",
"platform": "Version platform", "platform": "Version platform",
"setupCmd": "Setup executable/command", "setupCmd": "Setup executable/command",
"setupDesc": "Ran once when the game is installed", "setupDesc": "Ran once when the game is installed",
"setupMode": "Setup mode", "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.", "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", "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.", "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" "version": "Select version to import"
}, },
"withoutMetadata": "Import without metadata" "withoutMetadata": "Import without metadata"
}, },
"libraryHint": "No libraries configured.",
"libraryHintDocsLink": "What does this mean? {arrow}",
"metadata": { "metadata": {
"companies": { "companies": {
"action": "Manage {arrow}", "action": "Manage {arrow}",
"addGame": { "addGame": {
"description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.", "description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.",
"developer": "Developer?", "developer": "Developer?",
"noGames": "No games to add",
"publisher": "Publisher?", "publisher": "Publisher?",
"title": "Connect game to this company" "title": "Connect game to this company"
}, },
"description": "Companies organize games by who they were developed or published by.", "description": "Companies organize games by who they were developed or published by.",
"editor": { "editor": {
"action": "Add Game {plus}", "action": "Add Game {plus}",
"descriptionPlaceholder": "{'<'}description{'>'}",
"developed": "Developed", "developed": "Developed",
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.", "libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
"libraryTitle": "Game Library", "libraryTitle": "Game Library",
@@ -421,8 +410,6 @@
}, },
"metadataProvider": "Metadata provider", "metadataProvider": "Metadata provider",
"noGames": "No games imported", "noGames": "No games imported",
"libraryHint": "No libraries configured.",
"libraryHintDocsLink": "What does this mean? {arrow}",
"offline": "Drop couldn't access this game.", "offline": "Drop couldn't access this game.",
"offlineTitle": "Game offline", "offlineTitle": "Game offline",
"openEditor": "Open in Editor {arrow}", "openEditor": "Open in Editor {arrow}",
@@ -434,6 +421,7 @@
"desc": "Configure your library sources, where Drop will look for new games and versions to import.", "desc": "Configure your library sources, where Drop will look for new games and versions to import.",
"documentationLink": "Documentation {arrow}", "documentationLink": "Documentation {arrow}",
"edit": "Edit source", "edit": "Edit source",
"freeSpace": "Free space",
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.", "fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.", "fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
"fsFlatTitle": "Compatibility", "fsFlatTitle": "Compatibility",
@@ -445,21 +433,16 @@
"nameDesc": "The name of your source, for reference.", "nameDesc": "The name of your source, for reference.",
"namePlaceholder": "My New Source", "namePlaceholder": "My New Source",
"sources": "Library Sources", "sources": "Library Sources",
"typeDesc": "The type of your source. Changes the required options.",
"working": "Working?",
"freeSpace": "Free space",
"totalSpace": "Total space", "totalSpace": "Total space",
"typeDesc": "The type of your source. Changes the required options.",
"utilizationPercentage": "Utilization percentage", "utilizationPercentage": "Utilization percentage",
"percentage": "{number}%" "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.", "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", "title": "Libraries",
"version": { "version": {
"delta": "Upgrade mode", "noVersions": "You have no versions of this game available."
"noVersions": "You have no versions of this game available.", }
"noVersionsAdded": "no versions added"
},
"versionPriority": "Version priority"
}, },
"back": "Back to Library", "back": "Back to Library",
"collection": { "collection": {
@@ -482,7 +465,6 @@
"search": "Search library…", "search": "Search library…",
"subheader": "Organize your games into collections for easy access, and access all your games." "subheader": "Organize your games into collections for easy access, and access all your games."
}, },
"lowest": "lowest",
"news": { "news": {
"article": { "article": {
"add": "Add", "add": "Add",
@@ -515,11 +497,19 @@
"title": "Latest News" "title": "Latest News"
}, },
"options": "Options", "options": "Options",
"security": "Security",
"selectLanguage": "Select language", "selectLanguage": "Select language",
"services": {
"nginx": {
"description": "Built-in simple reverse proxy to connect all the Drop components together.",
"title": "NGINX"
},
"torrential": {
"description": "The internal download server for Drop.",
"title": "Torrential"
}
},
"settings": { "settings": {
"admin": { "admin": {
"description": "Configure Drop settings",
"store": { "store": {
"dropGameAltPlaceholder": "Example Game icon", "dropGameAltPlaceholder": "Example Game icon",
"dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.", "dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.",
@@ -563,10 +553,8 @@
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works." "welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works."
}, },
"store": { "store": {
"about": "About",
"commingSoon": "coming soon", "commingSoon": "coming soon",
"developers": "Developers | Developer | Developers", "developers": "Developers | Developer | Developers",
"exploreMore": "Explore more {arrow}",
"featured": "Featured", "featured": "Featured",
"images": "Game Images", "images": "Game Images",
"lookAt": "Check it out", "lookAt": "Check it out",
@@ -580,15 +568,12 @@
"openFeatured": "Star games in Admin Library {arrow}", "openFeatured": "Star games in Admin Library {arrow}",
"platform": "Platform | Platform | Platforms", "platform": "Platform | Platform | Platforms",
"publishers": "Publishers | Publisher | Publishers", "publishers": "Publishers | Publisher | Publishers",
"size": "Size",
"rating": "Rating", "rating": "Rating",
"readLess": "Click to read less",
"readMore": "Click to read more",
"recentlyAdded": "Recently Added", "recentlyAdded": "Recently Added",
"recentlyReleased": "Recently released", "recentlyReleased": "Recently released",
"recentlyUpdated": "Recently Updated",
"released": "Released", "released": "Released",
"reviews": "({0} Reviews)", "reviews": "({0} Reviews)",
"size": "Size",
"tags": "Tags", "tags": "Tags",
"title": "Store", "title": "Store",
"view": { "view": {
@@ -597,15 +582,16 @@
"srGames": "Games", "srGames": "Games",
"srViewGrid": "View grid" "srViewGrid": "View grid"
}, },
"viewInStore": "View in Store", "viewInStore": "View in Store"
"website": "Website"
}, },
"tasks": { "tasks": {
"admin": { "admin": {
"back": "{arrow} Back to Tasks", "back": "{arrow} Back to Tasks",
"completedTasksTitle": "Completed tasks", "completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks", "dailyScheduledTitle": "Daily scheduled tasks",
"execute": "{arrow} Execute",
"noTasksRunning": "No tasks currently running", "noTasksRunning": "No tasks currently running",
"progress": "{0}%",
"runningTasksTitle": "Running tasks", "runningTasksTitle": "Running tasks",
"scheduled": { "scheduled": {
"checkUpdateDescription": "Check if Drop has an update.", "checkUpdateDescription": "Check if Drop has an update.",
@@ -618,9 +604,7 @@
"cleanupSessionsName": "Clean up sessions." "cleanupSessionsName": "Clean up sessions."
}, },
"viewTask": "View {arrow}", "viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly scheduled tasks", "weeklyScheduledTitle": "Weekly scheduled tasks"
"progress": "{0}%",
"execute": "{arrow} Execute"
} }
}, },
"title": "Drop", "title": "Drop",
@@ -630,29 +614,23 @@
"upload": "Upload", "upload": "Upload",
"uploadFile": "Upload file", "uploadFile": "Upload file",
"user": { "user": {
"unknown": "Unknown user",
"editProfile": "Edit profile", "editProfile": "Edit profile",
"noActivity": "No recent activity",
"notFound": "User not found",
"recent": "Recent activity (TODO)", "recent": "Recent activity (TODO)",
"recentSub": "Recent activity by this user", "recentSub": "Recent activity by this user",
"notFound": "User not found", "unknown": "Unknown user"
"noActivity": "No recent activity"
}, },
"userHeader": { "userHeader": {
"closeSidebar": "Close sidebar", "closeSidebar": "Close sidebar",
"links": { "links": { "community": "Community", "library": "Library", "news": "News" },
"community": "Community", "profile": { "admin": "Admin Dashboard", "settings": "Account settings" }
"library": "Library",
"news": "News"
},
"profile": {
"admin": "Admin Dashboard",
"settings": "Account settings"
}
}, },
"users": { "users": {
"admin": { "admin": {
"adminHeader": "Admin?", "adminHeader": "Admin?",
"adminUserLabel": "Admin user", "adminUserLabel": "Admin user",
"authLink": "Authentication {arrow}",
"authentication": { "authentication": {
"configure": "Configure", "configure": "Configure",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.", "description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
@@ -664,7 +642,6 @@
"srOpenOptions": "Open options", "srOpenOptions": "Open options",
"title": "Authentication" "title": "Authentication"
}, },
"authLink": "Authentication {arrow}",
"authoptionsHeader": "Auth Options", "authoptionsHeader": "Auth Options",
"delete": "Delete", "delete": "Delete",
"deleteUser": "Delete user {0}", "deleteUser": "Delete user {0}",

View File

@@ -341,7 +341,7 @@
"installDir": "(install_dir)/", "installDir": "(install_dir)/",
"launchCmd": "Lancer l'exécutable/commande", "launchCmd": "Lancer l'exécutable/commande",
"launchDesc": "Exécutable pour lancer le jeu", "launchDesc": "Exécutable pour lancer le jeu",
"launchPlaceholder": "jeu.exe", "launchPlaceholder": "jeu.exe --args",
"loadingVersion": "Chargement des métadonnées de la version…", "loadingVersion": "Chargement des métadonnées de la version…",
"noAdv": "Pas d'option avancée pour cette configuration.", "noAdv": "Pas d'option avancée pour cette configuration.",
"noVersions": "Pas de version à importer", "noVersions": "Pas de version à importer",
@@ -479,7 +479,6 @@
"search": "Chercher bibliothèque…", "search": "Chercher bibliothèque…",
"subheader": "Organiser vos jeux en collections pour un accès facile, et accéder à tous vos jeux." "subheader": "Organiser vos jeux en collections pour un accès facile, et accéder à tous vos jeux."
}, },
"lowest": "le plus bas",
"news": { "news": {
"article": { "article": {
"add": "Ajouter", "add": "Ajouter",
@@ -512,7 +511,6 @@
"title": "Dernières Nouvelles" "title": "Dernières Nouvelles"
}, },
"options": "Options", "options": "Options",
"security": "Sécurité",
"selectLanguage": "Sélectionner la langue", "selectLanguage": "Sélectionner la langue",
"settings": { "settings": {
"admin": { "admin": {

View File

@@ -0,0 +1,32 @@
import type { Localisation } from "./utils";
import {
allLocalisableFiles,
flattenLocalisation,
keysFromContent,
stripEquivalence,
} from "./utils";
import fs from "node:fs";
const files = allLocalisableFiles();
const keySet = new Map<string, string>();
for (const file of files) {
const content = fs.readFileSync(file, "utf-8");
const keys = keysFromContent(content);
keys.forEach((key) => keySet.set(key, file));
}
const localeFile: Localisation = JSON.parse(
fs.readFileSync("./i18n/locales/en_us.json", "utf-8"),
);
const flattenedLocalisation = flattenLocalisation(localeFile);
for (const [key, file] of keySet.entries()) {
console.log(stripEquivalence(flattenedLocalisation.get(key)!));
if (!flattenedLocalisation.delete(key))
throw new Error(
`Found key "${key}" in file ${file} that doesn't exist in localisation`,
);
}

View File

@@ -0,0 +1,54 @@
import fs from "node:fs";
import type { Localisation } from "./utils";
import {
allLocalisableFiles,
fetchLocalisation,
keysFromContent,
} from "./utils";
const files = allLocalisableFiles();
const localeFile: Localisation = JSON.parse(
fs.readFileSync("./i18n/locales/en_us.json", "utf-8"),
);
const keepPrefixes = ["error", "common", "chars"];
const keyMap: Map<string, string> = new Map();
for (const file of files) {
const content = fs.readFileSync(file, "utf-8");
const keys = keysFromContent(content);
const fileNoExtension = file.slice(0, file.lastIndexOf("."));
for (const key of keys) {
const _value = fetchLocalisation(localeFile, key);
const newKeySuffix = key.split(".").slice(-1); /*value
.replaceAll(/[^a-zA-Z\s]/g, "")
.toLowerCase()
.split(" ")
.slice(0, 3)
.map((v, i) =>
v
? i > 0
? v[0].toUpperCase() + v.slice(1)
: v
: key.split(".").slice(-1),
)
.join("");*/
const newKey = [
...fileNoExtension
.replaceAll(/[^a-zA-Z0-9/]/g, "")
.toLowerCase()
.split("/"),
newKeySuffix,
].join(".");
const finalKey = keepPrefixes.some((v) => key.startsWith(v)) ? key : newKey;
keyMap.set(key, finalKey);
}
}
console.log(keyMap);

122
i18n/scripts/utils.ts Normal file
View File

@@ -0,0 +1,122 @@
import path from "node:path";
import fs from "node:fs";
import prettier from "prettier";
const prettierConfig = JSON.parse(
fs.readFileSync("./.prettierrc.json", "utf-8"),
);
const paths = ["./components", "./layouts", "./pages", "./server"];
const constPaths = ["error.vue", "app.vue"];
const extensions = [".vue", ".ts"];
function recursiveFindFiles(root: string): string[] {
const results = [];
const subpaths = fs.readdirSync(root);
for (const subpath of subpaths) {
const absPath = path.join(root, subpath);
if (extensions.some((v) => absPath.endsWith(v))) {
results.push(absPath);
continue;
}
const stat = fs.statSync(absPath);
if (stat.isDirectory()) {
results.push(...recursiveFindFiles(absPath));
continue;
}
}
return [...results, ...constPaths];
}
/**
* Fetches the paths of all files available to be localised
*/
export function allLocalisableFiles(): string[] {
const files = paths.map((k) => recursiveFindFiles(k)).flat();
return files;
}
const I18N_UTIL_REGEX = /(?<=[^a-zA-Z]t\(\s*?["']).*?(?=["'])/g;
const I18N_KEYPATH_REGEX = /(?<=keypath=["']).*?(?=["'])/g;
/**
* Uses regex to match all i18n keys in content
* @param content The file content to match against
*/
export function keysFromContent(content: string): string[] {
const matches = [
...content.matchAll(I18N_UTIL_REGEX),
...content.matchAll(I18N_KEYPATH_REGEX),
];
return matches.map((v) => v[0]);
}
export type Localisation = { [key: string]: Localisation | string };
export function flattenLocalisation(localisation: Localisation) {
const map = new Map<string, string>();
flattenLocalisationRecursive(map, [], localisation);
return map;
}
function flattenLocalisationRecursive(
map: Map<string, string>,
key: string[],
localisationBranch: Localisation | string,
) {
if (typeof localisationBranch === "string") {
map.set(key.join("."), localisationBranch);
return;
}
for (const [subKey, value] of Object.entries(localisationBranch)) {
const newKey = [...key, subKey];
flattenLocalisationRecursive(map, newKey, value);
}
}
export function deleteLocalisation(localisation: Localisation, key: string) {
const parts = key.split(".");
let current: Localisation | string = localisation;
for (const part of parts.slice(0, -1)) {
if (typeof current === "string")
throw new Error(`${key} not found in localisation`);
current = current[part];
}
if (typeof current === "string")
throw new Error(`${key} not found in localisation`);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete current[parts.at(-1)!];
}
export function fetchLocalisation(
localisation: Localisation,
key: string,
): string {
const parts = key.split(".");
let current: Localisation | string = localisation;
for (const part of parts.slice(0, -1)) {
if (typeof current === "string")
throw new Error(`${key} not found in localisation`);
current = current[part];
}
if (typeof current === "string")
throw new Error(`${key} not found in localisation`);
return current[parts.at(-1)!] as string;
}
export async function writeJSON<T>(path: string, object: T) {
const flatStr = JSON.stringify(object);
const formatted = await prettier.format(flatStr, {
parser: "json",
...prettierConfig,
});
fs.writeFileSync(path, formatted);
}
/**
* Strips some sort of English language string down to something that can be compared to be basically equivalent
*/
export function stripEquivalence(value: string): string {
return value.replaceAll(/[.,\s]/g, "").toLowerCase();
}

View File

@@ -1,5 +1,8 @@
<template> <template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900"> <div
v-if="!clientRequest"
class="flex flex-col w-full min-h-screen bg-zinc-900"
>
<LazyUserHeader class="z-50" hydrate-on-idle /> <LazyUserHeader class="z-50" hydrate-on-idle />
<div class="grow flex"> <div class="grow flex">
<NuxtPage /> <NuxtPage />
@@ -12,9 +15,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute(); const clientRequest = isClientRequest();
const i18nHead = useLocaleHead(); const i18nHead = useLocaleHead();
const noWrapper = !!route.query.noWrapper;
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -2,7 +2,7 @@ import tailwindcss from "@tailwindcss/vite";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { cpSync, readFileSync, existsSync } from "node:fs"; import { cpSync, readFileSync, existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import module from "module"; import { findPackageJSON } from "node:module";
import { viteStaticCopy } from "vite-plugin-static-copy"; import { viteStaticCopy } from "vite-plugin-static-copy";
import { type } from "arktype"; import { type } from "arktype";
@@ -11,14 +11,6 @@ const packageJsonSchema = type({
version: "string", version: "string",
}); });
const twemojiJson = module.findPackageJSON(
"@discordapp/twemoji",
import.meta.url,
);
if (!twemojiJson) {
throw new Error("Could not find @discordapp/twemoji package.");
}
// get drop version // get drop version
const dropVersion = getDropVersion(); const dropVersion = getDropVersion();
@@ -64,7 +56,7 @@ export default defineNuxtConfig({
experimental: { experimental: {
buildCache: true, buildCache: true,
viewTransition: true, viewTransition: false,
componentIslands: true, componentIslands: true,
}, },
@@ -92,6 +84,13 @@ export default defineNuxtConfig({
hooks: { hooks: {
"nitro:build:public-assets": (nitro) => { "nitro:build:public-assets": (nitro) => {
const twemojiJson = findPackageJSON(
"@discordapp/twemoji",
import.meta.url,
);
if (!twemojiJson) {
throw new Error("Could not find @discordapp/twemoji package.");
}
// this is only run during build, not dev server // this is only run during build, not dev server
// https://github.com/nuxt/nuxt/issues/18918#issuecomment-1925774964 // https://github.com/nuxt/nuxt/issues/18918#issuecomment-1925774964
// copy emojis to .output/public/twemoji // copy emojis to .output/public/twemoji
@@ -140,7 +139,6 @@ export default defineNuxtConfig({
scheduledTasks: { scheduledTasks: {
"0 * * * *": ["dailyTasks"], "0 * * * *": ["dailyTasks"],
"*/30 * * * *": ["downloadCleanup"],
}, },
storage: { storage: {

View File

@@ -1,6 +1,6 @@
{ {
"name": "drop", "name": "drop",
"version": "0.3.5", "version": "0.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
@@ -21,7 +21,7 @@
}, },
"dependencies": { "dependencies": {
"@discordapp/twemoji": "^16.0.1", "@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "3.5.0", "@drop-oss/droplet": "5.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",
@@ -29,23 +29,28 @@
"@nuxt/image": "^1.10.0", "@nuxt/image": "^1.10.0",
"@nuxtjs/i18n": "^9.5.5", "@nuxtjs/i18n": "^9.5.5",
"@prisma/client": "^6.11.1", "@prisma/client": "^6.11.1",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@vueuse/nuxt": "13.6.0", "@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"arktype": "^2.1.10", "arktype": "^2.1.10",
"axios": "^1.12.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cbor2": "^2.0.1",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"cookie-es": "^2.0.0", "cookie-es": "^2.0.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"fast-fuzzy": "^1.12.0", "fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3", "file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"kjua": "^0.10.0",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"micromark": "^4.0.1", "micromark": "^4.0.1",
"normalize-url": "^8.0.2", "normalize-url": "^8.0.2",
"nuxt": "^3.17.4", "nuxt": "^3.17.4",
"nuxt-security": "2.2.0", "nuxt-security": "2.2.0",
"otp-io": "^1.2.7",
"parse-cosekey": "^1.0.2",
"pino": "^9.7.0", "pino": "^9.7.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"prisma": "6.11.1", "prisma": "6.11.1",
@@ -70,6 +75,7 @@
"@types/node": "^22.13.16", "@types/node": "^22.13.16",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/turndown": "^5.0.5", "@types/turndown": "^5.0.5",
"@typescript-eslint/utils": "^8.50.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.24.0", "eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.1", "eslint-config-prettier": "^10.1.1",
@@ -77,6 +83,7 @@
"nitropack": "^2.11.12", "nitropack": "^2.11.12",
"ofetch": "^1.4.1", "ofetch": "^1.4.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-sort-json": "^4.1.1",
"sass": "^1.79.4", "sass": "^1.79.4",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",

View File

@@ -1,3 +1,232 @@
<template> <template>
<div></div> <div>
<div
v-if="!superlevel"
class="border-l-4 p-4 border-yellow-500 bg-yellow-500/10"
>
<div class="flex">
<div class="shrink-0">
<ExclamationTriangleIcon
class="size-5 text-yellow-500"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-300">
Sign in again to access these settings.
{{ " " }}
<NuxtLink
href="/auth/signin?redirect=/account/security&superlevel=true"
class="font-medium underline text-yellow-300 hover:text-yellow-200"
>Sign in &rarr;</NuxtLink
>
</p>
</div>
</div>
</div>
<div v-else class="border-l-4 p-4 border-green-500 bg-green-500/10">
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-500" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-green-300">
You have access to these protected actions.
</p>
</div>
</div>
</div>
<div class="mt-6 relative">
<div></div>
<div class="mt-8 border-b border-white/10 pb-2">
<h3 class="text-base font-semibold text-white">
Two-factor authentication
</h3>
</div>
<div class="mt-4 flex flex-wrap gap-8">
<!-- TOTP -->
<div
class="group relative border-white/10 bg-zinc-800/50 p-6 rounded-md"
>
<div>
<span
class="inline-flex rounded-lg p-3 bg-blue-400/10 text-blue-400"
>
<ClockIcon class="size-6" aria-hidden="true" />
</span>
</div>
<div class="mt-8 max-w-sm">
<h3 class="text-base font-semibold text-white">
<NuxtLink
:href="mfa.mecs.TOTP?.enabled ? '' : '/mfa/setup/totp'"
class="focus:outline-hidden"
>
<!-- Extend touch target to entire panel -->
<span
v-if="!mfa.mecs.TOTP?.enabled"
class="absolute inset-0"
aria-hidden="true"
></span>
TOTP
</NuxtLink>
</h3>
<p class="mt-2 text-sm text-gray-400">
TOTP generates one-time codes, completely offline. You can use any
TOTP authenticator you like.
</p>
<div v-if="mfa.mecs.TOTP?.enabled" class="mt-3">
<LoadingButton :loading="false">Disable</LoadingButton>
</div>
</div>
<span
class="pointer-events-none absolute top-6 right-6"
aria-hidden="true"
>
<svg
v-if="!mfa.mecs.TOTP?.enabled"
class="size-6 text-gray-500 group-hover:text-gray-200"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M20 4h1a1 1 0 00-1-1v1zm-1 12a1 1 0 102 0h-2zM8 3a1 1 0 000 2V3zM3.293 19.293a1 1 0 101.414 1.414l-1.414-1.414zM19 4v12h2V4h-2zm1-1H8v2h12V3zm-.707.293l-16 16 1.414 1.414 16-16-1.414-1.414z"
/>
</svg>
<CheckIcon v-else class="size-6 text-green-600" />
</span>
</div>
<!-- WebAuthn -->
<div
class="group relative border-white/10 bg-zinc-800/50 p-6 rounded-md"
>
<div>
<span
class="inline-flex rounded-lg p-3 bg-blue-400/10 text-blue-400"
>
<KeyIcon class="size-6" aria-hidden="true" />
</span>
</div>
<div class="mt-8 max-w-sm">
<h3 class="text-base font-semibold text-white">WebAuthn</h3>
<p class="mt-2 text-sm text-gray-400">
Otherwise known as passkeys. Authenticate using biometrics, a
device, YubiKeys, or any compatible FIDO2 device.
</p>
<p class="mt-1 text-xs font-bold text-zinc-300">
Also lets you bypass signing in with compatible devices.
</p>
</div>
<LoadingButton
class="mt-3"
:loading="false"
@click="() => (webAuthnOpen = true)"
>Manage</LoadingButton
>
</div>
</div>
<div v-if="!superlevel" class="absolute inset-0 bg-zinc-900/50" />
</div>
<ModalTemplate v-model="webAuthnOpen" size-class="max-w-2xl">
<template #default>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-white">WebAuthn Keys</h1>
<p class="mt-2 text-sm text-gray-300">
Create new keys or remove existing keys from your account.
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<NuxtLink
to="/mfa/setup/webauthn"
class="block rounded-md bg-blue-500 px-3 py-2 text-center text-sm font-semibold text-white shadow-xs hover:bg-blue-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
>
New key
</NuxtLink>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div
class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"
>
<table class="relative min-w-full divide-y divide-white/15">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
>
Name
</th>
<th
scope="col"
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
>
Created
</th>
<th scope="col" class="py-3.5 pr-4 pl-3 sm:pr-0">
<span class="sr-only">Delete</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr
v-for="mec in (mfa.mecs.WebAuthn?.credentials as Array<{
id: string;
name: string;
created: number;
}>) ?? []"
:key="mec.id"
>
<td
class="py-4 pr-3 pl-4 text-sm font-medium whitespace-nowrap text-white sm:pl-0"
>
{{ mec.name }}
</td>
<td
class="py-4 pr-3 pl-4 text-sm font-medium whitespace-nowrap text-white sm:pl-0"
>
<RelativeTime :date="new Date(mec.created)" />
</td>
<td
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0"
>
<a href="#" class="text-blue-400 hover:text-blue-300"
>Delete</a
>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<template #buttons>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="webAuthnOpen = false"
>
{{ $t("common.close") }}
</button>
</template>
</ModalTemplate>
</div>
</template> </template>
<script setup lang="ts">
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/vue/20/solid";
import { CheckIcon, ClockIcon, KeyIcon } from "@heroicons/vue/24/outline";
const superlevel = await $dropFetch("/api/v1/user/superlevel");
//const auth = await $dropFetch("/api/v1/user/auth");
const mfa = await $dropFetch("/api/v1/user/mfa");
const webAuthnOpen = ref(false);
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col gap-y-4 max-w-lg"> <div class="flex flex-col gap-y-4 max-w-[35vw]">
<Listbox <Listbox
as="div" as="div"
:model-value="currentlySelectedVersion" :model-value="currentlySelectedVersion"
@@ -73,139 +73,59 @@
</div> </div>
</Listbox> </Listbox>
<div v-if="versionGuesses" class="flex flex-col gap-8"> <div v-if="versionGuesses" class="flex flex-col gap-4">
<!-- setup executable --> <!-- setup executable -->
<div>
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.version.setupCmd") }}</label
>
<p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.setupDesc") }}
</p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>
{{ $t("library.admin.import.version.installDir") }}
</span>
<Combobox
as="div"
:value="versionSettings.setup"
nullable
@update:model-value="(v) => updateSetupCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="
$t('library.admin.import.version.setupPlaceholder')
"
@change="setupProcessQuery = $event.target.value"
@blur="setupProcessQuery = ''"
/>
<ComboboxButton
v-if="setupFilteredVersionGuesses?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon
class="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions <div class="bg-zinc-800 p-4 rounded-xl relative flex flex-col gap-y-2">
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm" <div>
> <label class="block text-sm font-medium leading-6 text-zinc-100">{{
<ComboboxOption $t("library.admin.import.version.setupCmd")
v-for="guess in setupFilteredVersionGuesses" }}</label>
:key="guess.filename" <p class="text-zinc-400 text-xs">
v-slot="{ active, selected }" {{ $t("library.admin.import.version.setupDesc") }}
:value="guess.filename" </p>
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<component
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
class="size-5"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="setupProcessQuery"
v-slot="{ active, selected }"
:value="setupProcessQuery"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ $t("chars.quoted", { text: setupProcessQuery }) }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<input
id="startup"
v-model="versionSettings.setupArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--setup"
/>
</div>
</div> </div>
<ol
v-if="versionSettings.setups.length > 0"
class="divide-y-1 divide-zinc-700"
>
<li
v-for="(launch, launchIdx) in versionSettings.setups"
:key="launchIdx"
class="py-2 inline-flex items-start gap-x-1"
>
<ImportVersionLaunchRow
v-model="versionSettings.setups[launchIdx]"
:version-guesses="versionGuesses"
:needs-name="false"
/>
<button
class="transition rounded p-1 bg-zinc-900/30 group hover:bg-red-600/30"
@click="() => versionSettings.setups.splice(launchIdx, 1)"
>
<TrashIcon
class="transition size-5 text-zinc-700 group-hover:text-red-700"
/>
</button>
</li>
</ol>
<span
v-else
class="text-sm text-zinc-700 uppercase font-display font-bold"
>{{ $t("library.admin.import.version.noSetups") }}</span
>
<LoadingButton
:loading="false"
class="w-fit"
@click="() => versionSettings.setups.push({} as any)"
>{{ $t("common.add") }}</LoadingButton
>
</div> </div>
<!-- setup mode --> <!-- setup mode -->
<SwitchGroup as="div" class="flex items-center justify-between"> <SwitchGroup
as="div"
class="bg-zinc-800 p-4 rounded-xl flex items-center justify-between gap-4"
>
<span class="flex flex-grow flex-col"> <span class="flex flex-grow flex-col">
<SwitchLabel <SwitchLabel
as="span" as="span"
@@ -220,7 +140,7 @@
<Switch <Switch
v-model="versionSettings.onlySetup" v-model="versionSettings.onlySetup"
:class="[ :class="[
versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-800', versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-900',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]" ]"
> >
@@ -233,143 +153,62 @@
/> />
</Switch> </Switch>
</SwitchGroup> </SwitchGroup>
<div class="relative"> <!-- launch executables -->
<label <div class="relative flex flex-col gap-y-2 bg-zinc-800 p-4 rounded-xl">
for="startup" <div>
class="block text-sm font-medium leading-6 text-zinc-100" <label class="block text-sm font-medium leading-6 text-zinc-100">{{
>{{ $t("library.admin.import.version.launchCmd") }}</label $t("library.admin.import.version.launchCmd")
> }}</label>
<p class="text-zinc-400 text-xs"> <p class="text-zinc-400 text-xs">
{{ $t("library.admin.import.version.launchDesc") }} {{ $t("library.admin.import.version.launchDesc") }}
</p> </p>
<div class="mt-2">
<div
class="flex w-fit rounded-md shadow-sm bg-zinc-950 ring-1 ring-inset ring-zinc-800 focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-600"
>
<span
class="flex select-none items-center pl-3 text-zinc-500 sm:text-sm"
>{{ $t("library.admin.import.version.installDir") }}</span
>
<Combobox
as="div"
:value="versionSettings.launch"
nullable
@update:model-value="(v) => updateLaunchCommand(v)"
>
<div class="relative">
<ComboboxInput
class="block flex-1 border-0 py-1.5 pl-1 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
:placeholder="
$t('library.admin.import.version.launchPlaceholder')
"
@change="launchProcessQuery = $event.target.value"
@blur="launchProcessQuery = ''"
/>
<ComboboxButton
v-if="launchFilteredVersionGuesses?.length ?? 0 > 0"
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<ChevronUpDownIcon
class="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
>
<ComboboxOption
v-for="guess in launchFilteredVersionGuesses"
:key="guess.filename"
v-slot="{ active, selected }"
:value="guess.filename"
as="template"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="[
'inline-flex items-center gap-x-2 block truncate',
selected && 'font-semibold',
]"
>
{{ guess.filename }}
<component
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
class="size-5"
/>
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
<ComboboxOption
v-if="launchProcessQuery"
v-slot="{ active, selected }"
:value="launchProcessQuery"
>
<li
:class="[
'relative cursor-default select-none py-2 pl-3 pr-9',
active
? 'bg-blue-600 text-white outline-none'
: 'text-zinc-100',
]"
>
<span
:class="['block truncate', selected && 'font-semibold']"
>
{{ $t("chars.quoted", { text: launchProcessQuery }) }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<input
id="startup"
v-model="versionSettings.launchArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--launch"
/>
</div>
</div> </div>
<ol
v-if="versionSettings.launches.length > 0"
class="divide-y-1 divide-zinc-700"
>
<li
v-for="(launch, launchIdx) in versionSettings.launches"
:key="launchIdx"
class="py-2 inline-flex items-start gap-x-1 w-full"
>
<ImportVersionLaunchRow
v-model="versionSettings.launches[launchIdx]"
:version-guesses="versionGuesses"
:needs-name="true"
/>
<button
class="transition rounded p-1 bg-zinc-900/30 group hover:bg-red-600/30"
@click="() => versionSettings.launches.splice(launchIdx, 1)"
>
<TrashIcon
class="transition size-5 text-zinc-700 group-hover:text-red-700"
/>
</button>
</li>
</ol>
<span
v-else
class="text-sm text-zinc-700 uppercase font-display font-bold"
>{{ $t("library.admin.import.version.noLaunches") }}</span
>
<LoadingButton
:loading="false"
class="w-fit"
@click="() => versionSettings.launches.push({} as any)"
>{{ $t("common.add") }}</LoadingButton
>
<div <div
v-if="versionSettings.onlySetup" v-if="versionSettings.onlySetup"
class="absolute inset-0 bg-zinc-900/50" class="absolute inset-0 bg-zinc-900/50"
/> />
</div> </div>
<PlatformSelector v-model="versionSettings.platform"> <SwitchGroup
{{ $t("library.admin.import.version.platform") }} as="div"
</PlatformSelector> class="bg-zinc-800 p-4 rounded-xl flex items-center gap-4 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">
<SwitchLabel <SwitchLabel
as="span" as="span"
@@ -385,7 +224,7 @@
<Switch <Switch
v-model="versionSettings.delta" v-model="versionSettings.delta"
:class="[ :class="[
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-800', versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-900',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]" ]"
> >
@@ -398,89 +237,9 @@
/> />
</Switch> </Switch>
</SwitchGroup> </SwitchGroup>
<Disclosure v-slot="{ open }" as="div" class="py-2">
<dt>
<DisclosureButton
class="border-b border-zinc-600 pb-2 flex w-full items-start justify-between text-left text-zinc-100"
>
<span class="text-base/7 font-semibold">
{{ $t("library.admin.import.version.advancedOptions") }}
</span>
<span class="ml-6 flex h-7 items-center">
<ChevronUpIcon v-if="!open" class="size-6" aria-hidden="true" />
<ChevronDownIcon v-else class="size-6" aria-hidden="true" />
</span>
</DisclosureButton>
</dt>
<DisclosurePanel
as="dd"
class="bg-zinc-950/30 p-3 rounded-b-lg mt-2 flex flex-col gap-y-4"
>
<!-- UMU launcher configuration -->
<div
v-if="versionSettings.platform == PlatformClient.Windows"
class="flex flex-col gap-y-4"
>
<SwitchGroup as="div" class="flex items-center justify-between">
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>
{{ $t("library.admin.import.version.umuOverride") }}
</SwitchLabel>
<SwitchDescription as="span" class="text-sm text-zinc-400">
{{ $t("library.admin.import.version.umuOverrideDesc") }}
</SwitchDescription>
</span>
<Switch
v-model="umuIdEnabled"
:class="[
umuIdEnabled ? 'bg-blue-600' : 'bg-zinc-800',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
>
<span
aria-hidden="true"
:class="[
umuIdEnabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</SwitchGroup>
<div>
<label
for="umu-id"
class="block text-sm font-medium leading-6 text-zinc-100"
>
{{ $t("library.admin.import.version.umuLauncherId") }}
</label>
<div class="mt-2">
<input
id="umu-id"
v-model="umuId"
name="umu-id"
type="text"
autocomplete="umu-id"
required
:disabled="!umuIdEnabled"
placeholder="umu-starcitizen"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-950 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
<div v-else class="text-zinc-400">
{{ $t("library.admin.import.version.noAdv") }}
</div>
</DisclosurePanel>
</Disclosure>
<LoadingButton <LoadingButton
class="w-fit" class="w-fit ml-auto"
:loading="importLoading" :loading="importLoading"
@click="startImport_wrapper" @click="startImport_wrapper"
> >
@@ -536,18 +295,15 @@ import {
SwitchDescription, SwitchDescription,
SwitchGroup, SwitchGroup,
SwitchLabel, SwitchLabel,
Disclosure,
DisclosureButton,
DisclosurePanel,
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue"; } from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid"; import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; import {
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid"; CheckIcon,
ChevronUpDownIcon,
TrashIcon,
} from "@heroicons/vue/20/solid";
import type { Platform } from "~/prisma/client/enums";
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",
@@ -561,76 +317,16 @@ const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`, `/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
); );
const currentlySelectedVersion = ref(-1); const currentlySelectedVersion = ref(-1);
const versionSettings = ref<{ const versionSettings = ref<typeof ImportVersion.infer>({
platform: PlatformClient | undefined; id: gameId,
version: "",
onlySetup: boolean;
launch: string;
launchArgs: string;
setup: string;
setupArgs: string;
delta: boolean;
umuId: string;
}>({
platform: undefined,
launch: "",
launchArgs: "",
setup: "",
setupArgs: "",
delta: false, delta: false,
onlySetup: false, onlySetup: false,
umuId: "", launches: [],
setups: [],
}); });
const versionGuesses = const versionGuesses = ref<Array<{ platform: Platform; filename: string }>>();
ref<Array<{ platform: PlatformClient; filename: string }>>();
const launchProcessQuery = ref("");
const setupProcessQuery = ref("");
const launchFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()),
),
);
const setupFilteredVersionGuesses = computed(() =>
versionGuesses.value?.filter((e) =>
e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase()),
),
);
function updateLaunchCommand(value: string) {
versionSettings.value.launch = value;
autosetPlatform(value);
}
function updateSetupCommand(value: string) {
versionSettings.value.setup = value;
autosetPlatform(value);
}
function autosetPlatform(value: string) {
if (!versionGuesses.value) return;
if (versionSettings.value.platform) return;
const guessIndex = versionGuesses.value.findIndex(
(e) => e.filename === value,
);
if (guessIndex == -1) return;
versionSettings.value.platform = versionGuesses.value[guessIndex].platform;
}
const umuIdEnabled = ref(false);
const umuId = computed({
get() {
if (umuIdEnabled.value) return versionSettings.value.umuId;
return undefined;
},
set(v) {
if (umuIdEnabled.value && v) {
versionSettings.value.umuId = v;
}
},
});
const importLoading = ref(false); const importLoading = ref(false);
const importError = ref<string | undefined>(); const importError = ref<string | undefined>();
@@ -639,15 +335,19 @@ async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return; if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value; currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value]; const version = versions[currentlySelectedVersion.value];
const results = await $dropFetch( try {
`/api/v1/admin/import/version/preload?id=${encodeURIComponent( const results = await $dropFetch(
gameId, `/api/v1/admin/import/version/preload?id=${encodeURIComponent(
)}&version=${encodeURIComponent(version)}`, gameId,
); )}&version=${encodeURIComponent(version)}`,
versionGuesses.value = results.map((e) => ({ {
...e, failTitle: "Failed to fetch version information",
platform: e.platform as PlatformClient, },
})); );
versionGuesses.value = results as typeof versionGuesses.value;
} catch {
currentlySelectedVersion.value = -1;
}
} }
async function startImport() { async function startImport() {
@@ -655,9 +355,9 @@ async function startImport() {
const taskId = await $dropFetch("/api/v1/admin/import/version", { const taskId = await $dropFetch("/api/v1/admin/import/version", {
method: "POST", method: "POST",
body: { body: {
...versionSettings.value,
id: gameId, id: gameId,
version: versions[currentlySelectedVersion.value], version: versions[currentlySelectedVersion.value],
...versionSettings.value,
}, },
}); });
router.push(`/admin/task/${taskId.taskId}`); router.push(`/admin/task/${taskId.taskId}`);

View File

@@ -3,11 +3,11 @@
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900" class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
> >
<div <div
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2" class="bg-zinc-950 w-full flex flex-row items-center gap-2 justify-between px-2 pt-6 lg:pt-0"
> >
<!--start--> <!--start-->
<div> <div>
<Listbox v-if="false" v-model="currentMode" as="div"> <Listbox v-model="currentMode" as="div" class="sm:hidden mb-2">
<div class="relative mt-2"> <div class="relative mt-2">
<ListboxButton <ListboxButton
class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6" class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
@@ -68,7 +68,7 @@
</div> </div>
</Listbox> </Listbox>
<div class="pt-4 inline-flex gap-x-2"> <div class="hidden sm:inline-flex pt-4 gap-x-2">
<div <div
v-for="[value, { icon }] in Object.entries(components)" v-for="[value, { icon }] in Object.entries(components)"
:key="value" :key="value"
@@ -93,7 +93,7 @@
<NuxtLink <NuxtLink
:href="`/store/${game.id}`" :href="`/store/${game.id}`"
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 hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="whitespace-nowrap inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
> >
{{ $t("library.admin.openStore") }} {{ $t("library.admin.openStore") }}
<ArrowTopRightOnSquareIcon <ArrowTopRightOnSquareIcon

View File

@@ -42,6 +42,7 @@
import { import {
BuildingStorefrontIcon, BuildingStorefrontIcon,
CodeBracketIcon, CodeBracketIcon,
ServerIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
const navigation: Array<NavigationItem & { icon: Component }> = [ const navigation: Array<NavigationItem & { icon: Component }> = [
@@ -57,6 +58,12 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
prefix: "/admin/settings/tokens", prefix: "/admin/settings/tokens",
icon: CodeBracketIcon, icon: CodeBracketIcon,
}, },
{
label: "Services",
route: "/admin/settings/services",
prefix: "/admin/settings/services",
icon: ServerIcon,
},
]; ];
// const notifications = useNotifications(); // const notifications = useNotifications();

View File

@@ -0,0 +1,91 @@
<template>
<div class="max-w-xl">
<div
class="divide-y divide-white/10 overflow-hidden rounded-lg bg-zinc-900 outline -outline-offset-1 outline-white/20 sm:grid sm:grid-cols-2 sm:divide-y-0"
>
<div
v-for="(service, serviceIdx) in services"
:key="service.name"
:class="[
serviceIdx === 0
? 'rounded-tl-lg rounded-tr-lg sm:rounded-tr-none'
: '',
serviceIdx === 1 ? 'sm:rounded-tr-lg' : '',
serviceIdx === services.length - 2 ? 'sm:rounded-bl-lg' : '',
serviceIdx === services.length - 1
? 'rounded-br-lg rounded-bl-lg sm:rounded-bl-none'
: '',
'group relative border-white/10 bg-zinc-800/50 p-6 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-indigo-500 sm:odd:not-nth-last-2:border-b sm:even:border-l sm:even:not-last:border-b',
]"
>
<div>
<span
:class="[
serviceMetadata[service.name].iconBackground,
serviceMetadata[service.name].iconForeground,
'inline-flex rounded-lg p-3',
]"
>
<component
:is="serviceMetadata[service.name].icon"
class="size-6"
aria-hidden="true"
/>
</span>
</div>
<div class="mt-8">
<h3 class="text-base font-semibold text-white">
<a :href="service.href" class="focus:outline-hidden">
<!-- Extend touch target to entire panel -->
<span class="absolute inset-0" aria-hidden="true"></span>
{{ serviceMetadata[service.name].title }}
</a>
</h3>
<p class="mt-2 text-sm text-zinc-400">
{{ serviceMetadata[service.name].description }}
</p>
</div>
<span
class="pointer-events-none absolute top-6 right-6"
aria-hidden="true"
>
<CheckIcon
:class="[
'size-6',
service.healthy ? 'text-green-600' : 'text-zinc-500',
]"
/>
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ArrowDownTrayIcon, CheckIcon } from "@heroicons/vue/24/outline";
definePageMeta({
layout: "admin",
});
const services = await $dropFetch("/api/v1/admin/services");
const { t } = useI18n();
const serviceMetadata = computed(() => ({
torrential: {
title: t("services.torrential.title"),
description: t("services.torrential.description"),
iconForeground: "text-blue-400",
iconBackground: "bg-blue-500/10",
icon: ArrowDownTrayIcon,
},
nginx: {
title: t("services.nginx.title"),
description: t("services.nginx.description"),
iconForeground: "text-green-400",
iconBackground: "bg-green-500/10",
icon: ArrowDownTrayIcon,
},
}));
</script>

View File

@@ -206,7 +206,6 @@ async function createToken(
}, },
failTitle: "Failed to create API token.", failTitle: "Failed to create API token.",
}); });
console.log(result);
newToken.value = result.token; newToken.value = result.token;
tokens.value.push(result); tokens.value.push(result);
} catch { } catch {

View File

@@ -44,8 +44,25 @@
</div> </div>
{{ task.name }} {{ task.name }}
</h1> </h1>
<ul class="flex flex-row items-center h-12 gap-x-3">
<li
v-for="[name, link] in task.actions.map((v) => v.split(':'))"
:key="link"
>
<NuxtLink :href="link">
<LoadingButton :loading="false"> {{ name }} </LoadingButton>
</NuxtLink>
</li>
<li
v-if="task.actions.length == 0"
class="text-md uppercase font-display font-bold text-zinc-700"
>
No actions
</li>
</ul>
<div <div
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1" class="bg-zinc-950 p-2 rounded-md h-[70vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
> >
<LogLine <LogLine
v-for="(_, idx) in task.log" v-for="(_, idx) in task.log"

View File

@@ -164,8 +164,8 @@ const scheduledTasks: {
description: "", description: "",
}, },
debug: { debug: {
name: "Debug Task", name: "",
description: "Does debugging things.", description: "",
}, },
}; };

40
pages/auth/mfa.vue Normal file
View File

@@ -0,0 +1,40 @@
<template>
<main class="mx-auto w-full max-w-7xl px-6 pt-10 pb-16 sm:pb-24 lg:px-8">
<DropLogo class="mx-auto h-10 w-auto sm:h-12" />
<div class="mx-auto mt-20 max-w-md text-center sm:mt-24">
<h1
class="mt-4 text-3xl font-semibold tracking-tight text-balance text-white sm:text-4xl"
>
Two-factor authentication
</h1>
<p class="mt-6 text-sm font-medium text-pretty text-zinc-400 sm:text-md">
Two-factor authentication is enabled on your account. Choose one of the
options below to continue.
</p>
</div>
<div class="mx-auto mt-16 flow-root max-w-lg sm:mt-20">
<NuxtPage />
<div v-if="route.path !== '/auth/mfa'" class="mt-10 flex justify-center">
<NuxtLink
:href="{ path: '/auth/mfa', query: route.query }"
class="text-sm/6 font-semibold text-blue-400"
><span aria-hidden="true">&larr;</span> Back to options</NuxtLink
>
</div>
</div>
</main>
</template>
<script setup lang="ts">
const route = useRoute();
definePageMeta({
layout: false,
});
useHead({
titleTemplate(title) {
return title ? `${title} - Drop` : "Two-factor authentication - Drop";
},
});
</script>

65
pages/auth/mfa/index.vue Normal file
View File

@@ -0,0 +1,65 @@
<template>
<ul
role="list"
class="-mt-6 divide-y divide-white/10 border-b border-white/10"
>
<li v-if="mfa.includes(MFAMec.TOTP)" class="relative flex gap-x-6 py-6">
<div
class="flex size-10 flex-none items-center justify-center rounded-lg bg-zinc-800/50 shadow-xs outline-1 -outline-offset-1 outline-white/10"
>
<ClockIcon class="size-6 text-blue-400" aria-hidden="true" />
</div>
<div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-white">
<NuxtLink :to="{ path: '/auth/mfa/totp', query: route.query }">
<span class="absolute inset-0" aria-hidden="true"></span>
TOTP
</NuxtLink>
</h3>
<p class="mt-2 text-sm/6 text-zinc-400">
Use a one-time code to sign in to your Drop account.
</p>
</div>
<div class="flex-none self-center">
<ChevronRightIcon class="size-5 text-zinc-500" aria-hidden="true" />
</div>
</li>
<li v-if="mfa.includes(MFAMec.WebAuthn)" class="relative flex gap-x-6 py-6">
<div
class="flex size-10 flex-none items-center justify-center rounded-lg bg-zinc-800/50 shadow-xs outline-1 -outline-offset-1 outline-white/10"
>
<KeyIcon class="size-6 text-blue-400" aria-hidden="true" />
</div>
<div class="flex-auto">
<h3 class="text-sm/6 font-semibold text-white">
<NuxtLink :to="{ path: '/auth/mfa/webauthn', query: route.query }">
<span class="absolute inset-0" aria-hidden="true"></span>
WebAuthn
</NuxtLink>
</h3>
<p class="mt-2 text-sm/6 text-zinc-400">
Use a passkey, like biometrics, a hardware security device, or other
compatible device to sign in to your Drop account.
</p>
</div>
<div class="flex-none self-center">
<ChevronRightIcon class="size-5 text-zinc-500" aria-hidden="true" />
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import {
ChevronRightIcon,
ClockIcon,
KeyIcon,
} from "@heroicons/vue/24/outline";
import { MFAMec } from "~/prisma/client/enums";
const mfa = await $dropFetch("/api/v1/auth/mfa");
const route = useRoute();
const router = useRouter();
if (mfa.length == 0) router.push("/");
</script>

78
pages/auth/mfa/totp.vue Normal file
View File

@@ -0,0 +1,78 @@
<template>
<div class="flex flex-col items-center">
<div v-if="success">
<CheckIcon class="w-8 h-8 text-green-600" />
</div>
<div v-else-if="loading">
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
<div v-else class="inline-flex gap-x-2">
<CodeInput
:length="6"
placeholder="123456"
@complete="(code) => signin(code)"
/>
</div>
<div v-if="error" class="mt-8 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import { CheckIcon } from "@heroicons/vue/24/outline";
import type { FetchError } from "ofetch";
definePageMeta({
layout: false,
});
const loading = ref<boolean>(false);
const success = ref(false);
const error = ref<undefined | string>(undefined);
async function signin(code: string) {
loading.value = true;
error.value = undefined;
try {
await $dropFetch("/api/v1/auth/mfa/totp", {
method: "POST",
body: { code },
});
} catch (e) {
error.value = (e as FetchError)?.data?.message ?? e;
loading.value = false;
return;
}
success.value = true;
await completeSignin();
}
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col items-center">
<div v-if="success">
<CheckIcon class="w-8 h-8 text-green-600" />
</div>
<div v-else-if="loading">
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
<div v-else class="inline-flex gap-x-2">
<LoadingButton :loading="false" @click="() => tryAuthWrapper()">
Sign in with WebAuthn</LoadingButton
>
</div>
<div v-if="error" class="mt-8 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { startAuthentication } from "@simplewebauthn/browser";
import type { FetchError } from "ofetch";
const loading = ref<boolean>(false);
const success = ref(false);
const error = ref<undefined | string>(undefined);
async function tryAuth() {
const optionsJSON = await $dropFetch("/api/v1/auth/mfa/webauthn/start", {
method: "POST",
});
let asseResp;
try {
asseResp = await startAuthentication({ optionsJSON });
} catch {
throw createError({
statusCode: 400,
message: "Passkey sign-in cancelled.",
});
}
if (!asseResp)
throw createError({
statusCode: 400,
message: "Passkey sign-in cancelled.",
});
await $dropFetch("/api/v1/auth/mfa/webauthn/finish", {
method: "POST",
body: asseResp,
});
await completeSignin();
}
async function tryAuthWrapper() {
loading.value = true;
try {
await tryAuth();
success.value = true;
} catch (e) {
error.value = (e as FetchError)?.data?.message ?? e;
}
loading.value = false;
}
</script>

View File

@@ -9,10 +9,18 @@
<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"
> >
{{ $t("auth.signin.title") }} {{
superlevel
? "Sign in to access protected action"
: $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">
{{ $t("auth.signin.noAccount") }} {{
superlevel
? "We need you to sign in again for security reasons while attempting to access more sensitive actions."
: $t("auth.signin.noAccount")
}}
</p> </p>
</div> </div>
@@ -49,11 +57,16 @@ import DropLogo from "~/components/DropLogo.vue";
const { t } = useI18n(); const { t } = useI18n();
const enabledAuths = await $dropFetch("/api/v1/auth"); const enabledAuths = await $dropFetch("/api/v1/auth");
const route = useRoute();
const superlevel = route.query.superlevel;
definePageMeta({ definePageMeta({
layout: false, layout: false,
}); });
useHead({ useHead({
title: t("auth.signin.pageTitle"), title: superlevel
? "Sign in to access protected action"
: t("auth.signin.pageTitle"),
}); });
</script> </script>

View File

@@ -8,20 +8,7 @@
{{ $t("auth.code.description") }} {{ $t("auth.code.description") }}
</p> </p>
<div v-if="!loading" class="mt-8 flex flex-row gap-4"> <div v-if="!loading" class="mt-8 flex flex-row gap-4">
<input <CodeInput @complete="(code) => complete(code)" />
v-for="i in codeLength"
ref="codeElements"
:key="i"
v-model="code[i - 1]"
class="uppercase w-16 h-16 appearance-none text-center bg-zinc-900 rounded-xl border-zinc-700 focus:border-blue-600 text-2xl text-bold font-display text-zinc-100"
type="text"
pattern="\d*"
:placeholder="placeholder[i - 1]"
@keydown="(v) => keydown(i - 1, v)"
@input="() => input(i - 1)"
@focusin="() => select(i - 1)"
@paste="(v) => paste(i - 1, v)"
/>
</div> </div>
<div v-else class="mt-8 flex items-center justify-center"> <div v-else class="mt-8 flex items-center justify-center">
<svg <svg
@@ -65,58 +52,10 @@
import { XCircleIcon } from "@heroicons/vue/24/solid"; import { XCircleIcon } from "@heroicons/vue/24/solid";
import { FetchError } from "ofetch"; import { FetchError } from "ofetch";
const codeLength = 7;
const placeholder = "1A2B3C4";
const codeElements = useTemplateRef("codeElements");
const code = ref<string[]>([]);
const router = useRouter(); const router = useRouter();
const loading = ref(false); const loading = ref(false);
const error = ref<string | undefined>(undefined); const error = ref<string | undefined>(undefined);
function keydown(index: number, event: KeyboardEvent) {
if (event.key === "Backspace" && !code.value[index] && index > 0) {
codeElements.value![index - 1].focus();
}
}
function input(index: number) {
if (codeElements.value === null) return;
const v = code.value[index] ?? "";
if (v.length > 1) code.value[index] = v[0];
if (!(index + 1 >= codeElements.value.length) && v) {
codeElements.value[index + 1].focus();
}
if (!(index - 1 < 0) && !v) {
codeElements.value[index - 1].focus();
}
console.log(index, codeLength - 1);
if (index == codeLength - 1) {
const assembledCode = code.value.join("");
if (assembledCode.length == codeLength) {
complete(assembledCode);
}
}
}
function select(index: number) {
if (!codeElements.value) return;
if (index >= codeElements.value.length) return;
codeElements.value[index].select();
}
function paste(index: number, event: ClipboardEvent) {
const newCode = event.clipboardData!.getData("text/plain");
for (let i = 0; i < newCode.length && i < codeLength; i++) {
code.value[i] = newCode[i];
codeElements.value![i].focus();
}
event.preventDefault();
}
async function complete(code: string) { async function complete(code: string) {
loading.value = true; loading.value = true;
try { try {

View File

@@ -52,16 +52,3 @@ useHead({
title: collection.value?.name || t("library.collection.title"), title: collection.value?.name || t("library.collection.title"),
}); });
</script> </script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -113,16 +113,6 @@ useHead({
<style scoped> <style scoped>
/* Fade transition for main content */ /* Fade transition for main content */
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* List transition animations */ /* List transition animations */
.list-enter-active, .list-enter-active,

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { CheckCircleIcon } from "@heroicons/vue/24/outline";
</script>
<template>
<div class="min-h-full w-full flex items-center justify-center py-24">
<div class="flex flex-col items-center">
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Added your 2FA method!
</h1>
<div class="mt-4">
<p class="mx-auto text-sm text-zinc-400 max-w-sm">
Drop has successfully created and added your 2FA method. If this is
your first time configuring 2FA, your account now requires it to
sign in.
</p>
<div class="mt-10 flex justify-center">
<NuxtLink
href="/account/security"
class="text-sm/6 font-semibold text-blue-400"
><span aria-hidden="true">&larr;</span> Back to account
security</NuxtLink
>
</div>
</div>
</div>
</div>
</div>
</template>

91
pages/mfa/setup/totp.vue Normal file
View File

@@ -0,0 +1,91 @@
<template>
<main
class="mx-auto grid lg:grid-cols-2 max-w-md lg:max-w-none min-h-full place-items-center w-full gap-4 px-6 py-12 sm:py-32 lg:px-8"
>
<div>
<div class="text-left max-w-md">
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Set up your authenticator
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
Use your TOTP authenticator, like Google Authenticator, Aegis, or
Bitwarden, to add 2FA to your Drop account.
</p>
<div class="mt-8">
<p class="text-xs leading-7 text-zinc-200">
Enter the generated code to enable TOTP
</p>
<div class="mt-2 flex flex-row gap-2">
<CodeInput
:length="6"
placeholder="123456"
size="w-10 h-10 text-sm"
@complete="(code) => complete(code)"
/>
</div>
<div
v-if="error"
class="mt-4 rounded-md bg-red-600/10 p-4 max-w-sm mx-auto"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<div class="max-w-2xl flex flex-col items-center gap-2">
<div id="qrcode" />
<p
class="font-bold font-display text-zinc-500 uppercase font-sm tracking-tight"
>
{{ totpSecrets?.secret }}
</p>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import type { FetchError } from "ofetch";
useHead({
title: "Set up TOTP",
});
const totpSecrets = await $dropFetch("/api/v1/user/mfa/totp/start", {
method: "POST",
});
const error = ref<string | undefined>();
const router = useRouter();
onMounted(async () => {
const kjua = await import("kjua");
const el = kjua.default({ text: totpSecrets.url, render: "svg", size: 400 });
document.querySelector("#qrcode")?.appendChild(el);
});
async function complete(code: string) {
try {
await $dropFetch("/api/v1/user/mfa/totp/finish", {
method: "POST",
body: { code },
});
router.push("/mfa/setup/successful");
} catch (e) {
error.value =
(e as FetchError).data?.message ?? (e as FetchError).statusMessage;
}
}
</script>

View File

@@ -0,0 +1,133 @@
<template>
<div
class="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<KeyIcon class="text-blue-600 mx-auto h-10 w-auto" />
<h2
class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-white"
>
Create a passkey
</h2>
<p class="text-sm text-center text-zinc-400">
WebAuthn, or passkeys, allow you to sign in or complete 2FA with
biometrics or hardware security devices.
</p>
</div>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form
class="space-y-6"
action="#"
method="POST"
@submit.prevent="attemptPasskeyWrapper"
>
<div>
<label for="name" class="block text-sm/6 font-medium text-gray-100"
>Name</label
>
<div class="mt-2">
<input
id="name"
v-model="name"
type="text"
name="name"
required
placeholder="My New Passkey"
class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-500 sm:text-sm/6"
/>
</div>
</div>
<div>
<LoadingButton :disabled="disabled" :loading="loading" class="w-full">
Create
</LoadingButton>
</div>
<div
v-if="error"
class="mt-4 rounded-md bg-red-600/10 p-4 max-w-sm mx-auto"
>
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { KeyIcon, XCircleIcon } from "@heroicons/vue/24/outline";
import type { FetchError } from "ofetch";
import { startRegistration } from "@simplewebauthn/browser";
const router = useRouter();
const name = ref("");
const disabled = computed(() => !name.value);
const loading = ref(false);
const error = ref<string | undefined>();
useHead({
title: "Create a passkey",
});
async function attemptPasskeyWrapper() {
loading.value = true;
try {
await attemptPasskey();
} catch (e) {
console.error(e);
error.value = (e as FetchError)?.data?.message ?? e;
}
loading.value = false;
}
async function attemptPasskey() {
if (!window.PublicKeyCredential)
throw createError({
statusCode: 400,
message: "Browser does not support WebAuthn",
fatal: true,
});
const optionsJSON = await $dropFetch("/api/v1/user/mfa/webauthn/start", {
method: "POST",
body: {
name: name.value,
},
});
let attResp;
try {
// Pass the options to the authenticator and wait for a response
attResp = await startRegistration({ optionsJSON });
} catch {
throw createError({
statusCode: 400,
message: "WebAuthn request cancelled.",
});
}
if (!attResp)
throw createError({
statusCode: 400,
message: "WebAuthn request cancelled.",
});
await $dropFetch("/api/v1/user/mfa/webauthn/finish", {
method: "POST",
body: attResp,
});
router.push("/mfa/setup/successful");
}
</script>

View File

@@ -152,16 +152,3 @@ useHead({
border-radius: 0.5rem; border-radius: 0.5rem;
} }
</style> </style>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -14,7 +14,7 @@
<h1 class="text-4xl font-display font-bold text-zinc-100"> <h1 class="text-4xl font-display font-bold text-zinc-100">
{{ $t("setup.welcome") }} {{ $t("setup.welcome") }}
</h1> </h1>
<LanguageSelectorListbox :show-text="false" class="mt-4 max-w-sm" /> <SelectorLanguageListbox :show-text="false" class="mt-4 max-w-sm" />
<p class="mt-6 text-zinc-400 max-w-xl"> <p class="mt-6 text-zinc-400 max-w-xl">
{{ $t("setup.welcomeDescription") }} {{ $t("setup.welcomeDescription") }}
</p> </p>

View File

@@ -100,7 +100,7 @@
</td> </td>
<td <td
v-else v-else
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400 italic" class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
> >
<span class="font-semibold text-blue-600">{{ <span class="font-semibold text-blue-600">{{
$t("store.commingSoon") $t("store.commingSoon")
@@ -231,30 +231,9 @@
<div> <div>
<div <div
v-if="showPreview"
class="mt-12 prose prose-invert prose-blue max-w-none"
v-html="previewHTML"
/>
<div
v-else
class="mt-12 prose prose-invert prose-blue max-w-none" class="mt-12 prose prose-invert prose-blue max-w-none"
v-html="descriptionHTML" v-html="descriptionHTML"
/> />
<button
v-if="showReadMore"
class="mt-8 w-full inline-flex items-center gap-x-6"
@click="() => (showPreview = !showPreview)"
>
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
<span
class="uppercase text-sm font-semibold font-display text-zinc-600"
>{{
showPreview ? $t("store.readMore") : $t("store.readLess")
}}</span
>
<div class="grow h-[1px] bg-zinc-700 rounded-full" />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -266,7 +245,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 type { PlatformClient } from "~/composables/types";
import { formatBytes } from "~/server/internal/utils/files"; import { formatBytes } from "~/server/internal/utils/files";
const route = useRoute(); const route = useRoute();
@@ -276,32 +254,11 @@ const user = useUser();
const { game, rating, size } = await $dropFetch(`/api/v1/games/${gameId}`); const { game, rating, size } = await $dropFetch(`/api/v1/games/${gameId}`);
// Preview description (first 30 lines)
const showPreview = ref(true);
const gameDescriptionCharacters = game.mDescription.split("");
// First new line after x characters
const descriptionSplitIndex = gameDescriptionCharacters.findIndex(
(v, i, arr) => {
// If we're at the last element, we return true.
// So we don't have to handle a -1 from this findIndex
if (i + 1 == arr.length) return true;
if (i < 500) return false;
if (v != "\n") return false;
return true;
},
);
const previewDescription = gameDescriptionCharacters
.slice(0, descriptionSplitIndex + 1) // Slice a character after
.join("");
const previewHTML = micromark(previewDescription);
const descriptionHTML = micromark(game.mDescription); const descriptionHTML = micromark(game.mDescription);
const showReadMore = previewHTML != descriptionHTML;
const platforms = game.versions const platforms = game.versions
.map((e) => e.platform as PlatformClient) .map((e) => e.launches.map((v) => v.platform))
.flat()
.flat() .flat()
.filter((e, i, u) => u.indexOf(e) === i); .filter((e, i, u) => u.indexOf(e) === i);

611
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,13 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- "@parcel/watcher"
- "@prisma/client" - "@prisma/client"
- "@prisma/engines" - "@prisma/engines"
- "@tailwindcss/oxide" - "@tailwindcss/oxide"
- argon2
- esbuild - esbuild
- prisma - prisma
- sharp
- unrs-resolver
overrides: overrides:
droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet

View File

@@ -0,0 +1,28 @@
/*
Warnings:
- The primary key for the `GameVersion` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `versionName` on the `GameVersion` table. All the data in the column will be lost.
- Made the column `libraryId` on table `Game` required. This step will fail if there are existing NULL values in that column.
- The required column `versionId` was added to the `GameVersion` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
- Added the required column `versionPath` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "Game" ALTER COLUMN "libraryId" SET NOT NULL;
DELETE FROM "GameVersion";
-- AlterTable
ALTER TABLE "GameVersion" DROP CONSTRAINT "GameVersion_pkey",
DROP COLUMN "versionName",
ADD COLUMN "displayName" TEXT,
ADD COLUMN "versionId" TEXT NOT NULL,
ADD COLUMN "versionPath" TEXT NOT NULL,
ADD CONSTRAINT "GameVersion_pkey" PRIMARY KEY ("gameId", "versionId");
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@@ -0,0 +1,14 @@
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateTable
CREATE TABLE "Depot" (
"id" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"key" TEXT NOT NULL,
CONSTRAINT "Depot_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@@ -0,0 +1,60 @@
/*
Warnings:
- You are about to drop the column `launchArgs` on the `GameVersion` table. All the data in the column will be lost.
- You are about to drop the column `launchCommand` on the `GameVersion` table. All the data in the column will be lost.
- You are about to drop the column `platform` on the `GameVersion` table. All the data in the column will be lost.
- You are about to drop the column `setupArgs` on the `GameVersion` table. All the data in the column will be lost.
- You are about to drop the column `setupCommand` on the `GameVersion` table. All the data in the column will be lost.
- You are about to drop the column `umuIdOverride` on the `GameVersion` table. All the data in the column will be lost.
- Added the required column `setupId` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "GameVersion" DROP COLUMN "launchArgs",
DROP COLUMN "launchCommand",
DROP COLUMN "platform",
DROP COLUMN "setupArgs",
DROP COLUMN "setupCommand",
DROP COLUMN "umuIdOverride",
ADD COLUMN "setupId" TEXT NOT NULL;
-- CreateTable
CREATE TABLE "SetupConfiguration" (
"setupId" TEXT NOT NULL,
"command" TEXT NOT NULL,
"args" TEXT[],
"platform" "Platform" NOT NULL,
CONSTRAINT "SetupConfiguration_pkey" PRIMARY KEY ("setupId")
);
-- CreateTable
CREATE TABLE "LaunchConfiguration" (
"launchId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"command" TEXT NOT NULL,
"args" TEXT[],
"platform" "Platform" NOT NULL,
"executorId" TEXT,
"umuIdOverride" TEXT,
"gameId" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
CONSTRAINT "LaunchConfiguration_pkey" PRIMARY KEY ("launchId")
);
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- AddForeignKey
ALTER TABLE "GameVersion" ADD CONSTRAINT "GameVersion_setupId_fkey" FOREIGN KEY ("setupId") REFERENCES "SetupConfiguration"("setupId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaunchConfiguration" ADD CONSTRAINT "LaunchConfiguration_executorId_fkey" FOREIGN KEY ("executorId") REFERENCES "LaunchConfiguration"("launchId") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaunchConfiguration" ADD CONSTRAINT "LaunchConfiguration_gameId_versionId_fkey" FOREIGN KEY ("gameId", "versionId") REFERENCES "GameVersion"("gameId", "versionId") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,14 @@
-- DropForeignKey
ALTER TABLE "GameVersion" DROP CONSTRAINT "GameVersion_setupId_fkey";
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "GameVersion" ALTER COLUMN "setupId" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- AddForeignKey
ALTER TABLE "GameVersion" ADD CONSTRAINT "GameVersion_setupId_fkey" FOREIGN KEY ("setupId") REFERENCES "SetupConfiguration"("setupId") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,26 @@
/*
Warnings:
- You are about to drop the column `setupId` on the `GameVersion` table. All the data in the column will be lost.
- Added the required column `gameId` to the `SetupConfiguration` table without a default value. This is not possible if the table is not empty.
- Added the required column `versionId` to the `SetupConfiguration` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "GameVersion" DROP CONSTRAINT "GameVersion_setupId_fkey";
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "GameVersion" DROP COLUMN "setupId";
-- AlterTable
ALTER TABLE "SetupConfiguration" ADD COLUMN "gameId" TEXT NOT NULL,
ADD COLUMN "versionId" TEXT NOT NULL;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- AddForeignKey
ALTER TABLE "SetupConfiguration" ADD CONSTRAINT "SetupConfiguration_gameId_versionId_fkey" FOREIGN KEY ("gameId", "versionId") REFERENCES "GameVersion"("gameId", "versionId") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "LaunchConfiguration" DROP CONSTRAINT "LaunchConfiguration_executorId_fkey";
-- DropForeignKey
ALTER TABLE "LaunchConfiguration" DROP CONSTRAINT "LaunchConfiguration_gameId_versionId_fkey";
-- DropForeignKey
ALTER TABLE "SetupConfiguration" DROP CONSTRAINT "SetupConfiguration_gameId_versionId_fkey";
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- AddForeignKey
ALTER TABLE "SetupConfiguration" ADD CONSTRAINT "SetupConfiguration_gameId_versionId_fkey" FOREIGN KEY ("gameId", "versionId") REFERENCES "GameVersion"("gameId", "versionId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaunchConfiguration" ADD CONSTRAINT "LaunchConfiguration_executorId_fkey" FOREIGN KEY ("executorId") REFERENCES "LaunchConfiguration"("launchId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaunchConfiguration" ADD CONSTRAINT "LaunchConfiguration_gameId_versionId_fkey" FOREIGN KEY ("gameId", "versionId") REFERENCES "GameVersion"("gameId", "versionId") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "actions" TEXT[];
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@@ -0,0 +1,22 @@
-- CreateEnum
CREATE TYPE "MFAMec" AS ENUM ('WebAuthn', 'TOTP');
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateTable
CREATE TABLE "LinkedMFAMec" (
"userId" TEXT NOT NULL,
"mec" "MFAMec" NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"version" INTEGER NOT NULL DEFAULT 1,
"credentials" JSONB NOT NULL,
CONSTRAINT "LinkedMFAMec_pkey" PRIMARY KEY ("userId","mec")
);
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- AddForeignKey
ALTER TABLE "LinkedMFAMec" ADD CONSTRAINT "LinkedMFAMec_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "Session" ALTER COLUMN "userId" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateIndex
CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32));
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@@ -0,0 +1,11 @@
-- DropIndex
DROP INDEX "Game_mName_idx";
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateIndex
CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32));
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@@ -0,0 +1,24 @@
/*
Warnings:
- You are about to drop the column `args` on the `LaunchConfiguration` table. All the data in the column will be lost.
- You are about to drop the column `args` on the `SetupConfiguration` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "Game_mName_idx";
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "LaunchConfiguration" DROP COLUMN "args";
-- AlterTable
ALTER TABLE "SetupConfiguration" DROP COLUMN "args";
-- CreateIndex
CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32));
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@@ -30,3 +30,9 @@ model Library {
games Game[] games Game[]
} }
model Depot {
id String @id @default(uuid())
endpoint String
key String @default(uuid())
}

View File

@@ -11,7 +11,25 @@ model LinkedAuthMec {
version Int @default(1) version Int @default(1)
credentials Json credentials Json
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, mec])
}
enum MFAMec {
WebAuthn
TOTP
}
model LinkedMFAMec {
userId String
mec MFAMec
enabled Boolean @default(true)
version Int @default(1)
credentials Json
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, mec]) @@id([userId, mec])
} }
@@ -63,7 +81,7 @@ model Session {
token String @id token String @id
expiresAt DateTime expiresAt DateTime
userId String userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
data Json // misc extra data data Json // misc extra data

View File

@@ -17,7 +17,8 @@ model Game {
// Any field prefixed with m is filled in from metadata // Any field prefixed with m is filled in from metadata
// Acts as a cache so we can search and filter it // Acts as a cache so we can search and filter it
mName String // Name of game mName String // Name of game
mShortDescription String // Short description mShortDescription String // Short description
mDescription String // Supports markdown mDescription String // Supports markdown
mReleased DateTime // When the game was released mReleased DateTime // When the game was released
@@ -36,8 +37,8 @@ model Game {
// These fields will not be optional in the next version // These fields will not be optional in the next version
// Any game without a library ID will be assigned one at startup, based on the defaults // Any game without a library ID will be assigned one at startup, based on the defaults
libraryId String? libraryId String
library Library? @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade) library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
libraryPath String libraryPath String
collections CollectionEntry[] collections CollectionEntry[]
@@ -51,18 +52,18 @@ model Game {
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([libraryId, libraryPath], name: "libraryKey") @@unique([libraryId, libraryPath], name: "libraryKey")
@@index([mName(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
} }
model GameTag { model GameTag {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
games Game[] games Game[]
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist) @@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
} }
model GameRating { model GameRating {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -83,28 +84,60 @@ model GameRating {
// A particular set of files that relate to the version // A particular set of files that relate to the version
model GameVersion { model GameVersion {
gameId String gameId String
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
versionName String // Sub directory for the game files versionId String @default(uuid())
displayName String?
versionPath String
created DateTime @default(now()) created DateTime @default(now())
platform Platform launches LaunchConfiguration[]
setups SetupConfiguration[]
launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine onlySetup Boolean @default(false)
launchArgs String[]
setupCommand String @default("") // Command to setup game (dependencies and such)
setupArgs String[]
onlySetup Boolean @default(false)
umuIdOverride String?
dropletManifest Json // Results from droplet dropletManifest Json // Results from droplet
versionIndex Int versionIndex Int
delta Boolean @default(false) delta Boolean @default(false)
@@id([gameId, versionName]) @@id([gameId, versionId])
}
model SetupConfiguration {
setupId String @id @default(uuid())
command String
platform Platform
gameId String
versionId String
gameVersion GameVersion @relation(fields: [gameId, versionId], references: [gameId, versionId], onDelete: Cascade, onUpdate: Cascade)
}
model LaunchConfiguration {
launchId String @id @default(uuid())
name String
command String
platform Platform
// For emulation targets
executorId String?
executor LaunchConfiguration? @relation(fields: [executorId], references: [launchId], name: "executor", onDelete: Cascade, onUpdate: Cascade)
umuIdOverride String?
gameId String
versionId String
gameVersion GameVersion @relation(fields: [gameId, versionId], references: [gameId, versionId], onDelete: Cascade, onUpdate: Cascade)
executions LaunchConfiguration[] @relation("executor")
} }
// A save slot for a game // A save slot for a game

View File

@@ -11,6 +11,8 @@ model Task {
progress Float progress Float
log String[] log String[]
actions String[]
acls String[] acls String[]
@@id([id, started]) @@id([id, started])

View File

@@ -8,7 +8,9 @@ model User {
displayName String displayName String
profilePictureObjectId String // Object profilePictureObjectId String // Object
authMecs LinkedAuthMec[] authMecs LinkedAuthMec[]
mfas LinkedMFAMec[]
clients Client[] clients Client[]
notifications Notification[] notifications Notification[]
collections Collection[] collections Collection[]

View File

@@ -1,14 +1,16 @@
import type { TSESLint } from "@typescript-eslint/utils"; import type { TSESLint } from "@typescript-eslint/utils";
const blacklistedFunctions = ["delete", "update"];
export default { export default {
meta: { meta: {
type: "problem", type: "problem",
docs: { docs: {
description: "Don't use Prisma error-prone .delete function", description: "Don't use Prisma error-prone .delete or .update function",
}, },
messages: { messages: {
noPrismaDelete: noPrismaDelete:
"Prisma .delete(...) function is used. Use .deleteMany(..) and check count instead.", "Prisma .delete(...) or .update(...) function is used. Use .deleteMany(..) or .updateMany(...) and check count instead.",
}, },
schema: [], schema: [],
}, },
@@ -17,7 +19,7 @@ export default {
CallExpression: function (node) { CallExpression: function (node) {
// @ts-expect-error It ain't typing properly // @ts-expect-error It ain't typing properly
const funcId = node.callee.property; const funcId = node.callee.property;
if (!funcId || funcId.name !== "delete") return; if (!funcId || !blacklistedFunctions.includes(funcId.name)) return;
// @ts-expect-error It ain't typing properly // @ts-expect-error It ain't typing properly
const tableExpr = node.callee.object; const tableExpr = node.callee.object;
if (!tableExpr) return; if (!tableExpr) return;

View File

@@ -32,20 +32,20 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Upload at least one file.", statusMessage: "Upload at least one file.",
}); });
try { await objectHandler.deleteAsSystem(company.mBannerObjectId);
await objectHandler.deleteAsSystem(company.mBannerObjectId); const { count } = await prisma.company.updateMany({
await prisma.company.update({ where: {
where: { id: companyId,
id: companyId, },
}, data: {
data: { mBannerObjectId: id,
mBannerObjectId: id, },
}, });
}); if (count == 0) {
await pull();
} catch {
await dump(); await dump();
throw createError({ statusCode: 404, message: "Company not found" });
} }
await pull();
return { id: id }; return { id: id };
}); });

View File

@@ -15,6 +15,15 @@ export default defineEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, GameDelete); const body = await readDropValidatedBody(h3, GameDelete);
const gameId = await prisma.game.findUnique({
where: { id: body.id },
select: { id: true },
});
if (!gameId)
throw createError({ statusCode: 404, message: "Game not found" });
// SAFETY: above check
// eslint-disable-next-line drop/no-prisma-delete
await prisma.game.update({ await prisma.game.update({
where: { where: {
id: body.id, id: body.id,

View File

@@ -20,6 +20,11 @@ export default defineEventHandler(async (h3) => {
const action = body.action === "developed" ? "developers" : "publishers"; const action = body.action === "developed" ? "developers" : "publishers";
const actionType = body.enabled ? "connect" : "disconnect"; const actionType = body.enabled ? "connect" : "disconnect";
const game = await prisma.game.findUnique({ where: { id: body.id } });
if (!game) throw createError({ statusCode: 404, message: "Game not found" });
// Safe because we query the game above
// eslint-disable-next-line drop/no-prisma-delete
await prisma.game.update({ await prisma.game.update({
where: { where: {
id: body.id, id: body.id,

View File

@@ -43,6 +43,15 @@ export default defineEventHandler(async (h3) => {
} }
: undefined; : undefined;
const gameId = await prisma.game.findUnique({
where: { id: body.id },
select: { id: true },
});
if (!gameId)
throw createError({ statusCode: 404, message: "Game not found" });
// SAFETY: Above check makes this update okay
// eslint-disable-next-line drop/no-prisma-delete
const game = await prisma.game.update({ const game = await prisma.game.update({
where: { where: {
id: body.id, id: body.id,

View File

@@ -32,20 +32,21 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Upload at least one file.", statusMessage: "Upload at least one file.",
}); });
try { await objectHandler.deleteAsSystem(company.mLogoObjectId);
await objectHandler.deleteAsSystem(company.mLogoObjectId); const { count } = await prisma.company.updateMany({
await prisma.company.update({ where: {
where: { id: companyId,
id: companyId, },
}, data: {
data: { mLogoObjectId: id,
mLogoObjectId: id, },
}, });
}); if (count == 0) {
await pull();
} catch {
await dump(); await dump();
throw createError({ statusCode: 404, message: "Company not found" });
} }
await pull();
return { id: id }; return { id: id };
}); });

View File

@@ -11,13 +11,17 @@ export default defineEventHandler(async (h3) => {
const restOfTheBody = { ...body }; const restOfTheBody = { ...body };
delete restOfTheBody["id"]; delete restOfTheBody["id"];
const newObj = await prisma.company.update({ const newObj = (
where: { await prisma.company.updateManyAndReturn({
id: id, where: {
}, id: id,
data: restOfTheBody, },
// I would put a select here, but it would be based on the body, and muck up the types data: restOfTheBody,
}); // I would put a select here, but it would be based on the body, and muck up the types
})
).at(0);
if (!newObj)
throw createError({ statusCode: 404, message: "Company not found" });
return newObj; return newObj;
}); });

View File

@@ -0,0 +1,21 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const CreateDepot = type({
endpoint: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["depot:new"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, CreateDepot);
const depot = await prisma.depot.create({
data: { endpoint: body.endpoint },
});
return depot;
});

Some files were not shown because too many files have changed in this diff Show More