mirror of
https://github.com/BillyOutlast/drop.git
synced 2026-02-04 00:31:17 +01:00
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:
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
web:
|
||||
name: Push website Docker image to registry
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +1,6 @@
|
||||
[submodule "drop-base"]
|
||||
path = drop-base
|
||||
url = https://github.com/Drop-OSS/drop-base.git
|
||||
[submodule "torrential"]
|
||||
path = torrential
|
||||
url = https://github.com/Drop-OSS/torrential.git
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
drop-base/
|
||||
# file is fully managed by pnpm, no reason to break it
|
||||
pnpm-lock.yaml
|
||||
|
||||
torrential/
|
||||
|
||||
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"jsonRecursiveSort": true,
|
||||
"jsonSortOrder": "{\"/.*/\": \"lexical\"}",
|
||||
"plugins": ["prettier-plugin-sort-json"]
|
||||
}
|
||||
46
.vscode/settings.json
vendored
46
.vscode/settings.json
vendored
@@ -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"
|
||||
"editor.quickSuggestions": {
|
||||
"strings": "on"
|
||||
},
|
||||
// prioritize ArkType's "type" for autoimports
|
||||
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"],
|
||||
// i18n Ally settings
|
||||
"i18n-ally.sortKeys": true,
|
||||
"i18n-ally.keepFulfilled": true,
|
||||
"i18n-ally.extract.autoDetect": true,
|
||||
"i18n-ally.localesPaths": ["i18n", "i18n/locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.extract.ignored": [
|
||||
"string >= 14",
|
||||
"string.alphanumeric >= 5",
|
||||
"/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}"
|
||||
],
|
||||
"i18n-ally.extract.ignoredByFiles": {
|
||||
"pages/admin/library/sources/index.vue": ["Filesystem"],
|
||||
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
|
||||
"pages/admin/library/sources/index.vue": ["Filesystem"],
|
||||
"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$"]
|
||||
}
|
||||
|
||||
33
Dockerfile
33
Dockerfile
@@ -6,46 +6,54 @@ ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
# so corepack knows pnpm's version
|
||||
## so corepack knows pnpm's version
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# prevent prompt to download
|
||||
## prevent prompt to download
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
# setup for offline
|
||||
## setup for offline
|
||||
RUN corepack pack
|
||||
# don't call out to network anymore
|
||||
## don't call out to network anymore
|
||||
ENV COREPACK_ENABLE_NETWORK=0
|
||||
|
||||
### Unified deps builder
|
||||
### INSTALL DEPS ONCE
|
||||
FROM base AS deps
|
||||
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
|
||||
|
||||
ENV NODE_ENV=production
|
||||
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
|
||||
|
||||
# copy deps and rest of project files
|
||||
## copy deps and rest of project files
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG BUILD_DROP_VERSION
|
||||
ARG BUILD_GIT_REF
|
||||
|
||||
# build
|
||||
## build
|
||||
RUN pnpm run postinstall && pnpm run build
|
||||
|
||||
### create run environment for Drop
|
||||
|
||||
# create run environment for Drop
|
||||
FROM base AS run-system
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NUXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
|
||||
RUN apk add --no-cache pnpm 7zip
|
||||
RUN apk add --no-cache pnpm 7zip nginx
|
||||
RUN pnpm install prisma@6.11.1
|
||||
# init prisma to download all required files
|
||||
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/prisma ./prisma
|
||||
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 DATA="/data"
|
||||
ENV NGINX_CONFIG="/nginx.conf"
|
||||
|
||||
CMD ["sh", "/app/startup/launch.sh"]
|
||||
|
||||
15
app.vue
15
app.vue
@@ -29,10 +29,11 @@ await updateUser();
|
||||
|
||||
const user = useUser();
|
||||
const apiDetails = await $dropFetch("/api/v1");
|
||||
const clientMode = isClientRequest();
|
||||
|
||||
const showExternalUrlWarning = ref(false);
|
||||
function checkExternalUrl() {
|
||||
if (!import.meta.client) return;
|
||||
if (!import.meta.client || clientMode) return;
|
||||
const realOrigin = window.location.origin.trim();
|
||||
const chosenOrigin = apiDetails.external.trim();
|
||||
const ignore = window.localStorage.getItem("ignoreExternalUrl");
|
||||
@@ -51,15 +52,3 @@ if (user.value?.admin) {
|
||||
});
|
||||
}
|
||||
</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
41
build/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,17 @@ import type { Component } from "vue";
|
||||
const notifications = useNotifications();
|
||||
const { t } = useI18n();
|
||||
|
||||
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
|
||||
const navigation: Ref<
|
||||
(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",
|
||||
prefix: "/account/security",
|
||||
icon: LockClosedIcon,
|
||||
@@ -67,6 +74,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||
prefix: "/account/devices",
|
||||
icon: DevicePhoneMobileIcon,
|
||||
},
|
||||
{
|
||||
label: t("account.token.title"),
|
||||
route: "/account/tokens",
|
||||
prefix: "/account/tokens",
|
||||
icon: CodeBracketIcon,
|
||||
},
|
||||
{
|
||||
label: t("account.notifications.notifications"),
|
||||
route: "/account/notifications",
|
||||
@@ -74,19 +87,13 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
|
||||
icon: BellIcon,
|
||||
count: notifications.value.length,
|
||||
},
|
||||
{
|
||||
label: t("account.token.title"),
|
||||
route: "/account/tokens",
|
||||
prefix: "/account/tokens",
|
||||
icon: CodeBracketIcon,
|
||||
},
|
||||
{
|
||||
label: t("account.settings"),
|
||||
route: "/account/settings",
|
||||
prefix: "/account/settings",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const currentPageIndex = useCurrentNavigationIndex(navigation);
|
||||
const currentPageIndex = useCurrentNavigationIndex(navigation.value);
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
v-model="username"
|
||||
name="username"
|
||||
type="username"
|
||||
autocomplete="username"
|
||||
autocomplete="username webauthn"
|
||||
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"
|
||||
/>
|
||||
@@ -86,25 +86,60 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 password = ref("");
|
||||
const rememberMe = 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 route = useRoute();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
function signin_wrapper() {
|
||||
loading.value = true;
|
||||
signin()
|
||||
.then(() => {
|
||||
router.push(route.query.redirect?.toString() ?? "/");
|
||||
})
|
||||
.catch((response) => {
|
||||
const message = response.statusMessage || t("errors.unknown");
|
||||
error.value = message;
|
||||
@@ -115,7 +150,7 @@ function signin_wrapper() {
|
||||
}
|
||||
|
||||
async function signin() {
|
||||
await $dropFetch("/api/v1/auth/signin/simple", {
|
||||
const { result } = await $dropFetch("/api/v1/auth/signin/simple", {
|
||||
method: "POST",
|
||||
body: {
|
||||
username: username.value,
|
||||
@@ -123,7 +158,11 @@ async function signin() {
|
||||
rememberMe: rememberMe.value,
|
||||
},
|
||||
});
|
||||
const user = useUser();
|
||||
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
|
||||
if (result == "2fa") {
|
||||
router.push({ query: route.query, path: "/auth/mfa" });
|
||||
return;
|
||||
}
|
||||
|
||||
await completeSignin();
|
||||
}
|
||||
</script>
|
||||
|
||||
86
components/CodeInput.vue
Normal file
86
components/CodeInput.vue
Normal 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>
|
||||
41
components/ExecutorWidget.vue
Normal file
41
components/ExecutorWidget.vue
Normal 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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<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="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
|
||||
@@ -10,10 +10,12 @@
|
||||
<!-- icon image -->
|
||||
<img :src="coreMetadataIconUrl" class="size-20" />
|
||||
<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 }}
|
||||
</h1>
|
||||
<p class="mt-1 text-lg text-zinc-400">
|
||||
<p class="mt-1 text-sm xl:text-lg text-zinc-400">
|
||||
{{ game.mShortDescription }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -28,7 +30,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<label
|
||||
for="releaseDate"
|
||||
@@ -461,7 +463,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel, GameTagModel } from "~/prisma/client/models";
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import { micromark } from "micromark";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -471,6 +473,7 @@ import {
|
||||
} from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { H3Error } from "h3";
|
||||
import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get";
|
||||
|
||||
const showUploadModal = ref(false);
|
||||
const showAddCarouselModal = ref(false);
|
||||
@@ -478,8 +481,9 @@ const showAddImageDescriptionModal = ref(false);
|
||||
const showEditCoreMetadata = ref(false);
|
||||
const mobileShowFinalDescription = ref(true);
|
||||
|
||||
type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
|
||||
const game = defineModel<ModelType>() as Ref<ModelType>;
|
||||
const game = defineModel<SerializeObject<AdminFetchGameType>>({
|
||||
required: true,
|
||||
});
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
|
||||
@@ -1,97 +1,146 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div v-if="game && unimportedVersions">
|
||||
<div class="grow flex flex-row gap-y-8">
|
||||
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
|
||||
<div
|
||||
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
|
||||
>
|
||||
<!-- version manager -->
|
||||
<div>
|
||||
<!-- version priority -->
|
||||
<div>
|
||||
<div class="border-b border-zinc-800 pb-3">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
|
||||
>
|
||||
<h3
|
||||
class="text-base font-semibold font-display leading-6 text-zinc-100"
|
||||
<div v-if="game && unimportedVersions" class="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold text-white">Versions</h1>
|
||||
<p class="mt-2 text-sm text-gray-300">
|
||||
Versions versions version, versions versions. Versions.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<NuxtLink
|
||||
:href="canImport ? `/admin/library/${game.id}/import` : ''"
|
||||
type="button"
|
||||
:class="[
|
||||
canImport ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-800/50',
|
||||
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
canImport
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
</NuxtLink>
|
||||
</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") }}
|
||||
|
||||
<!-- import games button -->
|
||||
|
||||
<NuxtLink
|
||||
:href="canImport ? `/admin/library/${game.id}/import` : ''"
|
||||
type="button"
|
||||
:class="[
|
||||
canImport
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-800/50',
|
||||
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
canImport
|
||||
? $t("library.admin.import.version.import")
|
||||
: $t("library.admin.import.version.noVersions")
|
||||
}}
|
||||
</NuxtLink>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center w-full text-sm text-zinc-600">
|
||||
{{ $t("lowest") }}
|
||||
</div>
|
||||
Name (ID)
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
||||
>
|
||||
Path
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
||||
>
|
||||
Setup Configurations
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
||||
>
|
||||
Launch Configurations
|
||||
</th>
|
||||
<th scope="col" class="py-3 pr-4 pl-3 sm:pr-0">
|
||||
<span class="sr-only">Edit</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<draggable
|
||||
:list="game.versions"
|
||||
handle=".handle"
|
||||
class="mt-2 space-y-4"
|
||||
class="divide-y divide-white/10"
|
||||
tag="tbody"
|
||||
@update="() => updateVersionOrder()"
|
||||
>
|
||||
<template
|
||||
#item="{ element: item }: { element: GameVersionModelWithSize }"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<template #item="{ element: version }: { element: VersionType }">
|
||||
<tr :key="version.versionId">
|
||||
<td>
|
||||
<Bars3Icon
|
||||
class="cursor-move w-6 h-6 text-zinc-400 handle"
|
||||
/>
|
||||
<button @click="() => deleteVersion(item.versionName)">
|
||||
<TrashIcon class="w-5 h-5 text-red-600" />
|
||||
</td>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-->
|
||||
<button
|
||||
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>
|
||||
<div
|
||||
v-if="game.versions.length == 0"
|
||||
class="text-center font-bold text-zinc-400 my-3"
|
||||
>
|
||||
{{ $t("library.admin.version.noVersionsAdded") }}
|
||||
</div>
|
||||
<div class="mt-2 text-center w-full text-sm text-zinc-600">
|
||||
{{ $t("highest") }}
|
||||
</div>
|
||||
</div>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,12 +166,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { GameModel, GameVersionModel } from "~/prisma/client/models";
|
||||
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import type { H3Error } from "h3";
|
||||
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
|
||||
import { formatBytes } from "~/server/internal/utils/files";
|
||||
import { ExclamationCircleIcon, Bars3Icon } from "@heroicons/vue/24/outline";
|
||||
import type { AdminFetchGameType } from "~/server/api/v1/admin/game/[id]/index.get";
|
||||
|
||||
// TODO implement UI for this page
|
||||
|
||||
@@ -136,29 +183,34 @@ const canImport = computed(
|
||||
() => hasDeleted.value || props.unimportedVersions.length > 0,
|
||||
);
|
||||
|
||||
type GameVersionModelWithSize = GameVersionModel & { size: number };
|
||||
|
||||
type GameAndVersions = GameModel & {
|
||||
versions: GameVersionModelWithSize[];
|
||||
};
|
||||
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
|
||||
SerializeObject<GameAndVersions>
|
||||
>;
|
||||
const game = defineModel<SerializeObject<AdminFetchGameType>>({
|
||||
required: true,
|
||||
});
|
||||
if (!game.value)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Game not provided to editor component",
|
||||
});
|
||||
|
||||
type VersionType = (typeof game.value.versions)[number];
|
||||
|
||||
async function updateVersionOrder() {
|
||||
try {
|
||||
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
id: game.value.id,
|
||||
versions: game.value.versions.map((e) => e.versionName),
|
||||
const newVersionOrder = await $dropFetch(
|
||||
"/api/v1/admin/game/:id/versions",
|
||||
{
|
||||
method: "PATCH",
|
||||
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;
|
||||
} catch (e) {
|
||||
createModal(
|
||||
@@ -175,17 +227,19 @@ async function updateVersionOrder() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVersion(versionName: string) {
|
||||
async function deleteVersion(versionId: string) {
|
||||
try {
|
||||
await $dropFetch("/api/v1/admin/game/version", {
|
||||
await $dropFetch("/api/v1/admin/game/:id/versions", {
|
||||
method: "DELETE",
|
||||
body: {
|
||||
version: versionId,
|
||||
},
|
||||
params: {
|
||||
id: game.value.id,
|
||||
versionName: versionName,
|
||||
},
|
||||
});
|
||||
game.value.versions.splice(
|
||||
game.value.versions.findIndex((e) => e.versionName === versionName),
|
||||
game.value.versions.findIndex((e) => e.versionId === versionId),
|
||||
1,
|
||||
);
|
||||
hasDeleted.value = true;
|
||||
|
||||
58
components/GameEditor/VersionConfig.vue
Normal file
58
components/GameEditor/VersionConfig.vue
Normal 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>
|
||||
253
components/ImportVersionLaunchRow.vue
Normal file
253
components/ImportVersionLaunchRow.vue
Normal 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>
|
||||
@@ -11,66 +11,7 @@
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form @submit.prevent="() => addGame()">
|
||||
<Listbox v-model="currentGame" as="div">
|
||||
<ListboxLabel
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
|
||||
>
|
||||
<div class="relative mt-2">
|
||||
<ListboxButton
|
||||
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<GameSearchResultWidget
|
||||
v-if="currentGame"
|
||||
:game="currentGame"
|
||||
/>
|
||||
<span v-else class="block truncate text-zinc-600">
|
||||
{{ $t("library.admin.import.selectGamePlaceholder") }}
|
||||
</span>
|
||||
<span
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
>
|
||||
<ChevronUpDownIcon
|
||||
class="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<transition
|
||||
leave-active-class="transition ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="result in metadataGames"
|
||||
:key="result.id"
|
||||
v-slot="{ active }"
|
||||
as="template"
|
||||
:value="result"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
]"
|
||||
>
|
||||
<GameSearchResultWidget :game="result" />
|
||||
</li>
|
||||
</ListboxOption>
|
||||
<p
|
||||
v-if="metadataGames.length == 0"
|
||||
class="w-full text-center p-2 uppercase font-display text-zinc-700 font-bold"
|
||||
>
|
||||
{{ $t("library.admin.metadata.companies.addGame.noGames") }}
|
||||
</p>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
<SelectorGame v-model="currentGame" :search="search" />
|
||||
<div class="mt-6 flex items-center justify-between gap-3">
|
||||
<label
|
||||
id="published-label"
|
||||
@@ -163,18 +104,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { GameModel } from "~/prisma/client/models";
|
||||
import {
|
||||
DialogTitle,
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxLabel,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||
|
||||
const props = defineProps<{
|
||||
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 open = defineModel<boolean>({ required: true });
|
||||
|
||||
const currentGame = ref<(typeof metadataGames.value)[number]>();
|
||||
const currentGame = ref<GameMetadataSearchResult>();
|
||||
const developed = ref(false);
|
||||
const published = ref(false);
|
||||
const addGameLoading = ref(false);
|
||||
@@ -243,4 +162,8 @@ async function addGame() {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function search(query: string) {
|
||||
return await $dropFetch("/api/v1/admin/search/game", { query: { q: query } });
|
||||
}
|
||||
</script>
|
||||
|
||||
229
components/Modal/SelectLaunch.vue
Normal file
229
components/Modal/SelectLaunch.vue
Normal 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>
|
||||
@@ -24,7 +24,6 @@
|
||||
>
|
||||
{{ name }}
|
||||
</NuxtLink>
|
||||
<!-- todo -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 flex shrink-0">
|
||||
|
||||
91
components/Selector/Combox.vue
Normal file
91
components/Selector/Combox.vue
Normal 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>
|
||||
132
components/Selector/Game.vue
Normal file
132
components/Selector/Game.vue
Normal 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<LanguageSelectorListbox />
|
||||
<SelectorLanguageListbox />
|
||||
<NuxtLink
|
||||
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
|
||||
to="https://translate.droposs.org/engage/drop/"
|
||||
@@ -39,7 +39,7 @@
|
||||
@blur="search = ''"
|
||||
/>
|
||||
<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" />
|
||||
</ComboboxButton>
|
||||
@@ -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"
|
||||
>
|
||||
<ListboxOption
|
||||
v-for="[name, value] in Object.entries(values)"
|
||||
v-for="[name, value] in values"
|
||||
:key="value"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
@@ -82,10 +82,11 @@ import {
|
||||
ListboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
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() {
|
||||
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>
|
||||
@@ -56,18 +56,14 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(source, sourceIdx) in sources"
|
||||
:key="source.id"
|
||||
class="even:bg-zinc-800"
|
||||
>
|
||||
<tr v-for="(source, sourceIdx) in sources" :key="source.id">
|
||||
<td
|
||||
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
|
||||
>
|
||||
{{ source.name }}
|
||||
</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
|
||||
:is="optionsMetadata[source.backend].icon"
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<MultiItemSelector
|
||||
<SelectorMultiItem
|
||||
v-else
|
||||
v-model="[optionValues[section.param] as any][0]"
|
||||
:items="section.options"
|
||||
@@ -189,7 +189,11 @@
|
||||
>
|
||||
{{ option.name }}
|
||||
<span v-if="currentSort === option.param">
|
||||
{{ sortOrder === "asc" ? $t("↑") : $t("↓") }}
|
||||
{{
|
||||
sortOrder === "asc"
|
||||
? $t("chars.arrowUp")
|
||||
: $t("chars.arrowDown")
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</MenuItem>
|
||||
@@ -291,7 +295,7 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<MultiItemSelector
|
||||
<SelectorMultiItem
|
||||
v-else
|
||||
v-model="[optionValues[section.param] as any][0]"
|
||||
:items="section.options"
|
||||
@@ -304,7 +308,7 @@
|
||||
<div
|
||||
v-if="games?.length ?? 0 > 0"
|
||||
ref="product-grid"
|
||||
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4"
|
||||
class="col-span-4 grid gap-5 grid-cols-[repeat(auto-fill,minmax(150px,auto))]"
|
||||
>
|
||||
<!-- Your content -->
|
||||
<GamePanel
|
||||
@@ -372,7 +376,7 @@ import {
|
||||
} from "@heroicons/vue/20/solid";
|
||||
import type { SerializeObject } from "nitropack";
|
||||
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 mobileFiltersOpen = ref(false);
|
||||
@@ -424,7 +428,7 @@ const options: Array<StoreFilterOption> = [
|
||||
name: "Platform",
|
||||
param: "platform",
|
||||
multiple: true,
|
||||
options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })),
|
||||
options: Object.values(Platform).map((e) => ({ name: e, param: e })),
|
||||
},
|
||||
...(props.extraOptions ?? []),
|
||||
];
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
|
||||
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
|
||||
</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
|
||||
type="button"
|
||||
:href="`/admin/task/${task.id}`"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{{ $t("drop.desc") }}
|
||||
</p>
|
||||
|
||||
<LanguageSelector />
|
||||
<SelectorLanguage />
|
||||
|
||||
<div class="flex space-x-6">
|
||||
<NuxtLink
|
||||
|
||||
22
composables/frontend.d.ts
vendored
Normal file
22
composables/frontend.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IconsLinuxLogo, IconsWindowsLogo, IconsMacLogo } from "#components";
|
||||
import { PlatformClient } from "./types";
|
||||
import { Platform } from "~/prisma/client/enums";
|
||||
|
||||
export const PLATFORM_ICONS = {
|
||||
[PlatformClient.Linux]: IconsLinuxLogo,
|
||||
[PlatformClient.Windows]: IconsWindowsLogo,
|
||||
[PlatformClient.macOS]: IconsMacLogo,
|
||||
[Platform.Linux]: IconsLinuxLogo,
|
||||
[Platform.Windows]: IconsWindowsLogo,
|
||||
[Platform.macOS]: IconsMacLogo,
|
||||
};
|
||||
|
||||
1
composables/kjua.d.ts
vendored
Normal file
1
composables/kjua.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "kjua";
|
||||
@@ -16,7 +16,7 @@ interface DropFetch<
|
||||
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
|
||||
>(
|
||||
request: R,
|
||||
opts?: O & { failTitle?: string },
|
||||
opts?: O & { failTitle?: string; params?: { [key: string]: string } },
|
||||
): Promise<
|
||||
// sometimes there is an error, other times there isn't
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -60,7 +60,7 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
{
|
||||
title: opts.failTitle,
|
||||
description:
|
||||
(e as FetchError)?.statusMessage ?? (e as string).toString(),
|
||||
(e as FetchError)?.data?.message ?? (e as string).toString(),
|
||||
//buttonText: $t("common.close"),
|
||||
},
|
||||
(_, c) => c(),
|
||||
@@ -89,3 +89,15 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
|
||||
if (import.meta.server) state.value = 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;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ websocketHandler.listen((message) => {
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
log: [],
|
||||
actions: [],
|
||||
};
|
||||
state.value.error = { title, description };
|
||||
break;
|
||||
|
||||
@@ -11,9 +11,3 @@ export type QuickActionNav = {
|
||||
notifications?: Ref<number>;
|
||||
action: () => Promise<void>;
|
||||
};
|
||||
|
||||
export enum PlatformClient {
|
||||
Windows = "Windows",
|
||||
Linux = "Linux",
|
||||
macOS = "macOS",
|
||||
}
|
||||
|
||||
@@ -11,3 +11,12 @@ export const updateUser = async () => {
|
||||
|
||||
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() ?? "/");
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ services:
|
||||
postgres:
|
||||
# using alpine image to reduce image size
|
||||
image: postgres:alpine
|
||||
ports:
|
||||
- 5432:5432
|
||||
healthcheck:
|
||||
test: pg_isready -d drop -U drop
|
||||
interval: 30s
|
||||
|
||||
Submodule drop-base updated: 06bea06363...2f0ed58cb3
1
drop.session.sql
Normal file
1
drop.session.sql
Normal file
@@ -0,0 +1 @@
|
||||
DELETE FROM "Session" WHERE 1=1;
|
||||
19
error.vue
19
error.vue
@@ -10,10 +10,10 @@ const props = defineProps({
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const user = useUser();
|
||||
const statusCode = props.error?.statusCode;
|
||||
const message =
|
||||
props.error?.message || props.error?.statusMessage || t("errors.unknown");
|
||||
const message = props.error?.data
|
||||
? 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;
|
||||
|
||||
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"
|
||||
>
|
||||
<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 }}
|
||||
</p>
|
||||
<h1
|
||||
@@ -63,15 +63,16 @@ if (import.meta.client) {
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||
{{ $t("errors.occurred") }}
|
||||
</p>
|
||||
<!-- <p>{{ error. }}</p> -->
|
||||
<div class="mt-10">
|
||||
<!-- full app reload to fix errors -->
|
||||
<NuxtLink
|
||||
v-if="user && !showSignIn"
|
||||
to="/"
|
||||
<!-- clearError is inconsistent so reload app to clear erro -->
|
||||
<a
|
||||
v-if="!showSignIn"
|
||||
href="/"
|
||||
class="text-sm font-semibold leading-7 text-blue-600"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</NuxtLink>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="text-sm font-semibold leading-7 text-blue-600"
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
"installDir": "(Installationsverzeichnis)/",
|
||||
"launchCmd": "Programm/Befehl starten",
|
||||
"launchDesc": "Ausführbare Datei zum starten des Spiels",
|
||||
"launchPlaceholder": "spiel.exe",
|
||||
"launchPlaceholder": "spiel.exe --args",
|
||||
"loadingVersion": "Lade Versionsmetadaten…",
|
||||
"noAdv": "Keine erweiterten Optionen für diese Konfiguration.",
|
||||
"noVersions": "Keine Version zum importieren",
|
||||
@@ -479,7 +479,6 @@
|
||||
"search": "Durchsuche Bibliothek…",
|
||||
"subheader": "Verwalte deine Spiele in Sammlungen für einen einfacheren Zugriff auf alle deine Spiele."
|
||||
},
|
||||
"lowest": "Niedrigste",
|
||||
"news": {
|
||||
"article": {
|
||||
"add": "Hinzufügen",
|
||||
@@ -512,7 +511,6 @@
|
||||
"title": "Neueste Neuigkeiten"
|
||||
},
|
||||
"options": "Einstellungen",
|
||||
"security": "Sicherheit",
|
||||
"selectLanguage": "Sprache auswählen",
|
||||
"settings": {
|
||||
"admin": {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"setup": {
|
||||
"welcome": "G'day."
|
||||
},
|
||||
"account": {
|
||||
"devices": {
|
||||
"subheader": "Manage the devices authorised to access your Drop account."
|
||||
@@ -19,5 +16,8 @@
|
||||
"subheader": "Add a new collection to organise your games"
|
||||
},
|
||||
"subheader": "Organise your games into collections for easy access, and access all your games."
|
||||
},
|
||||
"setup": {
|
||||
"welcome": "G'day."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
"installDir": "(install_dir)/",
|
||||
"launchCmd": "Launch executable/command, argh!",
|
||||
"launchDesc": "Executable to launch the game, matey!",
|
||||
"launchPlaceholder": "game.exe, aye!",
|
||||
"launchPlaceholder": "game.exe --args",
|
||||
"loadingVersion": "Loading version charts…",
|
||||
"noAdv": "No advanced options for this rig, argh.",
|
||||
"noVersions": "No versions to import, savvy!",
|
||||
@@ -370,7 +370,6 @@
|
||||
"search": "Search treasure hoard, ye dog…",
|
||||
"subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!"
|
||||
},
|
||||
"lowest": "lowest",
|
||||
"news": {
|
||||
"article": {
|
||||
"add": "Add, ye dog!",
|
||||
@@ -403,7 +402,6 @@
|
||||
"title": "Latest News from the High Seas"
|
||||
},
|
||||
"options": "Options, matey!",
|
||||
"security": "Safety",
|
||||
"selectLanguage": "Pick yer tongue",
|
||||
"settings": "Settings",
|
||||
"store": {
|
||||
|
||||
@@ -9,41 +9,41 @@
|
||||
"subheader": "Manage the devices authorized to access your Drop account.",
|
||||
"title": "Devices"
|
||||
},
|
||||
"home": { "title": "Home" },
|
||||
"notifications": {
|
||||
"all": "View all {arrow}",
|
||||
"clear": "Clear notifications",
|
||||
"desc": "View and manage your notifications.",
|
||||
"markAllAsRead": "Mark all as read",
|
||||
"clear": "Clear notifications",
|
||||
"markAsRead": "Mark as read",
|
||||
"none": "No notifications",
|
||||
"notifications": "Notifications",
|
||||
"title": "Notifications",
|
||||
"unread": "Unread Notifications"
|
||||
},
|
||||
"security": { "title": "Security" },
|
||||
"settings": "Settings",
|
||||
"title": "Account Settings",
|
||||
"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",
|
||||
"aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.",
|
||||
"expiry": "Expiry",
|
||||
"noExpiry": "No expiry",
|
||||
"revoke": "Revoke",
|
||||
"noTokens": "No tokens connected to your account.",
|
||||
|
||||
"expiryMonth": "A month",
|
||||
"expiry3Month": "3 months",
|
||||
"expiry6Month": "6 months",
|
||||
"expiryYear": "A year",
|
||||
"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.",
|
||||
"successNote": "Make sure to copy it now, as it won't be shown again."
|
||||
},
|
||||
"settings": "Settings",
|
||||
"title": "Account Settings"
|
||||
"successNote": "Make sure to copy it now, as it won't be shown again.",
|
||||
"title": "API Tokens"
|
||||
}
|
||||
},
|
||||
"actions": "Actions",
|
||||
"add": "Add",
|
||||
@@ -94,7 +94,8 @@
|
||||
"chars": {
|
||||
"arrow": "→",
|
||||
"arrowBack": "←",
|
||||
"quoted": "\"\"",
|
||||
"arrowDown": "↓",
|
||||
"arrowUp": "↑",
|
||||
"srComma": ", {0}"
|
||||
},
|
||||
"common": {
|
||||
@@ -109,7 +110,9 @@
|
||||
"friends": "Friends",
|
||||
"groups": "Groups",
|
||||
"insert": "Insert",
|
||||
"labelValueColon": "{label}: {value}",
|
||||
"name": "Name",
|
||||
"noData": "No data",
|
||||
"noResults": "No results",
|
||||
"noSelected": "No items selected.",
|
||||
"remove": "Remove",
|
||||
@@ -118,9 +121,7 @@
|
||||
"servers": "Servers",
|
||||
"srLoading": "Loading…",
|
||||
"tags": "Tags",
|
||||
"today": "Today",
|
||||
"labelValueColon": "{label}: {value}",
|
||||
"noData": "No data"
|
||||
"today": "Today"
|
||||
},
|
||||
"delete": "Delete",
|
||||
"drop": {
|
||||
@@ -156,9 +157,7 @@
|
||||
"invalidPassState": "Invalid password state. Please contact the server administrator.",
|
||||
"invalidUserOrPass": "Invalid username or password.",
|
||||
"inviteIdRequired": "id required in fetching invitation",
|
||||
"method": {
|
||||
"signinDisabled": "Sign in method not enabled"
|
||||
},
|
||||
"method": { "signinDisabled": "Sign in method not enabled" },
|
||||
"usernameTaken": "Username already taken."
|
||||
},
|
||||
"backHome": "{arrow} Back to home",
|
||||
@@ -246,33 +245,28 @@
|
||||
"footer": {
|
||||
"about": "About",
|
||||
"aboutDrop": "About Drop",
|
||||
"api": "API documentation",
|
||||
"comparison": "Comparison",
|
||||
"docs": {
|
||||
"client": "Client Docs",
|
||||
"server": "Server Docs"
|
||||
},
|
||||
"docs": { "client": "Client Docs", "server": "Server Docs" },
|
||||
"documentation": "Documentation",
|
||||
"findGame": "Find a Game",
|
||||
"footer": "Footer",
|
||||
"games": "Games",
|
||||
"social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub"
|
||||
},
|
||||
"social": { "discord": "Discord", "github": "GitHub" },
|
||||
"topSellers": "Top Sellers",
|
||||
"version": "Drop {version} {gitRef}"
|
||||
},
|
||||
"header": {
|
||||
"admin": {
|
||||
"admin": "Admin",
|
||||
"metadata": "Meta",
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"store": "Store",
|
||||
"tokens": "API tokens"
|
||||
},
|
||||
"home": "Home",
|
||||
"library": "Library",
|
||||
"metadata": "Meta",
|
||||
"settings": {
|
||||
"store": "Store",
|
||||
"title": "Settings",
|
||||
"tokens": "API tokens"
|
||||
},
|
||||
"tasks": "Tasks",
|
||||
"users": "Users"
|
||||
},
|
||||
@@ -280,23 +274,22 @@
|
||||
"openSidebar": "Open sidebar"
|
||||
},
|
||||
"helpUsTranslate": "Help us translate Drop {arrow}",
|
||||
"highest": "highest",
|
||||
"home": {
|
||||
"admin": {
|
||||
"title": "Home",
|
||||
"subheader": "Instance summary",
|
||||
"games": "Games",
|
||||
"librarySources": "Library sources",
|
||||
"version": "Version",
|
||||
"activeInactiveUsers": "Active/inactive users",
|
||||
"activeUsers": "Active users",
|
||||
"inactiveUsers": "Inactive users",
|
||||
"goToUsers": "Go to users",
|
||||
"users": "Users",
|
||||
"biggestGamesToDownload": "Biggest games to download",
|
||||
"latestVersionOnly": "Latest version only",
|
||||
"allVersionsCombined": "All versions combined",
|
||||
"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": {
|
||||
@@ -339,44 +332,40 @@
|
||||
"selectGameSearch": "Select game",
|
||||
"selectPlatform": "Please select a platform…",
|
||||
"version": {
|
||||
"advancedOptions": "Advanced options",
|
||||
"import": "Import version",
|
||||
"installDir": "(install_dir)/",
|
||||
"launchCmd": "Launch executable/command",
|
||||
"launchDesc": "Executable to launch the game",
|
||||
"launchPlaceholder": "game.exe",
|
||||
"launchPlaceholder": "game.exe --args",
|
||||
"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",
|
||||
"platform": "Version platform",
|
||||
"setupCmd": "Setup executable/command",
|
||||
"setupDesc": "Ran once when the game is installed",
|
||||
"setupMode": "Setup mode",
|
||||
"setupModeDesc": "When enabled, this version does not have a launch command, and simply runs the executable on the user's computer. Useful for games that only distribute installer and not portable files.",
|
||||
"setupPlaceholder": "setup.exe",
|
||||
"umuLauncherId": "UMU Launcher ID",
|
||||
"umuOverride": "Override UMU Launcher Game ID",
|
||||
"umuOverrideDesc": "By default, Drop uses a non-ID when launching with UMU Launcher. In order to get the right patches for some games, you may have to manually set this field.",
|
||||
"updateMode": "Update mode",
|
||||
"updateModeDesc": "When enabled, these files will be installed on top of (overwriting) the previous version's. If multiple \"update modes\" are chained together, they are applied in order.",
|
||||
"version": "Select version to import"
|
||||
},
|
||||
"withoutMetadata": "Import without metadata"
|
||||
},
|
||||
"libraryHint": "No libraries configured.",
|
||||
"libraryHintDocsLink": "What does this mean? {arrow}",
|
||||
"metadata": {
|
||||
"companies": {
|
||||
"action": "Manage {arrow}",
|
||||
"addGame": {
|
||||
"description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.",
|
||||
"developer": "Developer?",
|
||||
"noGames": "No games to add",
|
||||
"publisher": "Publisher?",
|
||||
"title": "Connect game to this company"
|
||||
},
|
||||
"description": "Companies organize games by who they were developed or published by.",
|
||||
"editor": {
|
||||
"action": "Add Game {plus}",
|
||||
"descriptionPlaceholder": "{'<'}description{'>'}",
|
||||
"developed": "Developed",
|
||||
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
|
||||
"libraryTitle": "Game Library",
|
||||
@@ -421,8 +410,6 @@
|
||||
},
|
||||
"metadataProvider": "Metadata provider",
|
||||
"noGames": "No games imported",
|
||||
"libraryHint": "No libraries configured.",
|
||||
"libraryHintDocsLink": "What does this mean? {arrow}",
|
||||
"offline": "Drop couldn't access this game.",
|
||||
"offlineTitle": "Game offline",
|
||||
"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.",
|
||||
"documentationLink": "Documentation {arrow}",
|
||||
"edit": "Edit source",
|
||||
"freeSpace": "Free space",
|
||||
"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.",
|
||||
"fsFlatTitle": "Compatibility",
|
||||
@@ -445,21 +433,16 @@
|
||||
"nameDesc": "The name of your source, for reference.",
|
||||
"namePlaceholder": "My New Source",
|
||||
"sources": "Library Sources",
|
||||
"typeDesc": "The type of your source. Changes the required options.",
|
||||
"working": "Working?",
|
||||
"freeSpace": "Free space",
|
||||
"totalSpace": "Total space",
|
||||
"typeDesc": "The type of your source. Changes the required options.",
|
||||
"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.",
|
||||
"title": "Libraries",
|
||||
"version": {
|
||||
"delta": "Upgrade mode",
|
||||
"noVersions": "You have no versions of this game available.",
|
||||
"noVersionsAdded": "no versions added"
|
||||
},
|
||||
"versionPriority": "Version priority"
|
||||
"noVersions": "You have no versions of this game available."
|
||||
}
|
||||
},
|
||||
"back": "Back to Library",
|
||||
"collection": {
|
||||
@@ -482,7 +465,6 @@
|
||||
"search": "Search library…",
|
||||
"subheader": "Organize your games into collections for easy access, and access all your games."
|
||||
},
|
||||
"lowest": "lowest",
|
||||
"news": {
|
||||
"article": {
|
||||
"add": "Add",
|
||||
@@ -515,11 +497,19 @@
|
||||
"title": "Latest News"
|
||||
},
|
||||
"options": "Options",
|
||||
"security": "Security",
|
||||
"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": {
|
||||
"admin": {
|
||||
"description": "Configure Drop settings",
|
||||
"store": {
|
||||
"dropGameAltPlaceholder": "Example Game icon",
|
||||
"dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.",
|
||||
@@ -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."
|
||||
},
|
||||
"store": {
|
||||
"about": "About",
|
||||
"commingSoon": "coming soon",
|
||||
"developers": "Developers | Developer | Developers",
|
||||
"exploreMore": "Explore more {arrow}",
|
||||
"featured": "Featured",
|
||||
"images": "Game Images",
|
||||
"lookAt": "Check it out",
|
||||
@@ -580,15 +568,12 @@
|
||||
"openFeatured": "Star games in Admin Library {arrow}",
|
||||
"platform": "Platform | Platform | Platforms",
|
||||
"publishers": "Publishers | Publisher | Publishers",
|
||||
"size": "Size",
|
||||
"rating": "Rating",
|
||||
"readLess": "Click to read less",
|
||||
"readMore": "Click to read more",
|
||||
"recentlyAdded": "Recently Added",
|
||||
"recentlyReleased": "Recently released",
|
||||
"recentlyUpdated": "Recently Updated",
|
||||
"released": "Released",
|
||||
"reviews": "({0} Reviews)",
|
||||
"size": "Size",
|
||||
"tags": "Tags",
|
||||
"title": "Store",
|
||||
"view": {
|
||||
@@ -597,15 +582,16 @@
|
||||
"srGames": "Games",
|
||||
"srViewGrid": "View grid"
|
||||
},
|
||||
"viewInStore": "View in Store",
|
||||
"website": "Website"
|
||||
"viewInStore": "View in Store"
|
||||
},
|
||||
"tasks": {
|
||||
"admin": {
|
||||
"back": "{arrow} Back to Tasks",
|
||||
"completedTasksTitle": "Completed tasks",
|
||||
"dailyScheduledTitle": "Daily scheduled tasks",
|
||||
"execute": "{arrow} Execute",
|
||||
"noTasksRunning": "No tasks currently running",
|
||||
"progress": "{0}%",
|
||||
"runningTasksTitle": "Running tasks",
|
||||
"scheduled": {
|
||||
"checkUpdateDescription": "Check if Drop has an update.",
|
||||
@@ -618,9 +604,7 @@
|
||||
"cleanupSessionsName": "Clean up sessions."
|
||||
},
|
||||
"viewTask": "View {arrow}",
|
||||
"weeklyScheduledTitle": "Weekly scheduled tasks",
|
||||
"progress": "{0}%",
|
||||
"execute": "{arrow} Execute"
|
||||
"weeklyScheduledTitle": "Weekly scheduled tasks"
|
||||
}
|
||||
},
|
||||
"title": "Drop",
|
||||
@@ -630,29 +614,23 @@
|
||||
"upload": "Upload",
|
||||
"uploadFile": "Upload file",
|
||||
"user": {
|
||||
"unknown": "Unknown user",
|
||||
"editProfile": "Edit profile",
|
||||
"noActivity": "No recent activity",
|
||||
"notFound": "User not found",
|
||||
"recent": "Recent activity (TODO)",
|
||||
"recentSub": "Recent activity by this user",
|
||||
"notFound": "User not found",
|
||||
"noActivity": "No recent activity"
|
||||
"unknown": "Unknown user"
|
||||
},
|
||||
"userHeader": {
|
||||
"closeSidebar": "Close sidebar",
|
||||
"links": {
|
||||
"community": "Community",
|
||||
"library": "Library",
|
||||
"news": "News"
|
||||
},
|
||||
"profile": {
|
||||
"admin": "Admin Dashboard",
|
||||
"settings": "Account settings"
|
||||
}
|
||||
"links": { "community": "Community", "library": "Library", "news": "News" },
|
||||
"profile": { "admin": "Admin Dashboard", "settings": "Account settings" }
|
||||
},
|
||||
"users": {
|
||||
"admin": {
|
||||
"adminHeader": "Admin?",
|
||||
"adminUserLabel": "Admin user",
|
||||
"authLink": "Authentication {arrow}",
|
||||
"authentication": {
|
||||
"configure": "Configure",
|
||||
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
|
||||
@@ -664,7 +642,6 @@
|
||||
"srOpenOptions": "Open options",
|
||||
"title": "Authentication"
|
||||
},
|
||||
"authLink": "Authentication {arrow}",
|
||||
"authoptionsHeader": "Auth Options",
|
||||
"delete": "Delete",
|
||||
"deleteUser": "Delete user {0}",
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
"installDir": "(install_dir)/",
|
||||
"launchCmd": "Lancer l'exécutable/commande",
|
||||
"launchDesc": "Exécutable pour lancer le jeu",
|
||||
"launchPlaceholder": "jeu.exe",
|
||||
"launchPlaceholder": "jeu.exe --args",
|
||||
"loadingVersion": "Chargement des métadonnées de la version…",
|
||||
"noAdv": "Pas d'option avancée pour cette configuration.",
|
||||
"noVersions": "Pas de version à importer",
|
||||
@@ -479,7 +479,6 @@
|
||||
"search": "Chercher bibliothèque…",
|
||||
"subheader": "Organiser vos jeux en collections pour un accès facile, et accéder à tous vos jeux."
|
||||
},
|
||||
"lowest": "le plus bas",
|
||||
"news": {
|
||||
"article": {
|
||||
"add": "Ajouter",
|
||||
@@ -512,7 +511,6 @@
|
||||
"title": "Dernières Nouvelles"
|
||||
},
|
||||
"options": "Options",
|
||||
"security": "Sécurité",
|
||||
"selectLanguage": "Sélectionner la langue",
|
||||
"settings": {
|
||||
"admin": {
|
||||
|
||||
32
i18n/scripts/detect-keys.ts
Normal file
32
i18n/scripts/detect-keys.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
54
i18n/scripts/rewrite-keys.ts
Normal file
54
i18n/scripts/rewrite-keys.ts
Normal 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
122
i18n/scripts/utils.ts
Normal 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();
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
<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 />
|
||||
<div class="grow flex">
|
||||
<NuxtPage />
|
||||
@@ -12,9 +15,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const clientRequest = isClientRequest();
|
||||
const i18nHead = useLocaleHead();
|
||||
const noWrapper = !!route.query.noWrapper;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import { execSync } from "node:child_process";
|
||||
import { cpSync, readFileSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import module from "module";
|
||||
import { findPackageJSON } from "node:module";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import { type } from "arktype";
|
||||
|
||||
@@ -11,14 +11,6 @@ const packageJsonSchema = type({
|
||||
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
|
||||
const dropVersion = getDropVersion();
|
||||
|
||||
@@ -64,7 +56,7 @@ export default defineNuxtConfig({
|
||||
|
||||
experimental: {
|
||||
buildCache: true,
|
||||
viewTransition: true,
|
||||
viewTransition: false,
|
||||
componentIslands: true,
|
||||
},
|
||||
|
||||
@@ -92,6 +84,13 @@ export default defineNuxtConfig({
|
||||
|
||||
hooks: {
|
||||
"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
|
||||
// https://github.com/nuxt/nuxt/issues/18918#issuecomment-1925774964
|
||||
// copy emojis to .output/public/twemoji
|
||||
@@ -140,7 +139,6 @@ export default defineNuxtConfig({
|
||||
|
||||
scheduledTasks: {
|
||||
"0 * * * *": ["dailyTasks"],
|
||||
"*/30 * * * *": ["downloadCleanup"],
|
||||
},
|
||||
|
||||
storage: {
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "drop",
|
||||
"version": "0.3.5",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordapp/twemoji": "^16.0.1",
|
||||
"@drop-oss/droplet": "3.5.0",
|
||||
"@drop-oss/droplet": "5.3.1",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@lobomfz/prismark": "0.0.3",
|
||||
@@ -29,23 +29,28 @@
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxtjs/i18n": "^9.5.5",
|
||||
"@prisma/client": "^6.11.1",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@vueuse/nuxt": "13.6.0",
|
||||
"argon2": "^0.43.0",
|
||||
"arktype": "^2.1.10",
|
||||
"axios": "^1.12.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cbor2": "^2.0.1",
|
||||
"cheerio": "^1.0.0",
|
||||
"cookie-es": "^2.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"file-type-mime": "^0.4.3",
|
||||
"jdenticon": "^3.3.0",
|
||||
"kjua": "^0.10.0",
|
||||
"luxon": "^3.6.1",
|
||||
"micromark": "^4.0.1",
|
||||
"normalize-url": "^8.0.2",
|
||||
"nuxt": "^3.17.4",
|
||||
"nuxt-security": "2.2.0",
|
||||
"otp-io": "^1.2.7",
|
||||
"parse-cosekey": "^1.0.2",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"prisma": "6.11.1",
|
||||
@@ -70,6 +75,7 @@
|
||||
"@types/node": "^22.13.16",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@typescript-eslint/utils": "^8.50.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
@@ -77,6 +83,7 @@
|
||||
"nitropack": "^2.11.12",
|
||||
"ofetch": "^1.4.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"sass": "^1.79.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
@@ -1,3 +1,232 @@
|
||||
<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 →</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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-y-4 max-w-lg">
|
||||
<div class="flex flex-col gap-y-4 max-w-[35vw]">
|
||||
<Listbox
|
||||
as="div"
|
||||
:model-value="currentlySelectedVersion"
|
||||
@@ -73,139 +73,59 @@
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<div v-if="versionGuesses" class="flex flex-col gap-8">
|
||||
<div v-if="versionGuesses" class="flex flex-col gap-4">
|
||||
<!-- 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
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-none sm:text-sm"
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="guess in setupFilteredVersionGuesses"
|
||||
:key="guess.filename"
|
||||
v-slot="{ active, selected }"
|
||||
:value="guess.filename"
|
||||
as="template"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-x-2 block truncate',
|
||||
selected && 'font-semibold',
|
||||
]"
|
||||
>
|
||||
{{ guess.filename }}
|
||||
<component
|
||||
:is="PLATFORM_ICONS[guess.platform as PlatformClient]"
|
||||
class="size-5"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="selected"
|
||||
:class="[
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||
active ? 'text-white' : 'text-blue-600',
|
||||
]"
|
||||
>
|
||||
<CheckIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<ComboboxOption
|
||||
v-if="setupProcessQuery"
|
||||
v-slot="{ active, selected }"
|
||||
:value="setupProcessQuery"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||
active
|
||||
? 'bg-blue-600 text-white outline-none'
|
||||
: 'text-zinc-100',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="['block truncate', selected && 'font-semibold']"
|
||||
>
|
||||
{{ $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 class="bg-zinc-800 p-4 rounded-xl relative flex flex-col gap-y-2">
|
||||
<div>
|
||||
<label 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>
|
||||
<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>
|
||||
<!-- 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">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
@@ -220,7 +140,7 @@
|
||||
<Switch
|
||||
v-model="versionSettings.onlySetup"
|
||||
: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',
|
||||
]"
|
||||
>
|
||||
@@ -233,143 +153,62 @@
|
||||
/>
|
||||
</Switch>
|
||||
</SwitchGroup>
|
||||
<div class="relative">
|
||||
<label
|
||||
for="startup"
|
||||
class="block text-sm font-medium leading-6 text-zinc-100"
|
||||
>{{ $t("library.admin.import.version.launchCmd") }}</label
|
||||
>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.launchDesc") }}
|
||||
</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>
|
||||
<!-- launch executables -->
|
||||
<div class="relative flex flex-col gap-y-2 bg-zinc-800 p-4 rounded-xl">
|
||||
<div>
|
||||
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
|
||||
$t("library.admin.import.version.launchCmd")
|
||||
}}</label>
|
||||
<p class="text-zinc-400 text-xs">
|
||||
{{ $t("library.admin.import.version.launchDesc") }}
|
||||
</p>
|
||||
</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
|
||||
v-if="versionSettings.onlySetup"
|
||||
class="absolute inset-0 bg-zinc-900/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PlatformSelector v-model="versionSettings.platform">
|
||||
{{ $t("library.admin.import.version.platform") }}
|
||||
</PlatformSelector>
|
||||
<SwitchGroup as="div" class="flex items-center justify-between">
|
||||
<SwitchGroup
|
||||
as="div"
|
||||
class="bg-zinc-800 p-4 rounded-xl flex items-center gap-4 justify-between"
|
||||
>
|
||||
<span class="flex flex-grow flex-col">
|
||||
<SwitchLabel
|
||||
as="span"
|
||||
@@ -385,7 +224,7 @@
|
||||
<Switch
|
||||
v-model="versionSettings.delta"
|
||||
: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',
|
||||
]"
|
||||
>
|
||||
@@ -398,89 +237,9 @@
|
||||
/>
|
||||
</Switch>
|
||||
</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
|
||||
class="w-fit"
|
||||
class="w-fit ml-auto"
|
||||
:loading="importLoading"
|
||||
@click="startImport_wrapper"
|
||||
>
|
||||
@@ -536,18 +295,15 @@ import {
|
||||
SwitchDescription,
|
||||
SwitchGroup,
|
||||
SwitchLabel,
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Combobox,
|
||||
ComboboxButton,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
|
||||
import {
|
||||
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({
|
||||
layout: "admin",
|
||||
@@ -561,76 +317,16 @@ const versions = await $dropFetch(
|
||||
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
|
||||
);
|
||||
const currentlySelectedVersion = ref(-1);
|
||||
const versionSettings = ref<{
|
||||
platform: PlatformClient | undefined;
|
||||
|
||||
onlySetup: boolean;
|
||||
launch: string;
|
||||
launchArgs: string;
|
||||
setup: string;
|
||||
setupArgs: string;
|
||||
|
||||
delta: boolean;
|
||||
umuId: string;
|
||||
}>({
|
||||
platform: undefined,
|
||||
launch: "",
|
||||
launchArgs: "",
|
||||
setup: "",
|
||||
setupArgs: "",
|
||||
const versionSettings = ref<typeof ImportVersion.infer>({
|
||||
id: gameId,
|
||||
version: "",
|
||||
delta: false,
|
||||
onlySetup: false,
|
||||
umuId: "",
|
||||
launches: [],
|
||||
setups: [],
|
||||
});
|
||||
|
||||
const versionGuesses =
|
||||
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 versionGuesses = ref<Array<{ platform: Platform; filename: string }>>();
|
||||
|
||||
const importLoading = ref(false);
|
||||
const importError = ref<string | undefined>();
|
||||
@@ -639,15 +335,19 @@ async function updateCurrentlySelectedVersion(value: number) {
|
||||
if (currentlySelectedVersion.value == value) return;
|
||||
currentlySelectedVersion.value = value;
|
||||
const version = versions[currentlySelectedVersion.value];
|
||||
const results = await $dropFetch(
|
||||
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
|
||||
gameId,
|
||||
)}&version=${encodeURIComponent(version)}`,
|
||||
);
|
||||
versionGuesses.value = results.map((e) => ({
|
||||
...e,
|
||||
platform: e.platform as PlatformClient,
|
||||
}));
|
||||
try {
|
||||
const results = await $dropFetch(
|
||||
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
|
||||
gameId,
|
||||
)}&version=${encodeURIComponent(version)}`,
|
||||
{
|
||||
failTitle: "Failed to fetch version information",
|
||||
},
|
||||
);
|
||||
versionGuesses.value = results as typeof versionGuesses.value;
|
||||
} catch {
|
||||
currentlySelectedVersion.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
@@ -655,9 +355,9 @@ async function startImport() {
|
||||
const taskId = await $dropFetch("/api/v1/admin/import/version", {
|
||||
method: "POST",
|
||||
body: {
|
||||
...versionSettings.value,
|
||||
id: gameId,
|
||||
version: versions[currentlySelectedVersion.value],
|
||||
...versionSettings.value,
|
||||
},
|
||||
});
|
||||
router.push(`/admin/task/${taskId.taskId}`);
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-950 w-full flex flex-col sm:flex-row items-center gap-2 justify-between pr-2"
|
||||
class="bg-zinc-950 w-full flex flex-row items-center gap-2 justify-between px-2 pt-6 lg:pt-0"
|
||||
>
|
||||
<!--start-->
|
||||
<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">
|
||||
<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"
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
<div class="pt-4 inline-flex gap-x-2">
|
||||
<div class="hidden sm:inline-flex pt-4 gap-x-2">
|
||||
<div
|
||||
v-for="[value, { icon }] in Object.entries(components)"
|
||||
:key="value"
|
||||
@@ -93,7 +93,7 @@
|
||||
<NuxtLink
|
||||
:href="`/store/${game.id}`"
|
||||
type="button"
|
||||
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
|
||||
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") }}
|
||||
<ArrowTopRightOnSquareIcon
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
import {
|
||||
BuildingStorefrontIcon,
|
||||
CodeBracketIcon,
|
||||
ServerIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
|
||||
const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
@@ -57,6 +58,12 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||
prefix: "/admin/settings/tokens",
|
||||
icon: CodeBracketIcon,
|
||||
},
|
||||
{
|
||||
label: "Services",
|
||||
route: "/admin/settings/services",
|
||||
prefix: "/admin/settings/services",
|
||||
icon: ServerIcon,
|
||||
},
|
||||
];
|
||||
|
||||
// const notifications = useNotifications();
|
||||
|
||||
91
pages/admin/settings/services.vue
Normal file
91
pages/admin/settings/services.vue
Normal 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>
|
||||
@@ -206,7 +206,6 @@ async function createToken(
|
||||
},
|
||||
failTitle: "Failed to create API token.",
|
||||
});
|
||||
console.log(result);
|
||||
newToken.value = result.token;
|
||||
tokens.value.push(result);
|
||||
} catch {
|
||||
|
||||
@@ -44,8 +44,25 @@
|
||||
</div>
|
||||
{{ task.name }}
|
||||
</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
|
||||
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
|
||||
v-for="(_, idx) in task.log"
|
||||
|
||||
@@ -164,8 +164,8 @@ const scheduledTasks: {
|
||||
description: "",
|
||||
},
|
||||
debug: {
|
||||
name: "Debug Task",
|
||||
description: "Does debugging things.",
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
40
pages/auth/mfa.vue
Normal file
40
pages/auth/mfa.vue
Normal 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">←</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
65
pages/auth/mfa/index.vue
Normal 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
78
pages/auth/mfa/totp.vue
Normal 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>
|
||||
92
pages/auth/mfa/webauthn.vue
Normal file
92
pages/auth/mfa/webauthn.vue
Normal 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>
|
||||
@@ -9,10 +9,18 @@
|
||||
<h2
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -49,11 +57,16 @@ import DropLogo from "~/components/DropLogo.vue";
|
||||
const { t } = useI18n();
|
||||
const enabledAuths = await $dropFetch("/api/v1/auth");
|
||||
|
||||
const route = useRoute();
|
||||
const superlevel = route.query.superlevel;
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: t("auth.signin.pageTitle"),
|
||||
title: superlevel
|
||||
? "Sign in to access protected action"
|
||||
: t("auth.signin.pageTitle"),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -8,20 +8,7 @@
|
||||
{{ $t("auth.code.description") }}
|
||||
</p>
|
||||
<div v-if="!loading" class="mt-8 flex flex-row gap-4">
|
||||
<input
|
||||
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)"
|
||||
/>
|
||||
<CodeInput @complete="(code) => complete(code)" />
|
||||
</div>
|
||||
<div v-else class="mt-8 flex items-center justify-center">
|
||||
<svg
|
||||
@@ -65,58 +52,10 @@
|
||||
import { XCircleIcon } from "@heroicons/vue/24/solid";
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
const codeLength = 7;
|
||||
const placeholder = "1A2B3C4";
|
||||
const codeElements = useTemplateRef("codeElements");
|
||||
const code = ref<string[]>([]);
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
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) {
|
||||
loading.value = true;
|
||||
try {
|
||||
|
||||
@@ -52,16 +52,3 @@ useHead({
|
||||
title: collection.value?.name || t("library.collection.title"),
|
||||
});
|
||||
</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>
|
||||
|
||||
@@ -113,16 +113,6 @@ useHead({
|
||||
|
||||
<style scoped>
|
||||
/* 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-enter-active,
|
||||
|
||||
32
pages/mfa/setup/successful.vue
Normal file
32
pages/mfa/setup/successful.vue
Normal 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">←</span> Back to account
|
||||
security</NuxtLink
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
91
pages/mfa/setup/totp.vue
Normal file
91
pages/mfa/setup/totp.vue
Normal 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>
|
||||
133
pages/mfa/setup/webauthn.vue
Normal file
133
pages/mfa/setup/webauthn.vue
Normal 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>
|
||||
@@ -152,16 +152,3 @@ useHead({
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<h1 class="text-4xl font-display font-bold text-zinc-100">
|
||||
{{ $t("setup.welcome") }}
|
||||
</h1>
|
||||
<LanguageSelectorListbox :show-text="false" class="mt-4 max-w-sm" />
|
||||
<SelectorLanguageListbox :show-text="false" class="mt-4 max-w-sm" />
|
||||
<p class="mt-6 text-zinc-400 max-w-xl">
|
||||
{{ $t("setup.welcomeDescription") }}
|
||||
</p>
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</td>
|
||||
<td
|
||||
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">{{
|
||||
$t("store.commingSoon")
|
||||
@@ -231,30 +231,9 @@
|
||||
|
||||
<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"
|
||||
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>
|
||||
@@ -266,7 +245,6 @@
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
import { StarIcon } from "@heroicons/vue/24/solid";
|
||||
import { micromark } from "micromark";
|
||||
import type { PlatformClient } from "~/composables/types";
|
||||
import { formatBytes } from "~/server/internal/utils/files";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -276,32 +254,11 @@ const user = useUser();
|
||||
|
||||
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 showReadMore = previewHTML != descriptionHTML;
|
||||
const platforms = game.versions
|
||||
.map((e) => e.platform as PlatformClient)
|
||||
.map((e) => e.launches.map((v) => v.platform))
|
||||
.flat()
|
||||
.flat()
|
||||
.filter((e, i, u) => u.indexOf(e) === i);
|
||||
|
||||
|
||||
611
pnpm-lock.yaml
generated
611
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
onlyBuiltDependencies:
|
||||
- "@parcel/watcher"
|
||||
- "@prisma/client"
|
||||
- "@prisma/engines"
|
||||
- "@tailwindcss/oxide"
|
||||
- argon2
|
||||
- esbuild
|
||||
- prisma
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
||||
overrides:
|
||||
droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet
|
||||
|
||||
@@ -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));
|
||||
14
prisma/migrations/20251218081603_add_depots/migration.sql
Normal file
14
prisma/migrations/20251218081603_add_depots/migration.sql
Normal 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));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
26
prisma/migrations/20251220103245_multi_setups/migration.sql
Normal file
26
prisma/migrations/20251220103245_multi_setups/migration.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
22
prisma/migrations/20251230040838_add_mfa/migration.sql
Normal file
22
prisma/migrations/20251230040838_add_mfa/migration.sql
Normal 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;
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
11
prisma/migrations/20260111022718_/migration.sql
Normal file
11
prisma/migrations/20260111022718_/migration.sql
Normal 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));
|
||||
@@ -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));
|
||||
@@ -30,3 +30,9 @@ model Library {
|
||||
|
||||
games Game[]
|
||||
}
|
||||
|
||||
model Depot {
|
||||
id String @id @default(uuid())
|
||||
endpoint String
|
||||
key String @default(uuid())
|
||||
}
|
||||
|
||||
@@ -11,7 +11,25 @@ model LinkedAuthMec {
|
||||
version Int @default(1)
|
||||
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])
|
||||
}
|
||||
@@ -63,7 +81,7 @@ model Session {
|
||||
token String @id
|
||||
expiresAt DateTime
|
||||
|
||||
userId String
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
data Json // misc extra data
|
||||
|
||||
@@ -17,7 +17,8 @@ model Game {
|
||||
|
||||
// Any field prefixed with m is filled in from metadata
|
||||
// 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
|
||||
mDescription String // Supports markdown
|
||||
mReleased DateTime // When the game was released
|
||||
@@ -36,8 +37,8 @@ model Game {
|
||||
|
||||
// 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
|
||||
libraryId String?
|
||||
library Library? @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
libraryId String
|
||||
library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
libraryPath String
|
||||
|
||||
collections CollectionEntry[]
|
||||
@@ -51,18 +52,18 @@ model Game {
|
||||
|
||||
@@unique([metadataSource, metadataId], name: "metadataKey")
|
||||
@@unique([libraryId, libraryPath], name: "libraryKey")
|
||||
@@index([mName(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
|
||||
}
|
||||
|
||||
model GameTag {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
|
||||
games Game[]
|
||||
games Game[]
|
||||
|
||||
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
|
||||
}
|
||||
|
||||
|
||||
model GameRating {
|
||||
id String @id @default(uuid())
|
||||
|
||||
@@ -83,28 +84,60 @@ model GameRating {
|
||||
|
||||
// A particular set of files that relate to the version
|
||||
model GameVersion {
|
||||
gameId String
|
||||
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
versionName String // Sub directory for the game files
|
||||
gameId String
|
||||
game Game @relation(fields: [gameId], references: [id], onDelete: Cascade)
|
||||
versionId String @default(uuid())
|
||||
|
||||
displayName String?
|
||||
versionPath String
|
||||
|
||||
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
|
||||
launchArgs String[]
|
||||
setupCommand String @default("") // Command to setup game (dependencies and such)
|
||||
setupArgs String[]
|
||||
onlySetup Boolean @default(false)
|
||||
|
||||
umuIdOverride String?
|
||||
onlySetup Boolean @default(false)
|
||||
|
||||
dropletManifest Json // Results from droplet
|
||||
|
||||
versionIndex Int
|
||||
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
|
||||
|
||||
@@ -11,6 +11,8 @@ model Task {
|
||||
progress Float
|
||||
log String[]
|
||||
|
||||
actions String[]
|
||||
|
||||
acls String[]
|
||||
|
||||
@@id([id, started])
|
||||
|
||||
@@ -8,7 +8,9 @@ model User {
|
||||
displayName String
|
||||
profilePictureObjectId String // Object
|
||||
|
||||
authMecs LinkedAuthMec[]
|
||||
authMecs LinkedAuthMec[]
|
||||
mfas LinkedMFAMec[]
|
||||
|
||||
clients Client[]
|
||||
notifications Notification[]
|
||||
collections Collection[]
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { TSESLint } from "@typescript-eslint/utils";
|
||||
|
||||
const blacklistedFunctions = ["delete", "update"];
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Don't use Prisma error-prone .delete function",
|
||||
description: "Don't use Prisma error-prone .delete or .update function",
|
||||
},
|
||||
messages: {
|
||||
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: [],
|
||||
},
|
||||
@@ -17,7 +19,7 @@ export default {
|
||||
CallExpression: function (node) {
|
||||
// @ts-expect-error It ain't typing properly
|
||||
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
|
||||
const tableExpr = node.callee.object;
|
||||
if (!tableExpr) return;
|
||||
|
||||
@@ -32,20 +32,20 @@ export default defineEventHandler(async (h3) => {
|
||||
statusMessage: "Upload at least one file.",
|
||||
});
|
||||
|
||||
try {
|
||||
await objectHandler.deleteAsSystem(company.mBannerObjectId);
|
||||
await prisma.company.update({
|
||||
where: {
|
||||
id: companyId,
|
||||
},
|
||||
data: {
|
||||
mBannerObjectId: id,
|
||||
},
|
||||
});
|
||||
await pull();
|
||||
} catch {
|
||||
await objectHandler.deleteAsSystem(company.mBannerObjectId);
|
||||
const { count } = await prisma.company.updateMany({
|
||||
where: {
|
||||
id: companyId,
|
||||
},
|
||||
data: {
|
||||
mBannerObjectId: id,
|
||||
},
|
||||
});
|
||||
if (count == 0) {
|
||||
await dump();
|
||||
throw createError({ statusCode: 404, message: "Company not found" });
|
||||
}
|
||||
await pull();
|
||||
|
||||
return { id: id };
|
||||
});
|
||||
|
||||
@@ -15,6 +15,15 @@ export default defineEventHandler(async (h3) => {
|
||||
|
||||
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({
|
||||
where: {
|
||||
id: body.id,
|
||||
|
||||
@@ -20,6 +20,11 @@ export default defineEventHandler(async (h3) => {
|
||||
const action = body.action === "developed" ? "developers" : "publishers";
|
||||
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({
|
||||
where: {
|
||||
id: body.id,
|
||||
|
||||
@@ -43,6 +43,15 @@ export default defineEventHandler(async (h3) => {
|
||||
}
|
||||
: 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({
|
||||
where: {
|
||||
id: body.id,
|
||||
|
||||
@@ -32,20 +32,21 @@ export default defineEventHandler(async (h3) => {
|
||||
statusMessage: "Upload at least one file.",
|
||||
});
|
||||
|
||||
try {
|
||||
await objectHandler.deleteAsSystem(company.mLogoObjectId);
|
||||
await prisma.company.update({
|
||||
where: {
|
||||
id: companyId,
|
||||
},
|
||||
data: {
|
||||
mLogoObjectId: id,
|
||||
},
|
||||
});
|
||||
await pull();
|
||||
} catch {
|
||||
await objectHandler.deleteAsSystem(company.mLogoObjectId);
|
||||
const { count } = await prisma.company.updateMany({
|
||||
where: {
|
||||
id: companyId,
|
||||
},
|
||||
data: {
|
||||
mLogoObjectId: id,
|
||||
},
|
||||
});
|
||||
if (count == 0) {
|
||||
await dump();
|
||||
throw createError({ statusCode: 404, message: "Company not found" });
|
||||
}
|
||||
|
||||
await pull();
|
||||
|
||||
return { id: id };
|
||||
});
|
||||
|
||||
@@ -11,13 +11,17 @@ export default defineEventHandler(async (h3) => {
|
||||
const restOfTheBody = { ...body };
|
||||
delete restOfTheBody["id"];
|
||||
|
||||
const newObj = await prisma.company.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: restOfTheBody,
|
||||
// I would put a select here, but it would be based on the body, and muck up the types
|
||||
});
|
||||
const newObj = (
|
||||
await prisma.company.updateManyAndReturn({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
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;
|
||||
});
|
||||
|
||||
21
server/api/v1/admin/depot/index.post.ts
Normal file
21
server/api/v1/admin/depot/index.post.ts
Normal 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
Reference in New Issue
Block a user