update to nuxt 4

This commit is contained in:
DecDuck
2025-09-20 11:20:49 +10:00
parent b4f9b77809
commit 2db8e753b7
313 changed files with 508 additions and 512 deletions

65
app/app.vue Normal file
View File

@ -0,0 +1,65 @@
<template>
<NuxtLoadingIndicator color="#2563eb" />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<ModalStack />
<div
v-if="showExternalUrlWarning"
class="fixed flex flex-row gap-x-2 right-0 bottom-0 m-2 px-2 py-2 z-50 text-right bg-red-700/90 rounded-lg"
>
<div class="flex flex-col">
<span class="text-sm text-zinc-200 font-bold font-display">{{
$t("errors.externalUrl.title")
}}</span>
<span class="text-xs text-red-400">{{
$t("errors.externalUrl.subtitle")
}}</span>
</div>
<button class="text-red-200" @click="() => hideExternalURL()">
<XMarkIcon class="size-5" />
</button>
</div>
</template>
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/outline";
await updateUser();
const user = useUser();
const apiDetails = await $dropFetch("/api/v1");
const showExternalUrlWarning = ref(false);
function checkExternalUrl() {
if (!import.meta.client) return;
const realOrigin = window.location.origin.trim();
const chosenOrigin = apiDetails.external.trim();
const ignore = window.localStorage.getItem("ignoreExternalUrl");
if (ignore && ignore == "true") return;
showExternalUrlWarning.value = !(realOrigin == chosenOrigin);
}
function hideExternalURL() {
window.localStorage.setItem("ignoreExternalUrl", "true");
showExternalUrlWarning.value = false;
}
if (user.value?.admin) {
onMounted(() => {
checkExternalUrl();
});
}
</script>
<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>

77
app/assets/core.scss Normal file
View File

@ -0,0 +1,77 @@
$motiva: (
("MotivaSansThin.ttf", "ttf", 100, normal),
("MotivaSansLight.woff.ttf", "woff", 300, normal),
("MotivaSansRegular.woff.ttf", "woff", 400, normal),
("MotivaSansMedium.woff.ttf", "woff", 500, normal),
("MotivaSansBold.woff.ttf", "woff", 600, normal),
("MotivaSansExtraBold.ttf", "woff", 700, normal),
("MotivaSansBlack.woff.ttf", "woff", 900, normal)
);
$helvetica: (
("Helvetica.woff", "woff", 400, normal),
("Helvetica-Oblique.woff", "woff", 400, italic),
("Helvetica-Bold.woff", "woff", 600, normal),
("Helvetica-BoldOblique.woff", "woff", 600, italic),
("helvetica-light-587ebe5a59211.woff2", "woff2", 300, normal)
);
@each $file, $format, $weight, $style in $motiva {
@font-face {
font-family: "Motiva Sans";
src: url("/fonts/motiva/#{$file}") format($format);
font-weight: $weight;
font-style: $style;
}
}
@each $file, $format, $weight, $style in $helvetica {
@font-face {
font-family: "Helvetica";
src: url("/fonts/helvetica/#{$file}") format($format);
font-weight: $weight;
font-style: $style;
}
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable.ttf");
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable-Italic.ttf");
font-style: italic;
}
.carousel__icon {
color: #f4f4f5;
}
.carousel__pagination-button::after {
background-color: #3f3f46;
border-radius: 999999px;
}
.carousel__pagination-button:hover::after {
background-color: #27272a;
border-radius: 999999px;
}
.carousel__pagination-button--active::after {
background-color: #a1a1aa;
}
.carousel__pagination-button--active:hover::after {
background-color: #d4d4d8;
}
.store-carousel > .carousel__viewport {
overflow: visible !important;
}
button {
cursor: pointer !important;
}
html {
background-color: oklch(0.21 0.006 285.885);
}

View File

@ -0,0 +1,13 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@config "../../tailwind.config.js";
@layer base {
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button,
input[type="number"] {
-webkit-appearance: none;
-moz-appearance: textfield !important;
}
}

View File

@ -0,0 +1,92 @@
<template>
<div class="flex grow flex-col gap-y-5 overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<UserIcon class="size-5" /> {{ $t("account.title") }}
</span>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" class="-mx-2 space-y-1">
<li v-for="(item, itemIdx) in navigation" :key="item.route">
<NuxtLink
:href="item.route"
:class="[
itemIdx == currentPageIndex
? 'bg-zinc-800 text-white'
: 'text-zinc-400 hover:bg-zinc-800 hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold',
]"
>
<component
:is="item.icon"
class="size-6 shrink-0"
aria-hidden="true"
/>
{{ item.label }}
<span
v-if="item.count !== undefined"
class="ml-auto w-9 min-w-max whitespace-nowrap rounded-full bg-zinc-900 px-2.5 py-0.5 text-center text-xs/5 font-medium text-white ring-1 ring-inset ring-zinc-700"
aria-hidden="true"
>{{ item.count }}</span
>
</NuxtLink>
</li>
</ul>
</li>
</ul>
</nav>
</div>
</template>
<script setup lang="ts">
import {
BellIcon,
HomeIcon,
LockClosedIcon,
DevicePhoneMobileIcon,
WrenchScrewdriverIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
import { UserIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue";
const notifications = useNotifications();
const { t } = useI18n();
const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
{ label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" },
{
label: t("security"),
route: "/account/security",
prefix: "/account/security",
icon: LockClosedIcon,
},
{
label: t("account.devices.title"),
route: "/account/devices",
prefix: "/account/devices",
icon: DevicePhoneMobileIcon,
},
{
label: t("account.notifications.notifications"),
route: "/account/notifications",
prefix: "/account/notifications",
icon: BellIcon,
count: notifications.value.length,
},
{
label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{
label: t("account.settings"),
route: "/account/settings",
prefix: "/account/settings",
icon: WrenchScrewdriverIcon,
},
];
const currentPageIndex = useCurrentNavigationIndex(navigation);
</script>

View File

@ -0,0 +1,153 @@
<template>
<div
class="inline-flex w-full group hover:scale-105 transition-all duration-200"
>
<LoadingButton
:loading="isLibraryLoading"
:style="'none'"
class="transition w-full inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
@click="() => toggleLibrary()"
>
{{ inLibrary ? $t("library.inLib") : $t("library.addToLib") }}
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</LoadingButton>
<!-- Collections dropdown -->
<Menu as="div" class="relative">
<MenuButton
as="div"
class="transition cursor-pointer inline-flex items-center rounded-r-md h-full ml-[2px] bg-white/10 hover:bg-white/20 backdrop-blur py-3.5 px-2 justify-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/20"
>
<ChevronDownIcon class="h-5 w-5 text-white" aria-hidden="true" />
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-50 mt-2 w-72 origin-top-right rounded-md bg-zinc-800/90 backdrop-blur shadow-lg focus:outline-none"
>
<div class="p-2">
<div
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
>
{{ $t("library.collection.collections") }}
</div>
<div
class="flex flex-col gap-y-2 py-1 max-h-[150px] overflow-y-auto"
>
<div
v-if="collections.length === 0"
class="px-3 py-2 text-sm text-zinc-500"
>
{{ $t("library.collection.noCollections") }}
</div>
<MenuItem
v-for="(collection, collectionIdx) in collections"
:key="collection.id"
v-slot="{ active }"
>
<button
:class="[
active ? 'bg-zinc-700/90' : '',
'group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-zinc-200',
]"
@click="() => toggleCollection(collection.id)"
>
<span>{{ collection.name }}</span>
<CheckIcon
v-if="inCollections[collectionIdx]"
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</button>
</MenuItem>
</div>
<div class="border-t border-zinc-700 pt-1">
<LoadingButton
:loading="false"
class="w-full"
@click="createCollectionModal = true"
>
<PlusIcon class="mr-2 h-4 w-4" />
{{ $t("library.collection.addToNew") }}
</LoadingButton>
</div>
</div>
</MenuItems>
</transition>
</Menu>
</div>
<ModalCreateCollection
v-model="createCollectionModal"
:game-id="props.gameId"
/>
</template>
<script setup lang="ts">
import { PlusIcon, ChevronDownIcon, CheckIcon } from "@heroicons/vue/24/solid";
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
const props = defineProps<{
gameId: string;
}>();
const isLibraryLoading = ref(false);
const { t } = useI18n();
const createCollectionModal = ref(false);
const collections = await useCollections();
const library = await useLibrary();
const inLibrary = computed(
() => library.value.entries.findIndex((e) => e.gameId == props.gameId) != -1,
);
const inCollections = computed(() =>
collections.value.map(
(e) => e.entries.findIndex((e) => e.gameId == props.gameId) != -1,
),
);
async function toggleLibrary() {
isLibraryLoading.value = true;
try {
await $dropFetch("/api/v1/collection/default/entry", {
method: inLibrary.value ? "DELETE" : "POST",
body: {
id: props.gameId,
},
failTitle: t("errors.library.add.title"),
});
await refreshLibrary();
} finally {
isLibraryLoading.value = false;
}
}
async function toggleCollection(id: string) {
try {
const collection = collections.value.find((e) => e.id == id);
if (!collection) return;
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
await $dropFetch(`/api/v1/collection/:id/entry`, {
method: index == -1 ? "POST" : "DELETE",
params: { id },
body: {
id: props.gameId,
},
failTitle: t("errors.library.add.title"),
});
await refreshCollection(id);
} finally {
/* empty */
}
}
</script>

View File

@ -0,0 +1,18 @@
<template>
<div class="flex">
<a
:href="`/auth/oidc?redirect=${route.query.redirect ?? '/'}`"
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
>
<i18n-t keypath="auth.signin.externalProvider" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</a>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
</script>

View File

@ -0,0 +1,129 @@
<template>
<form class="space-y-6" @submit.prevent="signin_wrapper">
<div>
<label
for="username"
class="block text-sm font-medium leading-6 text-zinc-300"
>{{ $t("auth.username") }}</label
>
<div class="mt-2">
<input
id="username"
v-model="username"
name="username"
type="username"
autocomplete="username"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label
for="password"
class="block text-sm font-medium leading-6 text-zinc-300"
>{{ $t("auth.password") }}</label
>
<div class="mt-2">
<input
id="password"
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
v-model="rememberMe"
name="remember-me"
type="checkbox"
class="h-4 w-4 rounded bg-zinc-800 border-zinc-700 text-blue-600 focus:ring-blue-600"
/>
<label
for="remember-me"
class="ml-3 block text-sm leading-6 text-zinc-400"
>{{ $t("auth.signin.rememberMe") }}</label
>
</div>
<div class="text-sm leading-6">
<NuxtLink
to="#"
class="font-semibold text-blue-600 hover:text-blue-500"
>{{ $t("auth.signin.forgot") }}</NuxtLink
>
</div>
</div>
<div>
<LoadingButton class="w-full" :loading="loading">{{
$t("auth.signin.signin")
}}</LoadingButton>
</div>
<div v-if="error" class="mt-1 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { UserModel } from "~~/prisma/client/models";
const username = ref("");
const password = ref("");
const rememberMe = ref(false);
const loading = ref(false);
const error = ref<string | undefined>();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
function signin_wrapper() {
loading.value = true;
signin()
.then(() => {
router.push(route.query.redirect?.toString() ?? "/");
})
.catch((response) => {
const message = response.message || t("errors.unknown");
error.value = message;
})
.finally(() => {
loading.value = false;
});
}
async function signin() {
await $dropFetch("/api/v1/auth/signin/simple", {
method: "POST",
body: {
username: username.value,
password: password.value,
rememberMe: rememberMe.value,
},
});
const user = useUser();
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
}
</script>

View File

@ -0,0 +1,25 @@
<template>
<div class="flex flex-row flex-wrap gap-2 justify-center">
<button
v-for="(_, i) in amount"
:key="i"
:class="[
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
'transition-all cursor-pointer h-2 rounded-full',
]"
/>
</div>
</template>
<script setup lang="ts">
import { injectCarousel } from "vue3-carousel";
const carousel = inject(injectCarousel)!;
const amount = carousel.maxSlide - carousel.minSlide + 1;
// function slideTo(index: number) {
// const offsetIndex = index + carousel.minSlide;
// carousel.nav.slideTo(offsetIndex);
// }
</script>

View File

@ -0,0 +1,75 @@
<template>
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<Bars3Icon class="size-6" /> {{ $t("userHeader.links.library") }}
</span>
<!-- Search bar -->
<div class="mt-5 relative">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
autocomplete="off"
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
:placeholder="$t('library.search')"
/>
<MagnifyingGlassIcon
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
aria-hidden="true"
/>
</div>
<TransitionGroup
v-if="filteredLibrary.length > 0"
name="list"
tag="ul"
role="list"
class="mt-2 space-y-0.5"
>
<li v-for="game in filteredLibrary" :key="game.id" class="flex">
<NuxtLink
:to="`/library/game/${game.id}`"
class="flex flex-row items-center w-full p-2 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
>
<img
:src="useObject(game.mIconObjectId)"
class="h-5 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
alt=""
/>
<div class="min-w-0 flex-1 pl-2.5">
<p
class="text-sm font-semibold text-display text-zinc-200 truncate text-left"
>
{{ game.mName }}
</p>
</div>
</NuxtLink>
</li>
</TransitionGroup>
<p
v-else
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
>
{{ !!searchQuery ? $t("common.noResults") : $t("library.noGames") }}
</p>
</div>
</template>
<script setup lang="ts">
import { Bars3Icon, MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
const library = await useLibrary();
const searchQuery = ref("");
const filteredLibrary = computed(() =>
library.value.entries
.map((e) => e.game)
.filter((e) =>
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase()),
),
);
</script>

View File

@ -0,0 +1,212 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-900 px-6 py-6 ring-1 ring-white/10"
>
<!-- Search and filters -->
<div class="space-y-6">
<div>
<label for="search" class="sr-only">{{ $t("news.search") }}</label>
<div class="relative">
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"
>
<MagnifyingGlassIcon
class="h-5 w-5 text-zinc-400"
aria-hidden="true"
/>
</div>
<input
id="search"
v-model="searchQuery"
type="text"
class="block w-full rounded-md border-0 bg-zinc-800 py-2.5 pl-10 pr-3 text-zinc-100 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
:placeholder="$t('news.searchPlaceholder')"
/>
</div>
</div>
<div class="pt-2">
<label
for="date"
class="block text-sm font-medium text-zinc-400 mb-2"
>{{ $t("common.date") }}</label
>
<select
id="date"
v-model="dateFilter"
class="mt-1 block w-full rounded-md border-0 bg-zinc-800 py-2 pl-3 pr-10 text-zinc-100 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm sm:leading-6"
>
<option value="all">{{ $t("news.filter.all") }}</option>
<option value="today">{{ $t("common.today") }}</option>
<option value="week">{{ $t("news.filter.week") }}</option>
<option value="month">{{ $t("news.filter.month") }}</option>
<option value="year">{{ $t("news.filter.year") }}</option>
</select>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2">
{{ $t("common.tags") }}
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in availableTags"
:key="tag"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors duration-200"
:class="[
selectedTags.includes(tag)
? 'bg-blue-600 text-white'
: 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700',
]"
@click="toggleTag(tag)"
>
{{ tag }}
</button>
</div>
</div>
</div>
<nav class="flex-1 space-y-2">
<NuxtLink
v-for="article in filteredArticles"
:key="article.id"
:to="`/news/${article.id}`"
class="group block rounded-lg hover-lift"
>
<div
class="relative flex flex-col gap-y-2 rounded-lg p-3 transition-all duration-200"
:class="[
route.params.id === article.id
? 'bg-zinc-800'
: 'hover:bg-zinc-800/50',
]"
>
<div
v-if="article.imageObjectId"
class="absolute inset-0 rounded-lg transition-all duration-200 overflow-hidden"
>
<img
:src="useObject(article.imageObjectId)"
class="absolute blur-sm inset-0 w-full h-full object-cover transition-all duration-200 group-hover:scale-110"
/>
<div class="absolute inset-0 bg-zinc-900/50" />
</div>
<h3 class="relative text-sm font-medium text-zinc-100">
{{ article.title }}
</h3>
<p
class="relative mt-1 text-xs text-zinc-400 line-clamp-2"
v-html="formatExcerpt(article.description)"
/>
<div
class="relative mt-2 flex items-center gap-x-2 text-xs text-zinc-500"
>
<time :datetime="article.publishedAt">
{{ $d(new Date(article.publishedAt), "short") }}
</time>
</div>
</div>
</NuxtLink>
</nav>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
import { micromark } from "micromark";
const news = useNews();
if (!news.value) {
news.value = await fetchNews();
}
const route = useRoute();
const searchQuery = ref("");
const dateFilter = ref("all");
const selectedTags = ref<string[]>([]);
// Get unique tags from all articles
const availableTags = computed(() => {
if (!news.value) return [];
const tags = new Set<string>();
news.value.forEach((article) => {
article.tags.forEach((tag) => tags.add(tag.name));
});
return Array.from(tags);
});
const toggleTag = (tag: string) => {
const index = selectedTags.value.indexOf(tag);
if (index === -1) {
selectedTags.value.push(tag);
} else {
selectedTags.value.splice(index, 1);
}
};
const formatExcerpt = (excerpt: string) => {
// Convert markdown to HTML, micromark is safe
return micromark(excerpt);
};
const filteredArticles = computed(() => {
if (!news.value) return [];
// filter articles based on search, date, and tags
return news.value.filter((article) => {
const matchesSearch =
article.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
article.description
.toLowerCase()
.includes(searchQuery.value.toLowerCase());
const articleDate = new Date(article.publishedAt);
const now = new Date();
let matchesDate = true;
switch (dateFilter.value.toLowerCase()) {
case "today": {
matchesDate = articleDate.toDateString() === now.toDateString();
break;
}
case "week": {
const weekAgo = new Date(now.setDate(now.getDate() - 7));
matchesDate = articleDate >= weekAgo;
break;
}
case "month": {
matchesDate =
articleDate.getMonth() === now.getMonth() &&
articleDate.getFullYear() === now.getFullYear();
break;
}
case "year": {
matchesDate = articleDate.getFullYear() === now.getFullYear();
break;
}
}
const matchesTags =
selectedTags.value.length === 0 ||
selectedTags.value.every((tag) =>
article.tags.find((e) => e.name == tag),
);
return matchesSearch && matchesDate && matchesTags;
});
});
</script>
<style scoped>
.hover-lift {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.hover-lift:hover {
transform: translateY(-2px) scale(1.02);
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<svg
aria-label="Drop Logo"
class="text-blue-400"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="currentColor"
stroke-width="2"
stroke-dasharray="100"
:stroke-dashoffset="dashArray"
/>
</svg>
</template>
<script setup lang="ts">
const props = defineProps<{ progress?: number }>();
const dashArray = computed(() =>
props.progress === undefined ? 0 : ((100 - props.progress) / 100) * 50 + 50,
);
</script>

View File

@ -0,0 +1,18 @@
<template>
<div class="inline-flex justify-center items-center gap-x-1 -mb-1 relative">
<svg
aria-hidden="true"
viewBox="0 0 418 42"
class="absolute inset-0 h-full w-full fill-blue-300/30 scale-75"
preserveAspectRatio="none"
>
<path
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z"
/>
</svg>
<DropLogo aria-hidden="true" class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase">
{{ $t("drop.drop") }}
</span>
</div>
</template>

View File

@ -0,0 +1,15 @@
<template>
<img ref="emojiEl" class="inline-block emoji" :src="url" :alt="emoji" />
</template>
<script lang="ts" setup>
import twemoji from "@discordapp/twemoji";
const props = defineProps<{
emoji: string;
}>();
const url = computed(() => {
return `/twemoji/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
});
</script>

View File

@ -0,0 +1,76 @@
<template>
<div ref="currentComponent">
<ClientOnly fallback-tag="span">
<VueCarousel :items-to-show="singlePage" :items-to-scroll="singlePage">
<VueSlide
v-for="(game, gameIdx) in games"
:key="gameIdx"
class="justify-start"
>
<GamePanel
:game="game"
:href="game ? `/store/${game.id}` : undefined"
:show-title-description="showGamePanelTextDecoration"
/>
</VueSlide>
<template #addons>
<VueNavigation />
</template>
</VueCarousel>
<template #fallback>
<div
class="flex flex-nowrap flex-row overflow-hidden whitespace-nowrap"
>
<SkeletonCard
v-for="index in 10"
:key="index"
:loading="true"
class="mr-3 flex-none"
/>
</div>
</template>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
import type { GameModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
const props = defineProps<{
items: Array<SerializeObject<GameModel>>;
min?: number;
width?: number;
}>();
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const currentComponent = ref<HTMLDivElement>();
const min = computed(() => Math.max(props.min ?? 8, props.items.length));
const games: Ref<Array<SerializeObject<GameModel> | undefined>> = computed(() =>
Array(min.value)
.fill(0)
.map((_, i) => props.items[i]),
);
const singlePage = ref(2);
const sizeOfCard = 192 + 10;
const handleResize = () => {
singlePage.value =
(props.width ??
currentComponent.value?.parentElement?.clientWidth ??
window.innerWidth) / sizeOfCard;
};
onMounted(() => {
handleResize();
window.addEventListener("resize", handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleResize);
});
</script>

View File

@ -0,0 +1,772 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game!">
<div class="grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col">
<div
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
>
<div class="inline-flex items-center gap-4">
<!-- icon image -->
<img :src="coreMetadataIconUrl" class="size-20" />
<div>
<h1 class="text-5xl font-bold font-display text-zinc-100">
{{ game.mName }}
</h1>
<p class="mt-1 text-lg text-zinc-400">
{{ game.mShortDescription }}
</p>
</div>
</div>
<button
type="button"
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showEditCoreMetadata = true)"
>
{{ $t("common.edit") }} <PencilIcon class="size-4" />
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" />
</div>
<!-- image carousel pick -->
<div class="border-b border-zinc-700">
<div class="border-b border-zinc-700 py-4">
<div
class="-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<div class="ml-4 mt-4">
<h3 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.game.imageCarousel") }}
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
{{ $t("library.admin.game.imageCarouselDescription") }}
</p>
</div>
<div class="ml-4 mt-4 shrink-0">
<button
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showAddCarouselModal = true)"
>
{{ $t("library.admin.game.addImageCarousel") }}
</button>
</div>
</div>
</div>
<div
v-if="game.mImageCarouselObjectIds.length == 0"
class="text-zinc-400 text-center py-8"
>
{{ $t("library.admin.game.imageCarouselEmpty") }}
</div>
<draggable
v-else
:list="game.mImageCarouselObjectIds"
class="w-full flex flex-row gap-x-4 overflow-x-auto my-2 py-4"
@update="() => updateImageCarousel()"
>
<template #item="{ element }: { element: string }">
<div class="relative group min-w-fit">
<img :src="useObject(element)" class="h-48 w-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => removeImageFromCarousel(element)"
>
{{ $t("library.admin.game.removeImageCarousel") }}
</button>
</div>
</div>
</template>
</draggable>
</div>
<!-- description editor -->
<div
class="mt-4 grow flex flex-col w-full space-y-4 border border-zinc-800 rounded overflow-hidden p-2"
>
<!-- toolbar -->
<div
class="h-8 bg-zinc-800 rounded inline-flex gap-x-4 items-center justify-start p-2"
>
<div>
<CheckIcon
v-if="descriptionSaving == DescriptionSavingState.NotLoading"
class="size-5 text-zinc-100"
/>
<div
v-else-if="descriptionSaving == DescriptionSavingState.Waiting"
>
<PencilIcon class="animate-pulse size-5 text-zinc-100" />
</div>
<div
v-else-if="descriptionSaving == DescriptionSavingState.Loading"
role="status"
>
<svg
aria-hidden="true"
class="w-5 h-5 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">{{ $t("common.srLoading") }}</span>
</div>
</div>
<button @click="() => (showAddImageDescriptionModal = true)">
<PhotoIcon
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
</button>
<button
class="block lg:hidden"
@click="
() => (mobileShowFinalDescription = !mobileShowFinalDescription)
"
>
<DocumentIcon
v-if="!mobileShowFinalDescription"
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
<PencilIcon
v-else
class="transition size-5 text-zinc-100 hover:text-zinc-300"
/>
</button>
</div>
<!-- edit area -->
<div class="grid lg:grid-cols-2 lg:gap-x-8 grow">
<!-- editing box -->
<div
:class="[
mobileShowFinalDescription ? 'hidden' : 'block',
'lg:block',
]"
>
<textarea
ref="descriptionEditor"
v-model="game.mDescription"
class="grow h-full w-full bg-zinc-950/30 text-zinc-100 border-zinc-900 rounded"
/>
</div>
<!-- result box -->
<div
:class="[
mobileShowFinalDescription ? 'block' : 'hidden',
'lg:block prose prose-invert prose-blue bg-zinc-950/30 rounded px-4 py-3',
]"
v-html="descriptionHTML"
/>
</div>
</div>
</div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
>
<!-- image library -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap gap-4"
>
<div>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
{{ $t("library.admin.game.imageLibrary") }}
</h3>
<p class="mt-1 text-sm text-zinc-400 max-w-lg">
{{ $t("library.admin.game.imageLibraryDescription") }}
</p>
</div>
<div class="flex-shrink-0">
<button
type="button"
class="relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showUploadModal = true)"
>
{{ $t("upload") }}
</button>
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-2 grid-flow-dense gap-8">
<div
v-for="(image, imageIdx) in game.mImageLibraryObjectIds"
:key="imageIdx"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
v-if="image !== game.mBannerObjectId"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => updateBannerImage(image)"
>
{{ $t("library.admin.game.setBanner") }}
</button>
<button
v-if="image !== game.mCoverObjectId"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => updateCoverImage(image)"
>
{{ $t("library.admin.game.setCover") }}
</button>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteImage(image)"
>
{{ $t("library.admin.game.deleteImage") }}
</button>
</div>
<div
v-if="
image === game.mBannerObjectId ||
image === game.mCoverObjectId
"
class="absolute bottom-0 left-0 flex flex-row gap-x-1 p-1"
>
<span
v-for="[key] of (
[
[
$t('library.admin.game.currentBanner'),
image === game.mBannerObjectId,
],
[
$t('library.admin.game.currentCover'),
image === game.mCoverObjectId,
],
] as const
).filter((e) => e[1])"
:key="key"
class="inline-flex items-center rounded-full bg-blue-900 px-2 py-1 text-xs font-medium text-blue-100"
>{{ key }}</span
>
</div>
</div>
</div>
</div>
</div>
</div>
<ModalUploadFile
v-model="showUploadModal"
:options="{ id: game.id }"
accept="image/*"
endpoint="/api/v1/admin/game/image"
:multiple="true"
@upload="(result: GameModel) => uploadAfterImageUpload(result)"
/>
<ModalTemplate v-model="showAddCarouselModal">
<template #default>
<div
class="grid grid-cols-2 grid-flow-dense gap-4 max-h-[70vh] overflow-y-auto p-4"
>
<div
v-for="(image, imageIdx) in validAddCarouselImages"
:key="imageIdx"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => addImageToCarousel(image)"
>
{{ $t("add") }}
</button>
</div>
</div>
<div
v-if="validAddCarouselImages.length == 0"
class="text-zinc-400 col-span-2"
>
{{ $t("library.admin.game.addCarouselNoImages") }}
</div>
</div>
</template>
<template #buttons>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false"
>
{{ $t("common.close") }}
</button>
</template>
</ModalTemplate>
<ModalTemplate v-model="showAddImageDescriptionModal">
<template #default>
<div class="grid grid-cols-2 grid-flow-dense gap-4">
<div
v-for="(image, imageIdx) in game.mImageLibraryObjectIds"
:key="imageIdx"
class="group relative flex items-center bg-zinc-950/30"
>
<img :src="useObject(image)" class="w-full h-auto" />
<div
class="transition-all lg:opacity-0 lg:group-hover:opacity-100 absolute inset-0 flex flex-col items-center justify-center gap-y-2 bg-zinc-950/50"
>
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => insertImageAtCursor(image)"
>
{{ $t("common.insert") }}
</button>
</div>
</div>
<div
v-if="game.mImageLibraryObjectIds.length == 0"
class="text-zinc-400 col-span-2"
>
{{ $t("library.admin.game.addDescriptionNoImages") }}
</div>
</div>
</template>
<template #buttons>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="showAddImageDescriptionModal = false"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
<ModalTemplate v-model="showEditCoreMetadata">
<template #default>
<div class="flex flex-col lg:flex-row gap-6">
<!-- icon upload div -->
<div class="flex flex-col items-center gap-4">
<img :src="coreMetadataIconUrl" class="size-24 aspect-square" />
<label for="file-upload">
<span
type="button"
class="cursor-pointer relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
{{ $t("upload") }}
</span>
<input
id="file-upload"
accept="image/*"
class="hidden"
type="file"
@change="(e: Event) => coreMetadataUploadFiles(e as any)"
/>
</label>
</div>
<!-- edit title -->
<div class="flex flex-col gap-y-4 grow">
<div>
<label
for="name"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.game.editGameName") }}</label
>
<div class="mt-2">
<input
id="name"
v-model="coreMetadataName"
type="text"
name="name"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<div>
<label
for="description"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.game.editGameDescription") }}</label
>
<div class="mt-2">
<input
id="description"
v-model="coreMetadataDescription"
type="text"
name="description"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
</div>
</div>
</template>
<template #buttons>
<LoadingButton
type="button"
:loading="coreMetadataLoading"
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => coreMetadataUpdate_wrapper()"
>
{{ $t("common.save") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="showEditCoreMetadata = false"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
import { micromark } from "micromark";
import {
CheckIcon,
DocumentIcon,
PencilIcon,
PhotoIcon,
} from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
const showUploadModal = ref(false);
const showAddCarouselModal = ref(false);
const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true);
type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
const game = defineModel<ModelType>() as Ref<ModelType>;
if (!game.value)
throw createError({
statusCode: 500,
message: "Game not provided to editor component",
});
const currentTags = ref<{ [key: string]: boolean }>(
Object.fromEntries(game.value.tags.map((e) => [e.id, true])),
);
const tags = (await $dropFetch("/api/v1/admin/tags")).map(
(e) => ({ name: e.name, param: e.id }) satisfies StoreSortOption,
);
watch(
currentTags,
async (v) => {
await $dropFetch(`/api/v1/admin/game/:id/tags`, {
method: "PATCH",
params: {
id: game.value.id,
},
body: { tags: Object.keys(v) },
failTitle: "Failed to update game tags",
});
},
{ deep: true },
);
const { t } = useI18n();
// I don't know why I split these fields off.
const coreMetadataName = ref(game.value.mName);
const coreMetadataDescription = ref(game.value.mShortDescription);
const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId));
const coreMetadataIconFileUpload = ref<FileList | undefined>();
const coreMetadataLoading = ref(false);
function coreMetadataUploadFiles(e: InputEvent) {
if (coreMetadataIconUrl.value.startsWith("blob")) {
URL.revokeObjectURL(coreMetadataIconUrl.value);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
coreMetadataIconFileUpload.value = (e.target as any)?.files;
const file = coreMetadataIconFileUpload.value?.item(0);
if (!file) {
createModal(
ModalType.Notification,
{
title: t("errors.upload.title"),
description: t("errors.upload.description", [t("errors.unknown")]),
buttonText: t("common.close"),
},
(e, c) => c(),
);
return;
}
const objectUrl = URL.createObjectURL(file);
coreMetadataIconUrl.value = objectUrl;
}
async function coreMetadataUpdate() {
const formData = new FormData();
const newIcon = coreMetadataIconFileUpload.value?.item(0);
if (newIcon) {
formData.append("icon", newIcon);
}
formData.append("name", coreMetadataName.value);
formData.append("description", coreMetadataDescription.value);
const result = await $dropFetch(
`/api/v1/admin/game/${game.value.id}/metadata`,
{
method: "POST",
body: formData,
},
);
return result;
}
function coreMetadataUpdate_wrapper() {
coreMetadataLoading.value = true;
coreMetadataUpdate()
.catch((e) => {
createModal(
ModalType.Notification,
{
title: t("errors.game.metadata.title"),
description: t("errors.game.metadata.description", [
(e as H3Error)?.message ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
},
(e, c) => c(),
);
})
.then((newGame) => {
console.log(newGame);
if (!newGame) return;
Object.assign(game.value, newGame);
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);
})
.finally(() => {
coreMetadataLoading.value = false;
showEditCoreMetadata.value = false;
});
}
const descriptionHTML = computed(() =>
micromark(game.value?.mDescription ?? ""),
);
const descriptionEditor = ref<HTMLTextAreaElement | undefined>();
// 0 is not loading
// 1 is waiting for stop
// 2 is loading
enum DescriptionSavingState {
NotLoading,
Waiting,
Loading,
}
const descriptionSaving = ref<DescriptionSavingState>(
DescriptionSavingState.NotLoading,
);
let savingTimeout: undefined | NodeJS.Timeout;
type PatchGameBody = Partial<GameModel>;
watch(descriptionHTML, (_v) => {
descriptionSaving.value = DescriptionSavingState.Waiting;
if (savingTimeout) clearTimeout(savingTimeout);
savingTimeout = setTimeout(async () => {
try {
descriptionSaving.value = DescriptionSavingState.Loading;
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.value.id,
},
body: {
mDescription: game.value.mDescription,
} satisfies PatchGameBody,
});
descriptionSaving.value = DescriptionSavingState.NotLoading;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.description.title"),
description: t("errors.game.description.description", [
(e as H3Error)?.message ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}, 1500);
});
const validAddCarouselImages = computed(() =>
game.value.mImageLibraryObjectIds.filter(
(e) => !game.value.mImageCarouselObjectIds.includes(e),
),
);
function insertImageAtCursor(id: string) {
showAddImageDescriptionModal.value = false;
if (!descriptionEditor.value || !game.value) return;
const insertPosition = descriptionEditor.value.selectionStart;
const text = `![](/api/v1/object/${id})`;
game.value.mDescription = `${game.value.mDescription.slice(
0,
insertPosition,
)}${text}${game.value.mDescription.slice(insertPosition)}`;
}
async function updateBannerImage(id: string) {
try {
if (game.value.mBannerObjectId == id) return;
const { mBannerObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.value.id,
},
body: {
mBannerObjectId: id,
} satisfies PatchGameBody,
});
game.value.mBannerObjectId = mBannerObjectId;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.banner.title"),
description: t("errors.game.banner.description", [
(e as H3Error)?.message ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function updateCoverImage(id: string) {
try {
if (game.value.mCoverObjectId == id) return;
const { mCoverObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.value.id,
},
body: {
mCoverObjectId: id,
} satisfies PatchGameBody,
});
game.value.mCoverObjectId = mCoverObjectId;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.cover.title"),
description: t("errors.game.cover.description", [
(e as H3Error)?.message ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function deleteImage(id: string) {
try {
const { mBannerObjectId, mImageLibraryObjectIds } = await $dropFetch(
"/api/v1/admin/game/image",
{
method: "DELETE",
body: {
gameId: game.value.id,
imageId: id,
},
},
);
game.value.mImageLibraryObjectIds = mImageLibraryObjectIds;
game.value.mBannerObjectId = mBannerObjectId;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.deleteImage.title"),
description: t("errors.game.deleteImage.description", [
(e as H3Error)?.message ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function uploadAfterImageUpload(result: GameModel) {
if (!game.value) return;
game.value.mImageLibraryObjectIds = result.mImageLibraryObjectIds;
}
function addImageToCarousel(id: string) {
game.value.mImageCarouselObjectIds.push(id);
updateImageCarousel();
}
function removeImageFromCarousel(id: string) {
const imageIndex = game.value.mImageCarouselObjectIds.findIndex(
(e) => e == id,
);
game.value.mImageCarouselObjectIds.splice(imageIndex, 1);
updateImageCarousel();
}
async function updateImageCarousel() {
try {
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.value.id,
},
body: {
mImageCarouselObjectIds: game.value.mImageCarouselObjectIds,
} satisfies PatchGameBody,
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.carousel.title"),
description: t("errors.game.carousel.description", [
(e as H3Error)?.message ?? t("errors.unknown"),
]),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
</script>

View File

@ -0,0 +1,285 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game && unimportedVersions" class="p-8">
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">Versions</h1>
<p class="mt-2 text-sm text-zinc-400 max-w-lg">
Versions are a collection of files that are downloaded to clients.
Each version can have multiple configurations, for different
platforms.
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
:href="canImport ? `/admin/library/g/${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 rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm">
<div>
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
Version Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Imported
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Platforms
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="version in game.versions"
:key="version.versionId"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ version.versionName }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime :date="version.created" />
</td>
<td class="px-3 py-4">
<ul class="space-y-4">
<li
v-for="gameVersion in version.gameVersions"
:key="gameVersion.versionId"
class="px-3 py-2 border border-zinc-800 rounded-lg shadow"
>
<div>
<div
class="text-sm flex items-center gap-x-2 text-zinc-200 font-semibold"
>
<IconsPlatform
:platform="
platforms[gameVersion.platformId].platformIcon.key
"
:fallback="
platforms[gameVersion.platformId].platformIcon
.fallback
"
class="size-5 text-blue-500"
/>
<span class="block truncate">{{
platforms[gameVersion.platformId].name
}}</span>
</div>
<!-- launch commands -->
<div class="space-y-1 mt-4">
<div
v-if="gameVersion.install"
class="flex items-center justify-between"
>
<span
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>Install</span
>
<div
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700">(install dir)/</span
>{{ gameVersion.install.command }}
{{ gameVersion.install.args }}
</div>
</div>
<div>
<span class="font-semibold text-sm text-zinc-100"
>Launch options</span
>
<ul class="divide-y divide-zinc-700">
<li
v-for="launch in gameVersion.launches"
:key="launch.command"
class="ml-2 py-2 flex justify-between items-center"
>
<h1
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>
{{ launch.name }}
</h1>
<div
class="mt-1 whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700"
>(install dir)/</span
>{{ launch.command }} {{ launch.args }}
</div>
</li>
</ul>
</div>
<div
v-if="gameVersion.uninstall"
class="flex items-center justify-between"
>
<span
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>Uninstall</span
>
<div
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700">(install dir)/</span
>{{ gameVersion.uninstall.command }}
{{ gameVersion.uninstall.args }}
</div>
</div>
</div>
</div>
</li>
</ul>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => deleteVersion(version.versionId)"
>
Delete
<span class="sr-only">
{{ $t("chars.srComma", [version.versionName]) }}
</span>
</button>
</td>
</tr>
<tr v-if="game.versions.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
No versions
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div v-else class="grow w-full flex items-center justify-center">
<div class="flex flex-col items-center">
<ExclamationCircleIcon
class="h-12 w-12 text-red-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
{{ $t("library.admin.offlineTitle") }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ $t("library.admin.offline") }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SerializeObject, TypedInternalResponse } from "nitropack";
import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
// TODO implement UI for this page
const props = defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
const hasDeleted = ref(false);
const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameFetchType = TypedInternalResponse<
"/api/v1/admin/game/:id",
unknown,
"get"
>["game"];
const game = defineModel<SerializeObject<GameFetchType>>({ required: true });
if (!game.value)
throw createError({
statusCode: 500,
message: "Game not provided to editor component",
});
const rawPlatforms = await useAdminPlatforms();
const platforms = Object.fromEntries(
renderPlatforms(rawPlatforms).map((v) => [v.param, v]),
);
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.versionId),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.order.title"),
description: t("errors.version.order.desc", {
error: (e as H3Error)?.message ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function deleteVersion(versionId: string) {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: versionId,
},
failTitle: "Failed to delete version.",
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionId === versionId),
1,
);
hasDeleted.value = true;
}
</script>

View File

@ -0,0 +1,104 @@
<template>
<NuxtLink
v-if="game || defaultPlaceholder"
:href="href"
:class="{
'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5':
animate,
}"
class="group relative flex-1 min-w-42 max-w-48 h-64 rounded-lg overflow-hidden"
>
<div
:class="{
'transition-all duration-300 group-hover:scale-110': animate,
}"
class="absolute inset-0"
>
<img
:src="imageProps.src"
class="w-full h-full object-cover brightness-[90%]"
:alt="imageProps.alt"
/>
<div
v-if="showTitleDescription"
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/0 to-transparent"
/>
</div>
<div
v-if="showTitleDescription"
class="absolute bottom-0 left-0 w-full p-3"
>
<h1
:class="{ 'group-hover:text-white transition-colors': animate }"
class="text-zinc-100 text-sm font-bold font-display"
>
{{
game ? game.mName : $t("settings.admin.store.dropGameNamePlaceholder")
}}
</h1>
<p
:class="{
'group-hover:text-zinc-300 transition-colors': animate,
}"
class="text-zinc-400 text-xs line-clamp-2"
>
{{
game
? game.mShortDescription
: $t("settings.admin.store.dropGameDescriptionPlaceholder")
}}
</p>
</div>
</NuxtLink>
<SkeletonCard
v-else-if="defaultPlaceholder === false"
:message="$t('store.noGame')"
/>
</template>
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
const { t } = useI18n();
const {
game,
href = undefined,
showTitleDescription = true,
animate = true,
defaultPlaceholder = false,
} = defineProps<{
game:
| SerializeObject<{
id: string;
mCoverObjectId: string;
mName: string;
mShortDescription: string;
}>
| undefined
| null;
href?: string | undefined;
showTitleDescription?: boolean;
animate?: boolean;
defaultPlaceholder?: boolean;
}>();
const imageProps = {
src: "",
alt: t("settings.admin.store.dropGameAltPlaceholder"),
};
if (game) {
imageProps.src = useObject(game.mCoverObjectId);
imageProps.alt = game.mName;
} else if (defaultPlaceholder) {
imageProps.src = "/game-panel-placeholder.png";
}
</script>
<style scoped>
img.active {
view-transition-name: selected-game;
contain: layout;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="flex flex-row items-center gap-x-2">
<img :src="game.icon" class="w-12 h-12 rounded-sm object-cover" />
<div class="flex flex-col items-left">
<h1 class="font-semibold font-display text-lg text-zinc-100">
{{ game.name }}
<span
v-if="game.sourceName"
class="inline-flex items-center rounded-md bg-blue-600/50 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-700/10"
>{{ game.sourceName }}</span
>
</h1>
<p class="text-sm text-zinc-400">{{ game.description }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
const { game } = defineProps<{
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
}>();
</script>

View File

@ -0,0 +1,9 @@
<template>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M20.992 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.050 0.005 0.109 0.005 0.168 0 1.523-1.191 2.768-2.693 2.854l-0.008 0zM11.026 20.163c-1.511-0.099-2.699-1.349-2.699-2.877 0-0.051 0.001-0.102 0.004-0.153l-0 0.007c-0.003-0.048-0.005-0.104-0.005-0.161 0-1.525 1.19-2.771 2.692-2.862l0.008-0c1.509 0.082 2.701 1.325 2.701 2.847 0 0.062-0.002 0.123-0.006 0.184l0-0.008c0.003 0.048 0.005 0.104 0.005 0.161 0 1.525-1.19 2.771-2.692 2.862l-0.008 0zM26.393 6.465c-1.763-0.832-3.811-1.49-5.955-1.871l-0.149-0.022c-0.005-0.001-0.011-0.002-0.017-0.002-0.035 0-0.065 0.019-0.081 0.047l-0 0c-0.234 0.411-0.488 0.924-0.717 1.45l-0.043 0.111c-1.030-0.165-2.218-0.259-3.428-0.259s-2.398 0.094-3.557 0.275l0.129-0.017c-0.27-0.63-0.528-1.142-0.813-1.638l0.041 0.077c-0.017-0.029-0.048-0.047-0.083-0.047-0.005 0-0.011 0-0.016 0.001l0.001-0c-2.293 0.403-4.342 1.060-6.256 1.957l0.151-0.064c-0.017 0.007-0.031 0.019-0.040 0.034l-0 0c-2.854 4.041-4.562 9.069-4.562 14.496 0 0.907 0.048 1.802 0.141 2.684l-0.009-0.11c0.003 0.029 0.018 0.053 0.039 0.070l0 0c2.14 1.601 4.628 2.891 7.313 3.738l0.176 0.048c0.008 0.003 0.018 0.004 0.028 0.004 0.032 0 0.060-0.015 0.077-0.038l0-0c0.535-0.72 1.044-1.536 1.485-2.392l0.047-0.1c0.006-0.012 0.010-0.027 0.010-0.043 0-0.041-0.026-0.075-0.062-0.089l-0.001-0c-0.912-0.352-1.683-0.727-2.417-1.157l0.077 0.042c-0.029-0.017-0.048-0.048-0.048-0.083 0-0.031 0.015-0.059 0.038-0.076l0-0c0.157-0.118 0.315-0.24 0.465-0.364 0.016-0.013 0.037-0.021 0.059-0.021 0.014 0 0.027 0.003 0.038 0.008l-0.001-0c2.208 1.061 4.8 1.681 7.536 1.681s5.329-0.62 7.643-1.727l-0.107 0.046c0.012-0.006 0.025-0.009 0.040-0.009 0.022 0 0.043 0.008 0.059 0.021l-0-0c0.15 0.124 0.307 0.248 0.466 0.365 0.023 0.018 0.038 0.046 0.038 0.077 0 0.035-0.019 0.065-0.046 0.082l-0 0c-0.661 0.395-1.432 0.769-2.235 1.078l-0.105 0.036c-0.036 0.014-0.062 0.049-0.062 0.089 0 0.016 0.004 0.031 0.011 0.044l-0-0.001c0.501 0.96 1.009 1.775 1.571 2.548l-0.040-0.057c0.017 0.024 0.046 0.040 0.077 0.040 0.010 0 0.020-0.002 0.029-0.004l-0.001 0c2.865-0.892 5.358-2.182 7.566-3.832l-0.065 0.047c0.022-0.016 0.036-0.041 0.039-0.069l0-0c0.087-0.784 0.136-1.694 0.136-2.615 0-5.415-1.712-10.43-4.623-14.534l0.052 0.078c-0.008-0.016-0.022-0.029-0.038-0.036l-0-0z"
/>
</svg>
</template>

View File

@ -0,0 +1,29 @@
<template>
<svg
viewBox="0 0 20 20"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g
id="Page-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g
id="Dribbble-Light-Preview"
transform="translate(-140.000000, -7559.000000)"
fill="currentColor"
>
<g id="icons" transform="translate(56.000000, 160.000000)">
<path
id="github-[#142]"
d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399"
/>
</g>
</g>
</g>
</svg>
</template>

View File

@ -0,0 +1,10 @@
<template>
<svg viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.53918 2.40715C4.82145 1.0075 6.06066 0 7.49996 0C8.93926 0 10.1785 1.0075 10.4607 2.40715L10.798 4.07944C10.9743 4.9539 11.3217 5.78562 11.8205 6.52763L12.4009 7.39103C12.7631 7.92978 12.9999 8.5385 13.0979 9.17323C13.6747 9.22167 14.1803 9.58851 14.398 10.1283L14.8897 11.3474C15.1376 11.962 14.9583 12.665 14.4455 13.0887L12.5614 14.6458C12.0128 15.0992 11.2219 15.1193 10.6506 14.6944L9.89192 14.1301C9.88189 14.1227 9.87197 14.1151 9.86216 14.1074C9.48973 14.2075 9.09793 14.261 8.69355 14.261H6.30637C5.90201 14.261 5.51023 14.2076 5.13782 14.1074C5.12802 14.1151 5.11811 14.1227 5.10808 14.1301L4.34942 14.6944C3.77811 15.1193 2.98725 15.0992 2.43863 14.6458L0.55446 13.0887C0.0417175 12.665 -0.1376 11.962 0.110281 11.3474L0.602025 10.1283C0.819715 9.58854 1.32527 9.2217 1.90198 9.17324C2 8.5385 2.2368 7.92978 2.59897 7.39103L3.17938 6.52763C3.67818 5.78562 4.02557 4.9539 4.20193 4.07944L4.53918 2.40715ZM10.8445 9.47585C10.6345 9.63293 10.4642 9.84382 10.3561 10.0938L9.58799 11.8713C9.20026 12.0979 8.75209 12.2237 8.28465 12.2237H6.7153C6.24789 12.2237 5.79975 12.0979 5.41203 11.8714L4.64386 10.0938C4.53581 9.8438 4.36552 9.6329 4.15546 9.47582C4.18121 9.15355 4.2689 8.83503 4.41853 8.53826L5.67678 6.04259L5.68433 6.05007C6.68715 7.04458 8.31304 7.04458 9.31585 6.05007L9.32324 6.04274L10.5814 8.53825C10.7311 8.83504 10.8187 9.15357 10.8445 9.47585ZM9.04068 4.26906V3.05592H8.01353V3.85713C8.23151 3.90123 8.44506 3.97371 8.64848 4.07458L9.04068 4.26906ZM6.98638 3.85718V3.05592H5.95923V4.26919L6.3517 4.07458C6.55504 3.97375 6.7685 3.90129 6.98638 3.85718ZM2.03255 10.1864C1.82255 10.1864 1.6337 10.3132 1.55571 10.5066L1.06397 11.7257C0.981339 11.9306 1.04111 12.1649 1.21203 12.3062L3.0962 13.8633C3.27907 14.0144 3.54269 14.0211 3.73313 13.8795L4.49179 13.3152C4.6813 13.1743 4.74901 12.923 4.6557 12.7071L3.69976 10.4951C3.61884 10.3078 3.43316 10.1864 3.22771 10.1864H2.03255ZM13.4443 10.5066C13.3663 10.3132 13.1775 10.1864 12.9674 10.1864H11.7723C11.5668 10.1864 11.3812 10.3078 11.3002 10.4951L10.3443 12.7071C10.251 12.923 10.3187 13.1743 10.5082 13.3152L11.2669 13.8795C11.4573 14.0211 11.7209 14.0144 11.9038 13.8633L13.788 12.3062C13.9589 12.1649 14.0187 11.9306 13.936 11.7257L13.4443 10.5066ZM6.81106 4.98568C7.24481 4.7706 7.75537 4.7706 8.18912 4.98568L8.68739 5.23275L8.58955 5.32978C7.98786 5.92649 7.01232 5.92649 6.41063 5.32978L6.31279 5.23275L6.81106 4.98568Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 814 1000"
>
<path
stroke="currentColor"
fill="currentColor"
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"
/>
</svg>
</template>

View File

@ -0,0 +1,26 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<component
:is="platformIcons[props.platform as HardwarePlatform]"
v-if="platformIcons[props.platform as HardwarePlatform]"
/>
<div v-else-if="props.fallback" v-html="props.fallback" />
<DropLogo v-else />
</template>
<script setup lang="ts">
import { HardwarePlatform } from "~~/prisma/client/enums";
import type { Component } from "vue";
import LinuxLogo from "./LinuxLogo.vue";
import WindowsLogo from "./WindowsLogo.vue";
import MacLogo from "./MacLogo.vue";
import DropLogo from "../DropLogo.vue";
const props = defineProps<{ platform: string; fallback?: string | undefined }>();
const platformIcons: { [key in HardwarePlatform]: Component } = {
[HardwarePlatform.Linux]: LinuxLogo,
[HardwarePlatform.Windows]: WindowsLogo,
[HardwarePlatform.macOS]: MacLogo,
};
</script>

View File

@ -0,0 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M9.41 20H6.5c-1.5 0-2.82-.5-3.89-1.57C1.54 17.38 1 16.09 1 14.58q0-1.95 1.17-3.48a5.25 5.25 0 0 1 3.08-1.95c.42-1.53 1.25-2.77 2.5-3.72C9 4.5 10.42 4 12 4c1.95 0 3.61.68 4.96 2.04C18.32 7.39 19 9.05 19 11c1.15.13 2.11.63 2.86 1.5c.64.73 1 1.56 1.1 2.5H18a5.01 5.01 0 0 0-4-2c-2.8 0-5 2.2-5 5c0 .72.15 1.39.41 2M23 17v2h-2v2h-2v-2h-2.2c-.4 1.2-1.5 2-2.8 2c-1.7 0-3-1.3-3-3s1.3-3 3-3c1.3 0 2.4.8 2.8 2zm-8 1c0-.5-.4-1-1-1s-1 .5-1 1s.4 1 1 1s1-.5 1-1"
/>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<template>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15 9H15.01M15 15C18.3137 15 21 12.3137 21 9C21 5.68629 18.3137 3 15 3C11.6863 3 9 5.68629 9 9C9 9.27368 9.01832 9.54308 9.05381 9.80704C9.11218 10.2412 9.14136 10.4583 9.12172 10.5956C9.10125 10.7387 9.0752 10.8157 9.00469 10.9419C8.937 11.063 8.81771 11.1823 8.57913 11.4209L3.46863 16.5314C3.29568 16.7043 3.2092 16.7908 3.14736 16.8917C3.09253 16.9812 3.05213 17.0787 3.02763 17.1808C3 17.2959 3 17.4182 3 17.6627V19.4C3 19.9601 3 20.2401 3.10899 20.454C3.20487 20.6422 3.35785 20.7951 3.54601 20.891C3.75992 21 4.03995 21 4.6 21H6.33726C6.58185 21 6.70414 21 6.81923 20.9724C6.92127 20.9479 7.01881 20.9075 7.10828 20.8526C7.2092 20.7908 7.29568 20.7043 7.46863 20.5314L12.5791 15.4209C12.8177 15.1823 12.937 15.063 13.0581 14.9953C13.1843 14.9248 13.2613 14.8987 13.4044 14.8783C13.5417 14.8586 13.7588 14.8878 14.193 14.9462C14.4569 14.9817 14.7263 15 15 15Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@ -0,0 +1,12 @@
<template>
<svg
fill="currentColor"
viewBox="0 0 1920 1920"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1863.53 1016.437c31.171 0 56.47 25.299 56.47 56.47v790.589c0 16.376-7.115 31.849-19.313 42.465-10.39 9.149-23.605 14.005-37.158 14.005-2.484 0-5.082-.113-7.567-.452l-903.53-123.331c-28.008-3.84-48.903-27.784-48.903-56.02v-667.256c0-31.171 25.3-56.47 56.471-56.47Zm-1129.412 0c31.171 0 56.47 25.299 56.47 56.47v634.504c0 16.376-7.115 31.85-19.426 42.579-10.39 9.035-23.491 13.891-37.044 13.891-2.485 0-5.196-.113-7.68-.564L48.79 1669.35C20.78 1665.51 0 1641.68 0 1613.444v-540.537c0-31.171 25.299-56.47 56.47-56.47Zm-7.726-859.855c16.151-2.372 32.415 2.597 44.725 13.327 12.424 10.73 19.426 26.315 19.426 42.579V846.99c0 31.285-25.186 56.47-56.47 56.47H56.424c-31.171 0-56.47-25.185-56.47-56.47V306.455c0-28.123 20.781-52.066 48.79-55.906ZM1855.974.474c16.15-2.033 32.414 2.71 44.724 13.44 12.198 10.73 19.313 26.203 19.313 42.466v790.588c0 31.285-25.299 56.471-56.47 56.471H960.01c-31.171 0-56.47-25.186-56.47-56.47V179.711c0-28.235 20.78-52.066 48.903-55.906Z"
fill-rule="evenodd"
/>
</svg>
</template>

View File

@ -0,0 +1,238 @@
<template>
<div class="flex flex-col gap-y-4">
<!-- without metadata option -->
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
@click="() => importGame(false)"
>
{{ $t("library.admin.import.withoutMetadata") }}
</LoadingButton>
</div>
<!-- divider -->
<div
class="inline-flex items-center gap-x-4 text-zinc-600 font-display font-bold"
>
<div class="h-[1px] grow bg-zinc-800" />
{{ $t("auth.signin.or") }}
<div class="h-[1px] grow bg-zinc-800" />
</div>
<!-- with metadata option -->
<div class="flex flex-col gap-y-4">
<form @submit.prevent="() => searchGame()">
<label
for="searchTerm"
class="block text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.import.search") }}</label
>
<div class="mt-2 flex">
<div class="-mr-px grid grow grid-cols-1 focus-within:relative">
<input
id="searchTerm"
v-model="gameSearchTerm"
type="text"
name="searchTerm"
class="col-start-1 row-start-1 block w-full rounded-l-md bg-zinc-950 py-1.5 px-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:placeholder="$t('library.admin.import.searchPlaceholder')"
/>
</div>
<LoadingButton
:loading="gameSearchLoading"
:style="'none'"
type="submit"
class="w-24 flex shrink-0 items-center justify-center gap-x-1.5 rounded-r-md bg-zinc-950 px-3 py-2 text-sm font-semibold text-zinc-100 outline-1 -outline-offset-1 outline-zinc-800 hover:bg-zinc-900 focus:relative focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
>
<MagnifyingGlassIcon
class="-ml-0.5 size-4 text-gray-400"
aria-hidden="true"
/>
{{ $t("library.admin.import.search") }}
</LoadingButton>
</div>
</form>
<Listbox
v-if="metadataResults && metadataResults.length > 0"
v-model="model"
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="model !== undefined" :game="model" />
<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 metadataResults"
: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>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div
v-else-if="gameSearchResultsLoading"
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
{{ $t("library.admin.import.loading") }}
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
<div
v-if="gameSearchResultsError"
class="w-fit 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">
{{ gameSearchResultsError }}
</h3>
</div>
</div>
</div>
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
:disabled="model === undefined"
@click="() => importGame()"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<div
v-if="props.error"
class="mt-4 w-fit 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">
{{ props.error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
const model = ref<GameMetadataSearchResult | undefined>(undefined);
const props = defineProps<{
gameName: string;
loading: boolean;
error?: string | undefined;
}>();
const emit = defineEmits<{
import: [metadata: GameMetadataSearchResult | undefined];
}>();
function importGame(metadata = true) {
emit("import", metadata ? model.value : undefined);
}
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
onMounted(() => {
if (!metadataResults.value) searchGame();
});
const gameSearchLoading = ref(false);
const gameSearchResultsLoading = ref(false);
const gameSearchResultsError = ref<string | undefined>();
const gameSearchTerm = ref(props.gameName);
async function searchGame() {
gameSearchResultsError.value = undefined;
gameSearchLoading.value = true;
try {
const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
);
metadataResults.value = results;
gameSearchLoading.value = false;
} catch (e) {
gameSearchLoading.value = false;
throw e;
}
}
</script>

View File

@ -0,0 +1,336 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="flex flex-row gap-x-4">
<label
for="icon-upload"
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
>
<input
id="icon-upload"
type="file"
class="sr-only"
accept="image/*"
@change="addFile"
/>
<img
v-if="currentFileObjectUrl"
:src="currentFileObjectUrl"
class="absolute inset-0 object-cover w-full h-full"
/>
<div
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50"
>
<ArrowUpTrayIcon class="size-6" />
<span class="text-xs font-bold font-display uppercase">Upload</span>
</div>
</label>
<div class="grow flex flex-col gap-y-4">
<div>
<label for="name" class="block text-sm font-medium text-zinc-100"
>Name</label
>
<input
id="name"
v-model="name"
type="text"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
<div>
<label for="description" class="block text-sm font-medium text-zinc-100"
>Description</label
>
<input
id="description"
v-model="description"
type="text"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
<SwitchGroup
as="div"
class="max-w-lg flex items-center justify-between gap-x-4"
>
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>Create as platform</SwitchLabel
>
<SwitchDescription as="span" class="text-sm text-zinc-400"
>Versions for this redistributable will be able to take a series of
launch commands. Intended to be used with emulators and similar
programs.</SwitchDescription
>
</span>
<Switch
v-model="isPlatform"
:class="[
isPlatform ? '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="[
isPlatform ? '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 class="relative">
<div class="flex flex-row gap-x-4">
<label
for="platform-icon-upload"
class="relative size-24 bg-zinc-800 rounded-md overflow-hidden has-[:focus]:ring-2 has-[:focus]:ring-blue-600"
>
<input
id="platform-icon-upload"
type="file"
class="sr-only"
accept="image/svg+xml"
:disabled="!isPlatform"
@change="addSvg"
/>
<div
v-if="platform.icon"
class="absolute inset-0 object-cover w-full h-full text-blue-600"
v-html="platform.icon"
/>
<div
class="absolute inset-0 cursor-pointer flex flex-col gap-y-1 items-center justify-center text-zinc-300 bg-zinc-900/50 focus:text-zinc-100"
>
<ArrowUpTrayIcon class="size-6" />
<span class="text-xs font-bold font-display uppercase"
>Upload SVG</span
>
</div>
</label>
<div class="grow flex flex-col gap-y-4">
<div>
<label
for="platform-name"
class="block text-sm font-medium text-zinc-100"
>Platform Name</label
>
<input
id="platform-name"
v-model="platform.name"
type="text"
:disabled="!isPlatform"
class="mt-1 block w-full rounded-md border-0 bg-zinc-950 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div>
<div class="mt-2 w-full">
<label for="platform-name" class="block text-sm font-medium text-zinc-100"
>File Extensions {{ currentExtDotted }}
</label
>
<Combobox
as="div"
:model-value="currentExtDotted"
nullable
class="mt-1 w-full"
:disabled="!isPlatform"
@update:model-value="(v) => addExt(v)"
>
<div class="relative">
<ComboboxInput
class="w-full block flex-1 rounded-lg border-1 border-zinc-800 py-2 px-2 bg-zinc-950 text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder=".exe"
@change="currentExt = $event.target.value"
@blur="currentExt = ''"
/>
<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-if="currentExt"
v-slot="{ active }"
:value="currentExtDotted"
>
<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">
<span class="text-blue-300">filename</span
><span class="font-semibold">{{ currentExtDotted }}</span>
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
<div class="mt-2 flex gap-1 flex-wrap">
<div
v-for="ext in platform.fileExts"
:key="ext"
class="bg-blue-600/10 border-1 border-blue-700 rounded-full px-2 py-1 text-xs text-blue-400"
>
{{ ext }}
</div>
<span
v-if="platform.fileExts.length == 0"
class="uppercase font-display text-zinc-700 font-bold text-xs"
>No suggested file extensions.</span
>
</div>
</div>
<div v-if="!isPlatform" class="absolute inset-0 bg-zinc-950/20" />
</div>
<div>
<LoadingButton
class="w-fit"
:loading="props.loading"
:disabled="buttonDisabled"
@click="() => importRedist()"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<div v-if="props.error" class="mt-4 w-fit 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">
{{ props.error }}
</h3>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
Combobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Switch,
SwitchDescription,
SwitchGroup,
SwitchLabel,
} from "@headlessui/vue";
import { ArrowUpTrayIcon } from "@heroicons/vue/24/outline";
const currentFile = ref<File | undefined>(undefined);
const currentFileObjectUrl = ref<string | undefined>(undefined);
const emit = defineEmits<{
import: [
metadata: { name: string; description: string; icon: File } | undefined,
platform: typeof platform.value | undefined,
];
}>();
const name = ref("");
const description = ref("");
const isPlatform = ref(false);
const currentExt = ref("");
const currentExtDotted = computed(() => {
if(!currentExt.value) return "";
const cleaned = currentExt.value.replace(/\W/g, "").toLowerCase();
return `.${cleaned}`;
});
const platform = ref<{ name: string; icon: string; fileExts: string[] }>({
name: "",
icon: "",
fileExts: [],
});
const buttonDisabled = computed<boolean>(
() =>
!(
name.value &&
description.value &&
currentFileObjectUrl.value &&
(!isPlatform.value || (platform.value.name && platform.value.icon))
),
);
function addFile(event: Event) {
const file = (event.target as HTMLInputElement)?.files?.[0];
if (!file) return;
if (currentFileObjectUrl.value) {
URL.revokeObjectURL(currentFileObjectUrl.value);
}
currentFile.value = file;
currentFileObjectUrl.value = URL.createObjectURL(file);
}
async function addSvg(event: Event) {
const file = (event.target as HTMLInputElement)?.files?.[0];
if (!file) return;
const svgContent = await file.text();
const parser = new DOMParser();
try {
const document = parser.parseFromString(svgContent, "image/svg+xml");
const svg = document.getElementsByTagName("svg").item(0);
if (!svg) throw "No SVG in uploaded image.";
svg.removeAttribute("width");
svg.removeAttribute("height");
platform.value.icon = svg.outerHTML;
} catch (e) {
createModal(
ModalType.Notification,
{
title: "Failed to upload SVG",
description: (e as string)?.toString() ?? e,
},
(_, c) => c(),
);
return;
}
}
function addExt(ext: string | null) {
if (!ext) return;
if (platform.value.fileExts.includes(ext)) return;
platform.value.fileExts.push(ext);
currentExt.value = "";
}
const props = defineProps<{
gameName: string;
loading: boolean;
error?: string | undefined;
}>();
function importRedist() {
if (!currentFile.value) return;
emit(
"import",
{
name: name.value,
description: description.value,
icon: currentFile.value,
},
isPlatform.value
? {
...platform.value,
fileExts: platform.value.fileExts.map((e) => e.slice(1)),
}
: undefined,
);
}
</script>

View File

@ -0,0 +1,29 @@
<template>
<div>
<LanguageSelectorListbox />
<NuxtLink
class="mt-1 transition text-blue-500 hover:text-blue-600 text-sm"
to="https://translate.droposs.org/engage/drop/"
target="_blank"
>
<i18n-t
keypath="helpUsTranslate"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1 hover:underline"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
<DevOnly>
<h1 class="mt-3 text-sm text-gray-500">{{ $t("welcome") }}</h1>
</DevOnly>
</div>
</template>
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
</script>

View File

@ -0,0 +1,155 @@
<template>
<Listbox v-model="wiredLocale" as="div">
<ListboxLabel
v-if="showText"
class="block text-sm/6 font-medium text-zinc-400"
>{{ $t("selectLanguage") }}</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="grid w-full cursor-default grid-cols-1 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-300 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
>
<span class="col-start-1 row-start-1 flex items-center gap-3 pr-6">
<EmojiText
:emoji="localeToEmoji(wiredLocale)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span class="block truncate">{{
currentLocaleInformation?.name ?? wiredLocale
}}</span>
</span>
<ChevronUpDownIcon
class="col-start-1 row-start-1 size-5 self-center justify-self-end text-gray-500 sm:size-4"
aria-hidden="true"
/>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-hidden sm:text-sm"
>
<ListboxOption
v-for="listLocale in locales"
:key="listLocale.code"
v-slot="{ active, selected }"
as="template"
:value="listLocale.code"
>
<li
:class="[
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-300',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<div class="flex items-center">
<EmojiText
:emoji="localeToEmoji(listLocale.code)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'ml-3 block truncate',
]"
>{{ listLocale.name }}</span
>
</div>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { CheckIcon } from "@heroicons/vue/24/outline";
import type { Locale } from "vue-i18n";
const { showText = true } = defineProps<{ showText?: boolean }>();
const { availableLocales, locale: currLocale, setLocale } = useI18n();
function changeLocale(locale: Locale) {
setLocale(locale);
// dynamically update the HTML attributes for language and direction
// this is necessary for proper rendering of the page in the new language
useHead({
htmlAttrs: {
lang: locale,
// dir: availableLocales.find((l) => l === locale)?.dir || "ltr",
},
});
}
function localeToEmoji(local: string): string {
switch (local) {
// Default locale
case "en":
case "en-us":
return "🇺🇸";
case "en-gb":
return "🇬🇧";
case "en-ca":
return "🇨🇦";
case "en-au":
return "🇦🇺";
case "en-pirate":
return "🏴‍☠️";
case "fr":
return "🇫🇷";
case "de":
return "🇩🇪";
case "es":
return "🇪🇸";
case "it":
return "🇮🇹";
case "zh":
return "🇨🇳";
case "zh-tw":
return "🇹🇼";
default: {
return "❓";
}
}
}
const wiredLocale = computed({
get() {
return currLocale.value;
},
set(v) {
changeLocale(v);
},
});
const currentLocaleInformation = computed(() =>
availableLocales.find((e) => e == wiredLocale.value),
);
</script>

View File

@ -0,0 +1,27 @@
<template>
<span class="text-xs font-mono text-zinc-400 inline-flex items-top gap-x-2"
><span v-if="!short" class="text-zinc-500">{{ log.timestamp }}</span>
<span
:class="[
colours[log.level] || 'text-green-400',
'uppercase font-display font-semibold',
]"
>{{ log.level }}</span
>
<pre :class="[short ? 'line-clamp-1' : '', 'mt-[1px]']">{{
log.message
}}</pre>
</span>
</template>
<script setup lang="ts">
import type { TaskLog } from "~~/server/internal/tasks";
defineProps<{ log: typeof TaskLog.infer; short?: boolean }>();
const colours: { [key: string]: string } = {
info: "text-blue-400",
warn: "text-yellow-400",
error: "text-red-400",
};
</script>

View File

@ -0,0 +1,246 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.companies.addGame.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.companies.addGame.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => addGame()">
<Listbox v-model="currentGame" as="div">
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<GameSearchResultWidget
v-if="currentGame"
:game="currentGame"
/>
<span v-else class="block truncate text-zinc-600">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="result in metadataGames"
:key="result.id"
v-slot="{ active }"
as="template"
:value="result"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<GameSearchResultWidget :game="result" />
</li>
</ListboxOption>
<p
v-if="metadataGames.length == 0"
class="w-full text-center p-2 uppercase font-display text-zinc-700 font-bold"
>
{{ $t("library.admin.metadata.companies.addGame.noGames") }}
</p>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="mt-6 flex items-center justify-between gap-3">
<label
id="published-label"
for="published"
class="font-medium text-md text-zinc-100"
>{{
$t("library.admin.metadata.companies.addGame.publisher")
}}</label
>
<div
class="group/published relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-5"
/>
<input
id="published"
v-model="published"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="published-label"
/>
</div>
</div>
<div class="mt-2 flex items-center justify-between gap-3">
<label
id="developer-label"
for="developer"
class="font-medium text-md text-zinc-100"
>{{
$t("library.admin.metadata.companies.addGame.developer")
}}</label
>
<div
class="group/developer relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developer:translate-x-5"
/>
<input
id="developer"
v-model="developed"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="developer-label"
/>
</div>
</div>
<button class="hidden" type="submit" />
</form>
</div>
<div v-if="addError" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ addError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="addGameLoading"
:disabled="!(currentGame && (developed || published))"
class="w-full sm:w-fit"
@click="() => addGame()"
>
{{ $t("common.add") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { GameModel } from "~~/prisma/client/models";
import {
DialogTitle,
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const props = defineProps<{
companyId: string;
exclude?: string[];
}>();
const emit = defineEmits<{
created: [
game: SerializeObject<GameModel>,
published: boolean,
developed: boolean,
];
}>();
const games = await $dropFetch("/api/v1/admin/game");
const metadataGames = computed(() =>
games
.filter((e) => !(props.exclude ?? []).includes(e.id))
.map(
(e) =>
({
id: e.id,
name: e.mName,
icon: useObject(e.mIconObjectId),
description: e.mShortDescription,
}) satisfies Omit<GameMetadataSearchResult, "year">,
),
);
const { t } = useI18n();
const open = defineModel<boolean>({ required: true });
const currentGame = ref<NonNullable<(typeof metadataGames.value)[number]> | null>(null);
const developed = ref(false);
const published = ref(false);
const addGameLoading = ref(false);
const addError = ref<string | undefined>(undefined);
async function addGame() {
if (!currentGame.value) return;
addGameLoading.value = true;
try {
const game = await $dropFetch("/api/v1/admin/company/:id/game", {
method: "POST",
params: { id: props.companyId },
body: {
id: currentGame.value.id,
developed: developed.value,
published: published.value,
},
});
emit("created", game, published.value, developed.value);
} catch (e) {
if (e instanceof FetchError) {
addError.value = e.message ?? t("errors.unknown");
} else {
throw e;
}
} finally {
currentGame.value = null;
developed.value = false;
published.value = false;
addGameLoading.value = false;
open.value = false;
}
}
</script>

View File

@ -0,0 +1,115 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.collection.create") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.collection.createDesc") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => createCollection()">
<input
v-model="collectionName"
type="text"
:placeholder="$t('library.collection.namePlaceholder')"
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="createCollectionLoading"
:disabled="!collectionName"
class="w-full sm:w-fit"
@click="() => createCollection()"
>
{{ $t("common.create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { CollectionEntryModel, GameModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
const props = defineProps<{
gameId?: string;
}>();
const emit = defineEmits<{
created: [collectionId: string];
}>();
const open = defineModel<boolean>({ required: true });
const { t } = useI18n();
const collectionName = ref("");
const createCollectionLoading = ref(false);
const collections = await useCollections();
async function createCollection() {
if (!collectionName.value || createCollectionLoading.value) return;
try {
createCollectionLoading.value = true;
// Create the collection
const response = await $dropFetch("/api/v1/collection", {
method: "POST",
body: { name: collectionName.value },
});
// Add the game if provided
if (props.gameId) {
const entry = await $dropFetch<
CollectionEntryModel & { game: SerializeObject<GameModel> }
>(`/api/v1/collection/${response.id}/entry`, {
method: "POST",
body: { id: props.gameId },
});
response.entries.push(entry);
}
collections.value.push(response);
// Reset and emit
collectionName.value = "";
open.value = false;
emit("created", response.id);
} catch (error) {
console.error("Failed to create collection:", error);
const err = error as { message?: string };
createModal(
ModalType.Notification,
{
title: t("errors.library.collection.create.title"),
description: t("errors.library.collection.create.desc", [
err?.message ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally {
createCollectionLoading.value = false;
}
}
</script>

View File

@ -0,0 +1,148 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<h3 class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.companies.modals.createTitle") }}
</h3>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.companies.modals.createDescription") }}
</p>
</div>
<div class="mt-2">
<form class="space-y-4" @submit.prevent="() => createCompany()">
<div>
<label
for="name"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t("library.admin.metadata.companies.modals.createFieldName")
}}</label
>
<div class="mt-2">
<input
id="name"
v-model="companyName"
type="text"
name="name"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldNamePlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<div>
<label
for="description"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t(
"library.admin.metadata.companies.modals.createFieldDescription",
)
}}</label
>
<div class="mt-2">
<input
id="description"
v-model="companyDescription"
type="text"
name="description"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldDescriptionPlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<div>
<label
for="website"
class="block text-sm/6 font-medium text-zinc-100"
>{{
$t("library.admin.metadata.companies.modals.createFieldWebsite")
}}</label
>
<div class="mt-2">
<input
id="website"
v-model="companyWebsite"
type="text"
name="website"
:placeholder="
$t(
'library.admin.metadata.companies.modals.createFieldWebsitePlaceholder',
)
"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="loading"
:disabled="!companyValid"
class="w-full sm:w-fit"
@click="() => createCompany()"
>
{{ $t("common.create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { CompanyModel } from "~~/prisma/client/models";
const open = defineModel<boolean>({ required: true });
const emit = defineEmits<{
created: [company: CompanyModel];
}>();
const companyName = ref("");
const companyDescription = ref("");
const companyWebsite = ref("");
const loading = ref(false);
const companyValid = computed(
() => companyName.value && companyDescription.value,
);
async function createCompany() {
loading.value = true;
try {
const newCompany = await $dropFetch("/api/v1/admin/company", {
method: "POST",
body: {
name: companyName.value,
description: companyDescription.value,
website: companyWebsite.value,
},
failTitle: "Failed to create new company",
});
open.value = false;
emit("created", newCompany);
} finally {
/* empty */
}
loading.value = false;
}
</script>

View File

@ -0,0 +1,78 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.tags.modal.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.tags.modal.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => createTag()">
<input
v-model="tagName"
type="text"
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="createTagLoading"
:disabled="!tagName"
class="w-full sm:w-fit"
@click="() => createTag()"
>
{{ $t("common.create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { GameTagModel } from "~~/prisma/client/models";
const emit = defineEmits<{
created: [tag: GameTagModel];
}>();
const open = defineModel<boolean>({ required: true });
const tagName = ref("");
const createTagLoading = ref(false);
async function createTag() {
if (!tagName.value || createTagLoading.value) return;
createTagLoading.value = true;
// Create the collection
const tag = await $dropFetch("/api/v1/admin/tags", {
method: "POST",
body: { name: tagName.value },
failTitle: "Failed to create tag",
});
// Reset and emit
tagName.value = "";
open.value = false;
emit("created", tag);
createTagLoading.value = false;
}
</script>

View File

@ -0,0 +1,267 @@
<template>
<ModalTemplate v-model="model" size-class="max-w-3xl">
<template #default>
<div class="space-y-5">
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("account.token.name") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("account.token.nameDesc") }}
</p>
<div class="mt-2">
<input
id="name"
v-model="name"
name="name"
type="text"
autocomplete="name"
:placeholder="
props.suggestedName ?? $t('account.token.namePlaceholder')
"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<Listbox v-model="expiryKey" as="div">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
$t("users.admin.simple.inviteExpiryLabel")
}}</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{ expiryKey }}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="[label] in Object.entries(expiry)"
:key="label"
v-slot="{ active, selected }"
as="template"
:value="label"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ label }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("account.token.acls") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("account.token.aclsDesc") }}
</p>
<fieldset class="divide-y divide-zinc-700">
<div
v-for="[sectionName, sectionAcls] in Object.entries(
aclsBySection,
)"
:key="sectionName"
class="grid lg:grid-cols-3 gap-1 py-3"
>
<div
v-for="[acl, description] in Object.entries(sectionAcls)"
:key="acl"
class="flex gap-3"
>
<div class="flex h-6 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
id="acl"
v-model="currentACLs[acl]"
aria-describedby="acl-description"
name="acl"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 border-white/10 bg-white/5 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:indeterminate:border-blue-500 dark:indeterminate:bg-blue-500 dark:focus-visible:outline-blue-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto"
/>
<svg
class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-white/25"
viewBox="0 0 14 14"
fill="none"
>
<path
class="opacity-0 group-has-checked:opacity-100"
d="M3 8L6 11L11 3.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
class="opacity-0 group-has-indeterminate:opacity-100"
d="M3 7H11"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
<div class="text-sm/6">
<label
for="acl"
class="font-display font-medium text-white"
>{{ acl }}</label
>
{{ " " }}
<span id="acl-description" class="text-xs text-zinc-400"
><span class="sr-only">{{ acl }} </span
>{{ description }}</span
>
</div>
</div>
</div>
</fieldset>
</div>
</div>
</template>
<template #buttons>
<LoadingButton :loading="props.loading" @click="() => createToken()">
{{ $t("common.create") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => cancel()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
import type { DurationLike } from "luxon";
// Reuse for both admin and user tokens
const model = defineModel<boolean>({ required: true });
const { t } = useI18n();
const props = defineProps<{
acls: { [key: string]: string };
loading?: boolean;
suggestedAcls?: string[];
suggestedName?: string;
}>();
// Label to parameters to moment.js .add()
const expiry: Record<string, DurationLike | undefined> = {
[t("account.token.expiryMonth")]: {
month: 1,
},
[t("account.token.expiry3Month")]: {
month: 3,
},
[t("account.token.expiry6Month")]: {
month: 6,
},
[t("account.token.expiryYear")]: {
year: 1,
},
[t("account.token.expiry5Year")]: {
year: 5,
},
[t("account.token.noExpiry")]: undefined,
};
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0]); // Cast to any because we just know it's okay
const name = ref(props.suggestedName ?? "");
const currentACLs = ref<{ [key: string]: boolean }>(
Object.fromEntries((props.suggestedAcls ?? []).map((v) => [v, true])),
);
const aclsBySection = computed(() => {
const sections: { [key: string]: { [key: string]: string } } = {};
for (const [acl, description] of Object.entries(props.acls)) {
const section = acl.split(":")[0];
sections[section] ??= {};
sections[section][acl] = description;
}
return sections;
});
const emit = defineEmits<{
create: [name: string, acls: string[], expiry: DurationLike | undefined];
}>();
function createToken() {
emit(
"create",
name.value,
Object.entries(currentACLs.value)
.filter(([_acl, enabled]) => enabled)
.map(([acl, _enabled]) => acl),
expiry[expiryKey.value],
);
}
function cancel() {
model.value = false;
}
watch(model, (c) => {
if (!c) {
name.value = "";
currentACLs.value = {};
}
});
</script>

View File

@ -0,0 +1,80 @@
<template>
<ModalTemplate :model-value="!!collection">
<template #default>
<div>
<DialogTitle
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("library.collection.delete") }}
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [collection?.name]) }}
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
</p>
</div>
</template>
<template #buttons>
<LoadingButton
:loading="deleteLoading"
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteCollection()"
>
{{ $t("delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (collection = undefined)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import type { CollectionModel } from "~~/prisma/client/models";
import { DialogTitle } from "@headlessui/vue";
const collection = defineModel<CollectionModel | undefined>();
const deleteLoading = ref(false);
const collections = await useCollections();
const { t } = useI18n();
async function deleteCollection() {
try {
if (!collection.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/collection/:id`, {
method: "DELETE",
params: {
id: collection.value.id,
},
});
const index = collections.value.findIndex(
(e) => e.id == collection.value?.id,
);
collections.value.splice(index, 1);
collection.value = undefined;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display message on error
e?.message ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally {
deleteLoading.value = false;
}
}
</script>

View File

@ -0,0 +1,84 @@
<template>
<ModalTemplate :model-value="!!article">
<template #default>
<div>
<DialogTitle
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("news.delete") }}
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [article?.title]) }}
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
</p>
</div>
</template>
<template #buttons>
<LoadingButton
:loading="deleteLoading"
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteArticle()"
>
{{ $t("delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (article = undefined)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { DialogTitle } from "@headlessui/vue";
interface Article {
id: string;
title: string;
}
const article = defineModel<Article | undefined>();
const deleteLoading = ref(false);
const router = useRouter();
const { t } = useI18n();
const news = useNews();
if (!news.value) {
news.value = await fetchNews();
}
async function deleteArticle() {
try {
if (!article.value || !news.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/admin/news/${article.value.id}`, {
method: "DELETE",
});
const index = news.value.findIndex((e) => e.id == article.value?.id);
news.value.splice(index, 1);
article.value = undefined;
router.push("/news");
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.news.article.delete.title"),
description: t("errors.news.article.delete.desc", [
// @ts-expect-error attempt to display message on error
e?.message ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally {
deleteLoading.value = false;
}
}
</script>

View File

@ -0,0 +1,75 @@
<template>
<ModalTemplate :model-value="!!user">
<template #default>
<div>
<DialogTitle
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("users.admin.deleteUser", [user?.username]) }}
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [user?.username]) }}
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
</p>
</div>
</template>
<template #buttons>
<LoadingButton
:loading="deleteLoading"
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteUser()"
>
{{ $t("delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (user = undefined)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { DialogTitle } from "@headlessui/vue";
import type { UserModel } from "~~/prisma/client/models";
const user = defineModel<UserModel | undefined>();
const deleteLoading = ref(false);
const router = useRouter();
const { t } = useI18n();
async function deleteUser() {
try {
if (!user.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/admin/users/${user.value.id}`, {
method: "DELETE",
});
user.value = undefined;
await fetchUsers();
router.push("/admin/users");
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.admin.user.delete.title"),
description: t("errors.admin.user.delete.desc", [
// @ts-expect-error attempt to display message on error
e?.message ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally {
deleteLoading.value = false;
}
}
</script>

View File

@ -0,0 +1,186 @@
<template>
<TransitionRoot as="template" :show="open">
<Dialog class="relative z-50" @close="open = false">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed inset-0 bg-zinc-950 bg-opacity-75 transition-opacity"
/>
</TransitionChild>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div
class="flex min-h-full items-start justify-center p-4 text-center sm:items-center sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel
class="relative transform overflow-hidden rounded-lg bg-zinc-900 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
>
<div class="sm:flex sm:items-start">
<div
class="mt-3 w-full text-center sm:ml-4 sm:mt-0 sm:text-left"
>
<div class="mt-2">
<label
for="file-upload"
class="group cursor-pointer transition relative block w-full rounded-lg border-2 border-dashed border-zinc-600 p-12 text-center hover:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2"
>
<ArrowUpTrayIcon
class="transition mx-auto h-6 w-6 text-zinc-600 group-hover:text-zinc-700"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
/>
<span
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>{{ $t("uploadFile") }}</span
>
<div v-if="currentFileList">
<p
v-for="currentFile in currentFileList"
:key="currentFile"
class="mt-1 text-[10px] text-zinc-500 whitespace-nowrap"
>
{{ currentFile }}
</p>
</div>
</label>
<input
id="file-upload"
:accept="props.accept"
class="hidden"
type="file"
:multiple="props.multiple"
@change="(e: Event) => (file = (e.target as any)?.files)"
/>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<LoadingButton
:disabled="currentFiles == undefined"
type="button"
:loading="uploadLoading"
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => uploadFile_wrapper()"
>
{{ $t("upload") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="open = false"
>
{{ $t("cancel") }}
</button>
</div>
<div v-if="uploadError" 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">
{{ uploadError }}
</h3>
</div>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import { ref } from "vue";
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { ArrowUpTrayIcon } from "@heroicons/vue/20/solid";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const open = defineModel<boolean>({
required: true,
});
const { t } = useI18n();
const file = ref<FileList | undefined>();
const currentFiles = computed(() => file.value);
const currentFileList = computed(() => {
if (!currentFiles.value) return undefined;
const list = [];
for (const file of currentFiles.value) {
list.push(file.name);
}
return list;
});
const props = defineProps<{
endpoint: string;
accept: string;
multiple?: boolean;
options?: { [key: string]: string };
}>();
const emit = defineEmits(["upload"]);
const uploadLoading = ref(false);
const uploadError = ref<string | undefined>();
async function uploadFile() {
if (!currentFiles.value) return;
const form = new FormData();
for (const file of currentFiles.value) {
form.append(file.name, file);
}
if (props.options) {
for (const [key, value] of Object.entries(props.options)) {
form.append(key, value);
}
}
const result = await $dropFetch(props.endpoint, {
method: "POST",
body: form,
});
open.value = false;
file.value = undefined;
emit("upload", result);
}
function uploadFile_wrapper() {
uploadLoading.value = true;
uploadFile()
.catch((error) => {
uploadError.value = error.message ?? t("errors.unknown");
})
.finally(() => {
uploadLoading.value = false;
});
}
</script>

View File

@ -0,0 +1,115 @@
<template>
<div>
<div class="inline-flex gap-1 items-center flex-wrap">
<span
v-for="item in enabledItems"
:key="item.param"
class="inline-flex items-center gap-x-0.5 rounded-md bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-500 ring-1 ring-blue-800 ring-inset"
>
{{ item.name }}
<button
type="button"
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-600/20"
@click="() => remove(item.param)"
>
<span class="sr-only">{{ $t("common.remove") }}</span>
<svg
viewBox="0 0 14 14"
class="size-3.5 stroke-blue-500 group-hover:stroke-blue-400"
>
<path d="M4 4l6 6m0-6l-6 6" />
</svg>
<span class="absolute -inset-1" />
</button>
</span>
<span
v-if="enabledItems.length == 0"
class="font-display uppercase text-xs font-bold text-zinc-700"
>
{{ $t("common.noSelected") }}
</span>
</div>
<Combobox as="div" @update:model-value="add">
<div class="relative mt-2">
<ComboboxInput
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:display-value="(item) => (item as StoreSortOption)?.name"
placeholder="Start typing..."
@change="search = $event.target.value"
@blur="search = ''"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
>
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
v-if="filteredItems.length > 0 || search.length > 0"
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ComboboxOption
v-for="item in filteredItems"
:key="item.param"
v-slot="{ active }"
:value="item.param"
as="template"
>
<li
:class="[
'relative cursor-default py-2 pr-9 pl-3 select-none',
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
]"
>
<span class="block truncate">
{{ item.name }}
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from "@heroicons/vue/20/solid";
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
const props = defineProps<{
items: Array<StoreSortOption>;
}>();
const model = defineModel<{ [key: string]: boolean }>();
const search = ref("");
const filteredItems = computed(() =>
props.items.filter(
(item) =>
!model.value?.[item.param] &&
item.name.toLowerCase().includes(search.value.toLowerCase()),
),
);
const enabledItems = computed(() =>
props.items.filter((e) => model.value?.[e.param]),
);
function add(item: string) {
search.value = "";
model.value ??= {};
model.value[item] = true;
}
function remove(item: string) {
model.value ??= {};
model.value[item] = false;
}
</script>

View File

@ -0,0 +1,455 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="w-full">
<!-- Create article button - only show for admin users -->
<button
v-if="user?.admin"
class="transition inline-flex w-full items-center px-4 gap-x-2 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 font-semibold text-sm shadow-sm"
@click="modalOpen = !modalOpen"
>
<PlusIcon
class="h-5 w-5 transition-transform duration-200"
:class="{ 'rotate-90': modalOpen }"
/>
<span>{{ $t("news.article.new") }}</span>
</button>
<ModalTemplate v-model="modalOpen" size-class="sm:max-w-[80vw]">
<h3 class="text-lg font-semibold text-zinc-100 mb-4">
{{ $t("news.article.create") }}
</h3>
<form class="space-y-4" @submit.prevent="() => createArticle()">
<div>
<label for="title" class="block text-sm font-medium text-zinc-400">{{
$t("news.article.titles")
}}</label>
<input
id="title"
v-model="newArticle.title"
type="text"
autocomplete="off"
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
required
/>
</div>
<div>
<label
for="excerpt"
class="block text-sm font-medium text-zinc-400"
>{{ $t("news.article.shortDesc") }}</label
>
<input
id="excerpt"
v-model="newArticle.description"
type="text"
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
required
/>
</div>
<div>
<label
for="content"
class="block text-sm font-medium text-zinc-400"
>{{ $t("news.article.content") }}</label
>
<div class="mt-1 flex flex-col gap-4">
<!-- Markdown shortcuts -->
<div class="flex flex-wrap gap-2">
<button
v-for="shortcut in markdownShortcuts"
:key="shortcut.label"
type="button"
class="px-2 py-1 text-sm rounded bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors"
@click="applyMarkdown(shortcut)"
>
{{ shortcut.label }}
</button>
</div>
<div
class="grid grid-rows-2 sm:grid-cols-2 sm:grid-rows-1 gap-4 h-[400px]"
>
<!-- Editor -->
<div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">{{
$t("news.article.editor")
}}</span>
<textarea
id="content"
ref="contentEditor"
v-model="newArticle.content"
class="flex-1 rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500 font-mono resize-none"
required
@keydown="handleContentKeydown"
/>
</div>
<!-- Preview -->
<div class="flex flex-col">
<span class="text-sm text-zinc-500 mb-2">{{
$t("news.article.preview")
}}</span>
<div
class="flex-1 p-4 rounded-md bg-zinc-900 border border-zinc-700 overflow-y-auto"
>
<div
class="prose prose-invert prose-sm h-full overflow-y-auto"
v-html="markdownPreview"
/>
</div>
</div>
</div>
</div>
<p class="mt-2 text-sm text-zinc-500">
{{ $t("news.article.editorGuide") }}
</p>
</div>
<div>
<label
for="file-upload"
class="group cursor-pointer transition relative block w-full rounded-lg border-2 border-dashed border-zinc-600 p-12 text-center hover:border-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2"
>
<ArrowUpTrayIcon
class="transition mx-auto h-6 w-6 text-zinc-600 group-hover:text-zinc-700"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
/>
<span
class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500"
>{{ $t("news.article.uploadCover") }}</span
>
<p v-if="currentFile" class="mt-1 text-xs text-zinc-400">
{{ currentFile.name }}
</p>
</label>
<input
id="file-upload"
accept="image/*"
class="hidden"
type="file"
@change="(e: Event) => (file = (e.target as any)?.files)"
/>
</div>
<div>
<label class="block text-sm font-medium text-zinc-400 mb-2">{{
$t("common.tags")
}}</label>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="tag in newArticle.tags"
:key="tag"
class="inline-flex items-center gap-x-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-600/80 text-white"
>
{{ tag }}
<button
type="button"
class="text-white hover:text-white/80"
@click="removeTag(tag)"
>
<XMarkIcon class="h-3 w-3" />
</button>
</span>
</div>
<div class="flex gap-x-2">
<input
v-model="newTagInput"
type="text"
:placeholder="$t('news.article.tagPlaceholder')"
class="mt-1 block w-full rounded-md bg-zinc-900 border-zinc-700 text-zinc-100 shadow-sm focus:border-primary-500 focus:ring-primary-500"
@keydown.enter.prevent="addTag"
/>
<button
type="button"
class="mt-1 px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
@click="addTag"
>
{{ $t("news.article.add") }}
</button>
</div>
</div>
<button type="submit" class="hidden" />
<div v-if="error" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</form>
<template #buttons>
<LoadingButton
:loading="loading"
class="bg-blue-600 text-white hover:bg-blue-500"
:disabled="!isValidArticle"
@click="() => createArticle()"
>
{{ $t("news.article.submit") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (modalOpen = !modalOpen)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
import {
ArrowUpTrayIcon,
PlusIcon,
XCircleIcon,
XMarkIcon,
} from "@heroicons/vue/24/solid";
import { micromark } from "micromark";
const news = useNews();
if (!news.value) {
news.value = await fetchNews();
}
const user = useUser();
const modalOpen = ref(false);
const loading = ref(false);
const newTagInput = ref("");
const newArticle = ref({
title: "",
description: "",
content: "",
tags: [] as string[],
});
const isValidArticle = computed(
() =>
newArticle.value.title &&
newArticle.value.description &&
newArticle.value.content,
);
const markdownPreview = computed(() => {
// TODO: maybe?? add https://github.com/cure53/DOMPurify
// micromark says its safe, but this is straight html we are injecting
return micromark(newArticle.value.content);
});
const file = ref<FileList | undefined>();
const currentFile = computed(() => file.value?.item(0));
const { t } = useI18n();
const error = ref<string | undefined>();
const contentEditor = ref<HTMLTextAreaElement>();
const markdownShortcuts = [
{
label: t("editor.bold"),
prefix: "**",
suffix: "**",
placeholder: t("editor.boldPlaceholder"),
},
{
label: t("editor.italic"),
prefix: "_",
suffix: "_",
placeholder: t("editor.italicPlaceholder"),
},
{
label: t("editor.link"),
prefix: "[",
suffix: "](url)",
placeholder: t("editor.linkPlaceholder"),
},
{
label: t("editor.code"),
prefix: "`",
suffix: "`",
placeholder: t("editor.codePlaceholder"),
},
{
label: t("editor.listItem"),
prefix: "- ",
suffix: "",
placeholder: t("editor.listItemPlaceholder"),
},
{
label: t("editor.heading"),
prefix: "## ",
suffix: "",
placeholder: t("editor.headingPlaceholder"),
},
];
function handleContentKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
const textarea = contentEditor.value;
if (!textarea) return;
const start = textarea.selectionStart;
const text = textarea.value;
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
const currentLine = text.slice(lineStart, start);
// Check if the current line starts with a list marker
const listMatch = currentLine.match(/^(\s*)([-*+]|\d+\.)\s/);
let insertion = "\n";
if (listMatch) {
// If the line is empty except for the list marker, end the list
if (currentLine.trim() === listMatch[0].trim()) {
const removeLength = currentLine.length;
newArticle.value.content =
text.slice(0, lineStart) + text.slice(lineStart + removeLength);
// Move cursor to new position after removing the list marker
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd = lineStart;
});
return;
}
// Otherwise, continue the list
insertion = "\n" + listMatch[1] + listMatch[2] + " ";
}
newArticle.value.content =
text.slice(0, start) + insertion + text.slice(start);
nextTick(() => {
textarea.selectionStart = textarea.selectionEnd =
start + insertion.length;
});
}
}
function addTag() {
const tag = newTagInput.value.trim();
if (tag && !newArticle.value.tags.includes(tag)) {
newArticle.value.tags.push(tag);
newTagInput.value = ""; // Clear the input
}
}
function removeTag(tagToRemove: string) {
newArticle.value.tags = newArticle.value.tags.filter(
(tag) => tag !== tagToRemove,
);
}
function applyMarkdown(shortcut: (typeof markdownShortcuts)[0]) {
const textarea = contentEditor.value;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
const replacement = selectedText || shortcut.placeholder;
const newText =
text.substring(0, start) +
shortcut.prefix +
replacement +
shortcut.suffix +
text.substring(end);
newArticle.value.content = newText;
nextTick(() => {
textarea.focus();
const newStart = start + shortcut.prefix.length;
const newEnd = newStart + replacement.length;
textarea.setSelectionRange(newStart, newEnd);
});
}
async function createArticle() {
if (!user.value) return;
loading.value = true;
try {
const formData = new FormData();
if (currentFile.value) {
formData.append("image", currentFile.value);
}
formData.append("title", newArticle.value.title);
formData.append("description", newArticle.value.description);
formData.append("content", newArticle.value.content);
formData.append("tags", JSON.stringify(newArticle.value.tags));
const createdArticle = await $dropFetch("/api/v1/admin/news", {
method: "POST",
body: formData,
});
news.value?.push(createdArticle);
// Reset form
newArticle.value = {
title: "",
description: "",
content: "",
tags: [],
};
modalOpen.value = false;
} catch (e) {
// @ts-expect-error attempt to get message on error
error.value = e?.message ?? t("errors.unknown");
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.prose {
max-width: none;
}
.prose a {
color: #60a5fa;
text-decoration: none;
}
.prose a:hover {
text-decoration: underline;
}
.prose img {
border-radius: 0.5rem;
}
.prose code {
background: #27272a;
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-size: 0.875em;
}
.prose pre {
background: #18181b;
padding: 1em;
border-radius: 0.5rem;
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<div class="pointer-events-auto w-full bg-zinc-950/40 rounded">
<div class="p-4">
<div class="flex items-start">
<div class="w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-zinc-100">
{{ notification.title }}
</p>
<p class="mt-1 text-sm text-zinc-400 line-clamp-3">
{{ notification.description }}
</p>
<div
v-if="notification.actions.length > 0"
class="mt-3 flex space-x-7"
>
<NuxtLink
v-for="[name, link] in notification.actions.map((e) =>
e.split('|'),
)"
:key="name"
type="button"
:href="link"
class="rounded-md text-sm font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{{ name }}
</NuxtLink>
<!-- todo -->
</div>
</div>
<div class="ml-4 flex shrink-0">
<button
type="button"
class="inline-flex rounded-md text-zinc-400 hover:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="() => deleteMe()"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/solid";
import type { NotificationModel } from "~~/prisma/client/models";
const props = defineProps<{ notification: NotificationModel }>();
async function deleteMe() {
await $dropFetch(`/api/v1/notifications/:id`, {
method: "DELETE",
params: {
id: props.notification.id,
},
});
const notifications = useNotifications();
const indexOfMe = notifications.value.findIndex(
(e) => e.id === props.notification.id,
);
// Delete me
notifications.value.splice(indexOfMe, 1);
}
</script>

View File

@ -0,0 +1,22 @@
<template>
<div
:class="[
'transition border border-3 rounded-xl relative cursor-pointer',
active ? 'border-blue-600' : 'border-zinc-700',
]"
>
<div v-if="active" class="absolute top-1 right-1 z-1">
<CheckIcon
class="rounded-full p-1.5 bg-blue-600 size-6 text-transparent stroke-3 stroke-zinc-900 font-bold"
/>
</div>
<slot />
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from "@heroicons/vue/24/solid";
const { active = false } = defineProps<{ active?: boolean }>();
</script>

View File

@ -0,0 +1,5 @@
<template>
<div class="flex rounded px-2 py-2 bg-zinc-900 text-zinc-600">
<slot />
</div>
</template>

View File

@ -0,0 +1,94 @@
<template>
<Listbox v-model="model" as="div">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
><slot
/></ListboxLabel>
<div class="relative">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500 sm:text-sm sm:leading-6"
>
<span v-if="currentEntry" class="flex items-center">
<IconsPlatform
:platform="currentEntry.platformIcon.key"
:fallback="currentEntry.platformIcon.fallback"
class="h-5 w-5 flex-shrink-0 text-blue-600"
/>
<span class="ml-3 block truncate">{{ currentEntry.name }}</span>
</span>
<span v-else>{{ $t("library.admin.import.selectPlatform") }}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 ml-3 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-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="entry in values"
:key="entry.param"
v-slot="{ active, selected }"
as="template"
:value="entry.param"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<div class="flex items-center">
<IconsPlatform
v-if="entry.platformIcon"
:platform="entry.platformIcon.key"
:fallback="entry.platformIcon.fallback"
class="size-5 text-blue-500"
/>
<span class="ml-3 block truncate">{{ entry.name }}</span>
</div>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
const model = defineModel<string | undefined>();
const props = defineProps<{ platforms: PlatformRenderable[] }>();
const currentEntry = computed(() =>
model.value
? props.platforms.find((v) => v.param === model.value)
: undefined,
);
const values = props.platforms;
</script>

View File

@ -0,0 +1,120 @@
<template>
<Combobox
as="div"
:value="props.value"
nullable
@update:model-value="(v) => emit('update', 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="file.exe"
@change="query = $event.target.value"
@blur="query = ''"
/>
<ComboboxButton
v-if="filtered?.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 filtered"
: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 }}
<IconsPlatform
:platform="guess.platform.platformIcon.key"
:fallback="guess.platform.platformIcon.fallback"
class="size-5 flex-shrink-0 text-blue-600"
/>
</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="query"
v-slot="{ active, selected }"
:value="query"
>
<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: query }) }}
</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 { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
const emit = defineEmits<{
update: [v: string];
}>();
const props = defineProps<{
value?: string;
guesses?: Array<{ platform: PlatformRenderable; filename: string }>;
}>();
const query = ref("");
const filtered = computed(() =>
props.guesses?.filter((e) =>
e.filename.toLowerCase().includes(query.value.toLowerCase()),
),
);
</script>

View File

@ -0,0 +1,14 @@
<template>
<div>{{ model }}</div>
</template>
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
import type { RedistModel, UserPlatformModel } from "~~/prisma/client/models";
type ModelType = SerializeObject<
RedistModel & { platform?: UserPlatformModel }
>;
const model = defineModel<ModelType>({ required: true });
</script>

View File

@ -0,0 +1,32 @@
<template>
<div class="relative inline-block group/relative-time">
<!-- Visible relative time -->
<time :datetime="isoDate" class="text-sm text-muted-foreground">
{{ DateTime.fromJSDate(date).toRelative({ locale: $i18n.locale }) }}
</time>
<!-- Custom tooltip that shows on hover -->
<div
role="tooltip"
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 rounded bg-zinc-900 text-white text-xs whitespace-nowrap shadow z-10 opacity-0 group-hover/relative-time:opacity-100 transition-opacity pointer-events-none"
aria-hidden="true"
>
{{ $d(date, "long") }}
</div>
</div>
</template>
<script setup lang="ts">
import { DateTime } from "luxon";
import { computed } from "vue";
const props = defineProps<{
date: string | Date;
}>();
const date = computed(() =>
typeof props.date === "string" ? new Date(props.date) : props.date,
);
const isoDate = computed(() => date.value.toISOString());
</script>

View File

@ -0,0 +1,159 @@
<template>
<div class="p-2 lg:p-4">
<div class="px-4 py-2 max-w-xl">
<h1 class="font-semibold text-zinc-100 text-xl">
{{ $t("setup.auth.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("setup.auth.description") }}
</p>
</div>
<div class="grid lg:grid-cols-2 xl:grid-cols-3 h-fit p-4 gap-4">
<div class="p-4 border-1 border-zinc-800 rounded-xl">
<div>
<h1 class="text-zinc-100 font-semibold text-lg">
{{ $t("setup.auth.simple.title") }}
</h1>
<p class="text-sm text-zinc-400">
{{ $t("setup.auth.simple.description") }}
</p>
<NuxtLink
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
href="https://docs.droposs.org/docs/authentication/simple"
target="_blank"
>
<i18n-t
keypath="setup.auth.docs"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</div>
<div class="mt-4">
<div class="w-full flex justify-between items-center">
<span class="text-zinc-100 font-semibold text-sm">{{
$t("setup.auth.enabled")
}}</span>
<CheckIcon
v-if="enabledAuth.Simple"
class="size-5 text-green-600"
/>
<XMarkIcon v-else class="size-5 text-red-600" />
</div>
<LoadingButton
class="mt-4"
:loading="invitationLoading"
:disabled="!enabledAuth.Simple"
@click="() => registerAsAdmin()"
>
<i18n-t
keypath="setup.auth.simple.register"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
{{ $t("chars.arrow") }}
</template>
</i18n-t>
</LoadingButton>
</div>
</div>
<div class="p-4 border-1 border-zinc-800 rounded-xl">
<div>
<h1 class="text-zinc-100 font-semibold text-lg">
{{ $t("setup.auth.openid.title") }}
</h1>
<p class="text-sm text-zinc-400">
{{ $t("setup.auth.openid.description") }}
</p>
<NuxtLink
class="mt-4 rounded-md inline-flex items-center text-sm font-semibold text-blue-500 hover:text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
href="https://docs.droposs.org/docs/authentication/oidc"
target="_blank"
>
<i18n-t
keypath="setup.auth.docs"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</div>
<div class="mt-4">
<div class="w-full flex justify-between items-center">
<span class="text-zinc-100 font-semibold text-sm">{{
$t("setup.auth.enabled")
}}</span>
<CheckIcon
v-if="enabledAuth.OpenID"
class="size-5 text-green-600"
/>
<XMarkIcon v-else class="size-5 text-red-600" />
</div>
<LoadingButton
class="mt-4"
:loading="false"
:disabled="!enabledAuth.OpenID"
@click="() => (complete = true)"
>
<i18n-t
keypath="setup.auth.openid.skip"
tag="span"
class="inline-flex items-center gap-x-1"
scope="global"
>
<template #arrow>
{{ $t("chars.arrow") }}
</template>
</i18n-t>
</LoadingButton>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ArrowTopRightOnSquareIcon,
CheckIcon,
XMarkIcon,
} from "@heroicons/vue/24/outline";
import { DateTime } from "luxon";
const complete = defineModel<boolean>({ required: true });
const { token } = defineProps<{ token: string }>();
const invitationLoading = ref(false);
const enabledAuth = await $dropFetch("/api/v1/admin/auth", {
headers: { Authorization: token },
});
async function registerAsAdmin() {
invitationLoading.value = true;
const expiryDate = DateTime.now().plus({ year: 5000 }).toJSON();
const invitation = await $dropFetch("/api/v1/admin/auth/invitation", {
method: "POST",
body: { isAdmin: true, expires: expiryDate },
headers: { Authorization: token },
failTitle: "Failed to create admin invitation",
});
window.open(`${invitation.inviteUrl}&after=close`, "_blank")?.focus();
invitationLoading.value = false;
complete.value = true;
}
</script>

View File

@ -0,0 +1,15 @@
<template>
<div class="p-8">
<AdminSourcesPage :token="token" />
</div>
</template>
<script setup lang="ts">
import AdminSourcesPage from "~~/pages/admin/library/sources/index.vue";
const complete = defineModel<boolean>({ required: true });
// Only runs on component load, so it's fine
complete.value = true;
const { token } = defineProps<{ token: string }>();
</script>

View File

@ -0,0 +1,19 @@
<template>
<div
:class="[
'rounded-lg w-48 h-64 bg-zinc-800/50 flex items-center justify-center transition-all duration-300 hover:bg-zinc-800',
props.loading && 'animate-pulse',
]"
>
<p class="text-zinc-700 text-sm font-semibold font-display uppercase">
{{ props.message }}
</p>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
message?: string;
loading?: boolean;
}>();
</script>

View File

@ -0,0 +1,27 @@
<template>
<div>
<label
for="path"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.sources.fsPath") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.fsPathDesc") }}
</p>
<div class="mt-2">
<input
id="path"
v-model="model!.baseDir"
name="path"
type="text"
autocomplete="path"
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</template>
<script setup lang="ts">
const model = defineModel<{ baseDir: string }>();
</script>

View File

@ -0,0 +1,27 @@
<template>
<div>
<label
for="path"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.sources.fsPath") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.fsPathDesc") }}
</p>
<div class="mt-2">
<input
id="path"
v-model="model!.baseDir"
name="path"
type="text"
autocomplete="path"
:placeholder="$t('library.admin.sources.fsPathPlaceholder')"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</template>
<script setup lang="ts">
const model = defineModel<{ baseDir: string }>();
</script>

View File

@ -0,0 +1,496 @@
<template>
<div>
<div>
<!-- Mobile filter dialog -->
<TransitionRoot as="template" :show="mobileFiltersOpen">
<Dialog
class="relative z-100 lg:hidden"
@close="mobileFiltersOpen = false"
>
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/25" />
</TransitionChild>
<div class="fixed inset-0 z-40 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="translate-x-full"
>
<DialogPanel
class="relative ml-auto flex size-full max-w-sm flex-col overflow-y-auto bg-zinc-900 pt-4 pb-6 shadow-xl"
>
<div class="flex items-center justify-between px-4">
<h2 class="text-lg font-medium text-zinc-100">
{{ $t("store.view.srFilters") }}
</h2>
<button
type="button"
class="relative -mr-2 flex size-10 items-center justify-center rounded-md bg-zinc-900 p-2 text-zinc-500 hover:bg-zinc-800 focus:ring-2 focus:ring-blue-500 focus:outline-hidden"
@click="mobileFiltersOpen = false"
>
<span class="absolute -inset-0.5" />
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-6" aria-hidden="true" />
</button>
</div>
<!-- Filters -->
<form class="mt-4 border-t border-zinc-700">
<Disclosure
v-for="section in options"
v-slot="{ open }"
:key="section.param"
as="div"
class="border-t border-zinc-700 px-4 py-6"
>
<h3 class="-mx-2 -my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 px-2 py-3 text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div
v-if="section.options.length <= 10"
class="gap-3 grid grid-cols-2"
>
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${option}`"
v-model="
(optionValues[section.param] as any)[
option.param
]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-900 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@update:value="
() =>
(optionValues[section.param] = option.param)
"
/>
</div>
</div>
<label
:for="`filter-mobile-${section.param}-${optionIdx}`"
class="min-w-0 flex-1 text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<main class="mx-auto px-4 sm:px-6 lg:px-8">
<div
class="flex items-baseline justify-between border-b border-zinc-700 py-6"
>
<div />
<div class="flex items-center">
<Menu as="div" class="relative inline-block text-left">
<div>
<MenuButton
class="group inline-flex justify-center text-sm font-medium text-zinc-400 hover:text-zinc-100"
>
{{ $t("store.view.sort") }}
<ChevronDownIcon
class="-mr-1 ml-1 size-5 shrink-0 text-gray-400 group-hover:text-zinc-100"
aria-hidden="true"
/>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-zinc-950 shadow-2xl ring-1 ring-white/5 focus:outline-hidden"
>
<div class="py-1">
<MenuItem
v-for="option in sorts"
:key="option.param"
v-slot="{ active }"
>
<button
:class="[
currentSort == option.param
? 'font-medium text-zinc-100'
: 'text-zinc-400',
active ? 'bg-zinc-900 outline-hidden' : '',
'w-full text-left block px-4 py-2 text-sm',
]"
@click="() => (currentSort = option.param)"
>
{{ option.name }}
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
<button
v-if="false"
type="button"
class="-m-2 ml-5 p-2 text-zinc-500 hover:text-zinc-400 sm:ml-7"
>
<span class="sr-only">{{ $t("store.view.srViewGrid") }}</span>
<Squares2X2Icon class="size-5" aria-hidden="true" />
</button>
<button
type="button"
:class="[
'-m-2 ml-4 p-2 sm:ml-6 lg:hidden',
filterQuery
? 'text-zinc-100 hover:text-zinc-200'
: 'text-zinc-500 hover:text-zinc-400',
]"
@click="mobileFiltersOpen = true"
>
<span class="sr-only"> {{ $t("store.view.srFilters") }} </span>
<FunnelIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
<section aria-labelledby="games-heading" class="pt-6 pb-24">
<h2 id="games-heading" class="sr-only">
{{ $t("store.view.srGames") }}
</h2>
<div class="grid grid-cols-1 gap-x-8 gap-y-10 lg:grid-cols-5">
<!-- Filters -->
<form class="hidden lg:block">
<Disclosure
v-for="section in options"
:key="section.param"
v-slot="{ open }"
as="div"
class="border-b border-zinc-700 py-6"
>
<h3 class="-my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 py-3 text-sm text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div v-if="section.options.length <= 10" class="space-y-4">
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex items-center gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${optionIdx}`"
v-model="
(optionValues[section.param] as any)[option.param]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}-${optionIdx}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="radio"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@input="optionValues[section.param] = option.param"
/>
</div>
</div>
<IconsPlatform
v-if="option.platformIcon"
:platform="option.platformIcon.key"
:fallback="option.platformIcon.fallback"
class="size-5 text-blue-500"
/>
<label
:for="`filter-${section.param}-${optionIdx}`"
class="text-sm text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
<!-- Product grid -->
<div
v-if="games?.length ?? 0 > 0"
ref="product-grid"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
>
<!-- Your content -->
<GamePanel
v-for="game in games"
:key="game.id"
:game="game"
:href="`/store/${game.id}`"
:show-title-description="showGamePanelTextDecoration"
/>
<div
v-if="loading"
class="absolute inset-0 bg-zinc-900/40 flex items-center justify-center"
>
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
</div>
<div v-else class="flex lg:col-span-4 items-start justify-center">
<span class="uppercase text-zinc-700 font-display font-bold">{{
$t("common.noResults")
}}</span>
</div>
</div>
</section>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import {
Dialog,
DialogPanel,
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { XMarkIcon } from "@heroicons/vue/24/outline";
import {
ChevronDownIcon,
FunnelIcon,
MinusIcon,
PlusIcon,
Squares2X2Icon,
} from "@heroicons/vue/20/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
import MultiItemSelector from "./MultiItemSelector.vue";
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const mobileFiltersOpen = ref(false);
const props = defineProps<{
params?: { [key: string]: string };
extraOptions?: Array<StoreFilterOption>;
prefilled?: {
[key: string]: { [key: string]: string | { [key: string]: boolean } };
};
}>();
const tags =
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
const userPlatforms = await $dropFetch("/api/v1/store/platforms");
const sorts: Array<StoreSortOption> = [
{
name: "Default",
param: "default",
},
{
name: "Newest",
param: "newest",
},
{
name: "Recently Added",
param: "recent",
},
];
const currentSort = ref(sorts[0].param);
const options: Array<StoreFilterOption> = [
...(tags.length > 0
? [
{
name: "Tags",
param: "tags",
multiple: true,
options: tags.map((e) => ({ name: e.name, param: e.id })),
},
]
: []),
{
name: "Platform",
param: "platform",
multiple: true,
options: renderPlatforms(userPlatforms),
},
...(props.extraOptions ?? []),
];
const optionValues = ref<{
[key: string]: string | undefined | { [key: string]: boolean | undefined };
}>(
Object.fromEntries(
options.map((v) => [v.param, v.multiple ? {} : undefined]),
),
);
Object.assign(optionValues.value, props.prefilled);
const filterQuery = computed(() => {
const query = Object.entries(optionValues.value)
.filter(
([_, v]) =>
v &&
(typeof v !== "object" || Object.values(v).filter((e) => e).length > 0),
)
.map(([n, v]) => {
if (typeof v === "string") return [`${n}=${v}`];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const enabledOptions = Object.entries(v as any).filter(([_, e]) => e);
return `${n}=${enabledOptions.map(([k, _]) => k).join(",")}`;
})
.join("&");
const extraFilters = props.params
? Object.entries(props.params)
.map(([k, v]) => `${k}=${v}`)
.join("&")
: props.params;
return `${query}${extraFilters ? (query ? "&" : "") + extraFilters : ""}`;
});
const games = ref<Array<SerializeObject<GameModel>>>();
const loading = ref(false);
const productGrid = useTemplateRef<HTMLElement>("product-grid");
const { reset } = useInfiniteScroll(
productGrid,
async () => await updateGames(filterQuery.value, false),
{
distance: 10,
canLoadMore: () => {
return canLoadMore.value;
},
},
);
const canLoadMore = ref(true);
async function updateGames(query: string, resetGames: boolean) {
loading.value = true;
games.value ??= [];
const newValues = await $dropFetch<{
results: Array<SerializeObject<GameModel>>;
count: number;
}>(
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
);
if (resetGames) {
games.value = newValues.results;
if (import.meta.client) await reset();
} else {
games.value.push(...newValues.results);
}
canLoadMore.value = games.value.length < newValues.count;
loading.value = false;
}
watch(filterQuery, (newUrl) => {
updateGames(newUrl, true);
});
watch(currentSort, (_) => {
updateGames(filterQuery.value, true);
});
await updateGames(filterQuery.value, true);
</script>

View File

@ -0,0 +1,55 @@
<template>
<div
v-if="task"
class="flex w-full items-center justify-between space-x-6 p-6"
>
<div class="flex-1 truncate">
<div class="flex items-center space-x-1">
<div>
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
<XMarkIcon v-else-if="task.error" class="size-5 text-red-600" />
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse m-1"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.name }}
</h3>
</div>
<div
v-if="active"
class="mt-2 w-full rounded-full overflow-hidden bg-zinc-900"
>
<div
:style="{ width: `${task.progress}%` }"
class="bg-blue-600 h-[3px] transition-all"
/>
</div>
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
</div>
<NuxtLink
type="button"
:href="`/admin/task/${task.id}`"
class="mt-3 ml-1 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t keypath="tasks.admin.viewTask" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-else>
<!-- renders server side when we don't want to access the current tasks -->
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import type { TaskMessage } from "~~/server/internal/tasks";
defineProps<{ task: TaskMessage | undefined; active?: boolean }>();
</script>

View File

@ -0,0 +1,161 @@
<template>
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
<h2 id="footer-heading" class="sr-only">{{ $t("footer.footer") }}</h2>
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
<!-- Drop Info -->
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div class="space-y-8">
<DropWordmark class="h-10" />
<p class="text-sm leading-6 text-zinc-300">
{{ $t("drop.desc") }}
</p>
<LanguageSelector />
<div class="flex space-x-6">
<NuxtLink
v-for="item in navigation.social"
:key="item.name"
:to="item.href"
target="_blank"
class="text-zinc-400 hover:text-zinc-400"
>
<span class="sr-only">{{ item.name }}</span>
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
</NuxtLink>
</div>
</div>
<!-- Foot links -->
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("footer.games") }}
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.games" :key="item.name">
<NuxtLink
:to="item.href"
class="text-sm leading-6 text-zinc-300 hover:text-white"
>{{ item.name }}</NuxtLink
>
</li>
</ul>
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("userHeader.links.community") }}
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.community" :key="item.name">
<NuxtLink
:to="item.href"
class="text-sm leading-6 text-zinc-300 hover:text-white"
>{{ item.name }}</NuxtLink
>
</li>
</ul>
</div>
</div>
<div class="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("footer.documentation") }}
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.documentation" :key="item.name">
<NuxtLink
:to="item.href"
class="text-sm leading-6 text-zinc-300 hover:text-white"
>{{ item.name }}</NuxtLink
>
</li>
</ul>
</div>
<div class="mt-10 md:mt-0">
<h3 class="text-sm font-semibold leading-6 text-white">
{{ $t("footer.about") }}
</h3>
<ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.about" :key="item.name">
<NuxtLink
:to="item.href"
class="text-sm leading-6 text-zinc-300 hover:text-white"
>{{ item.name }}</NuxtLink
>
</li>
</ul>
</div>
</div>
</div>
<div class="flex items-center justify-center xl:col-span-3 mt-8">
<NuxtLink
:to="`https://github.com/Drop-OSS/drop/releases/tag/${versionInfo.version}`"
class="text-xs text-zinc-700 hover:text-zinc-400 transition-colors duration-200 cursor-default select-none"
>
<i18n-t keypath="footer.version" tag="span" scope="global">
<template #version>
<span>{{ versionInfo.version }}</span>
</template>
<template #gitRef>
<span>{{ versionInfo.gitRef }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
const { t } = useI18n();
const versionInfo = await $dropFetch("/api/v1");
const navigation = computed(() => ({
games: [
{ name: t("store.recentlyAdded"), href: "#" },
{ name: t("store.recentlyReleased"), href: "#" },
{ name: t("footer.topSellers"), href: "#" },
{ name: t("footer.findGame"), href: "#" },
],
community: [
{ name: t("common.friends"), href: "#" },
{ name: t("common.groups"), href: "#" },
{ name: t("common.servers"), href: "#" },
],
documentation: [
// TODO: public API docs
// { name: t("footer.api"), href: "https://api.droposs.org/" },
{
name: t("footer.docs.server"),
href: "https://docs.droposs.org/docs/guides/quickstart",
},
{
name: t("footer.docs.client"),
href: "https://docs.droposs.org/docs/guides/client",
},
],
about: [
{ name: t("footer.aboutDrop"), href: "https://droposs.org/" },
{ name: t("footer.comparison"), href: "https://droposs.org/comparison" },
],
social: [
{
name: t("footer.social.github"),
href: "https://github.com/Drop-OSS",
icon: IconsGithubLogo,
},
{
name: t("footer.social.discord"),
href: "https://discord.gg/NHx46XKJWA",
icon: IconsDiscordLogo,
},
],
}));
</script>

View File

@ -0,0 +1,241 @@
<template>
<div class="hidden lg:flex bg-zinc-950 flex-row px-12 xl:px-48 py-5">
<div class="grow inline-flex items-center gap-x-20">
<NuxtLink :to="homepageURL">
<DropWordmark class="h-8" />
</NuxtLink>
<nav class="inline-flex items-center">
<ol class="inline-flex items-center gap-x-12">
<NuxtLink
v-for="(nav, navIdx) in navigation"
:key="navIdx"
:href="nav.route"
:class="[
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
navIdx === currentPageIndex ? 'text-zinc-100' : 'text-zinc-400',
]"
>
{{ nav.label }}
</NuxtLink>
</ol>
</nav>
</div>
<div class="inline-flex items-center">
<ol class="inline-flex gap-3">
<li>
<UserHeaderWidget>
<UserGroupIcon class="h-5" />
</UserHeaderWidget>
</li>
<li>
<Menu as="div" class="relative inline-block">
<MenuButton>
<UserHeaderWidget :notifications="unreadNotifications.length">
<BellIcon class="h-5" />
</UserHeaderWidget>
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute inset-x-0 -translate-x-1/2 top-10 z-50 w-96 focus:outline-none shadow-md"
>
<UserHeaderNotificationWidgetPanel
:notifications="unreadNotifications"
/>
</MenuItems>
</transition>
</Menu>
</li>
<UserHeaderUserWidget />
</ol>
</div>
</div>
<div
class="sticky lg:hidden top-0 z-40 flex h-16 justify-between items-center gap-x-4 border-b border-zinc-700 bg-zinc-950 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
>
<NuxtLink :to="homepageURL">
<DropWordmark class="mb-0.5" />
</NuxtLink>
<div class="flex gap-x-4 lg:gap-x-6">
<div class="flex items-center gap-x-3">
<!-- Profile dropdown -->
<UserHeaderUserWidget />
<button
type="button"
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
</div>
<TransitionRoot as="template" :show="sidebarOpen">
<Dialog class="relative z-50 lg:hidden" @close="sidebarOpen = false">
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-zinc-900/80" />
</TransitionChild>
<div class="fixed inset-0 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<DialogPanel class="relative mr-16 flex w-full max-w-xs flex-1">
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="absolute left-full top-0 flex w-16 justify-center pt-5"
>
<button
type="button"
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">{{
$t("userHeader.closeSidebar")
}}</span>
<XMarkIcon class="h-6 w-6 text-zinc-400" aria-hidden="true" />
</button>
</div>
</TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<div
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-950 px-6 pb-4"
>
<div class="flex shrink-0 h-16 items-center justify-between">
<NuxtLink :to="homepageURL">
<DropLogo class="h-8 w-auto" />
</NuxtLink>
<UserHeaderUserWidget />
</div>
<nav class="flex flex-1 flex-col gap-y-8">
<ol class="flex flex-col gap-y-3">
<NuxtLink
v-for="(nav, navIdx) in navigation"
:key="navIdx"
:href="nav.route"
:class="[
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
navIdx === currentPageIndex
? 'text-zinc-100'
: 'text-zinc-400',
]"
>
{{ nav.label }}
</NuxtLink>
</ol>
<div class="h-px w-full bg-zinc-700" />
<div class="flex flex-row gap-x-4 justify-stretch">
<li class="w-full">
<UserHeaderWidget class="w-full">
<UserGroupIcon class="h-5" />
</UserHeaderWidget>
</li>
<li class="w-full">
<UserHeaderWidget
class="w-full"
:notifications="unreadNotifications.length"
>
<BellIcon class="h-5" />
</UserHeaderWidget>
</li>
<li class="w-full">
<UserHeaderWidget class="w-full" />
</li>
</div>
</nav>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import { BellIcon, UserGroupIcon } from "@heroicons/vue/16/solid";
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
Menu,
MenuButton,
MenuItems,
} from "@headlessui/vue";
import type { NavigationItem } from "../composables/types";
import { Bars3Icon } from "@heroicons/vue/24/outline";
import { XMarkIcon } from "@heroicons/vue/24/solid";
const router = useRouter();
const { t } = useI18n();
const homepageURL = "/store";
const navigation: Ref<Array<NavigationItem>> = computed(() => [
{
prefix: "/store",
route: "/store",
label: t("store.title"),
},
{
prefix: "/library",
route: "/library",
label: t("userHeader.links.library"),
},
{
prefix: "/community",
route: "/community",
label: t("userHeader.links.community"),
},
{
prefix: "/news",
route: "/news",
label: t("userHeader.links.news"),
},
]);
const currentPageIndex = useCurrentNavigationIndex(navigation.value);
const notifications = useNotifications();
const unreadNotifications = computed(() =>
notifications.value.filter((e) => !e.read),
);
const sidebarOpen = ref(false);
router.afterEach(() => (sidebarOpen.value = false));
</script>

View File

@ -0,0 +1,52 @@
<template>
<PanelWidget class="flex-col gap-y-2">
<div class="border-b border-zinc-700 pb-3 p-2">
<div
class="-ml-4 -mt-2 flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<div class="ml-4 mt-2">
<h3 class="text-base font-semibold text-zinc-100 text-sm">
{{ $t("account.notifications.unread") }}
</h3>
</div>
<div class="ml-4 mt-2 shrink-0">
<NuxtLink
to="/account/notifications"
type="button"
class="text-sm text-zinc-400"
>
<i18n-t
keypath="account.notifications.all"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
</div>
<div class="flex flex-col gap-y-2 max-h-[300px] overflow-y-scroll">
<NotificationItem
v-for="notification in props.notifications"
:key="notification.id"
:notification="notification"
/>
</div>
<div
v-if="props.notifications.length == 0"
class="text-sm text-zinc-400 p-3 text-center w-full"
>
{{ $t("account.notifications.none") }}
</div>
</PanelWidget>
</template>
<script setup lang="ts">
import type { NotificationModel } from "~~/prisma/client/models";
const props = defineProps<{ notifications: Array<NotificationModel> }>();
</script>

View File

@ -0,0 +1,108 @@
<template>
<Menu v-if="user" as="div" class="relative inline-block">
<MenuButton>
<UserHeaderWidget>
<div class="inline-flex items-center text-zinc-300 hover:text-white">
<img
:src="useObject(user.profilePictureObjectId)"
class="w-5 h-5 rounded-sm"
/>
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
<ChevronDownIcon class="ml-3 h-4" />
</div>
</UserHeaderWidget>
</MenuButton>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 top-10 z-50 w-56 origin-top-right focus:outline-none shadow-md"
>
<PanelWidget class="flex-col gap-y-2">
<NuxtLink
:to="`/user/${user.id}`"
class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800"
>
<div class="inline-flex items-center text-zinc-300">
<img
:src="useObject(user.profilePictureObjectId)"
class="w-5 h-5 rounded-sm"
/>
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
</div>
</NuxtLink>
<div class="h-0.5 rounded-full w-full bg-zinc-800" />
<div class="flex flex-col">
<MenuItem
v-for="(nav, navIdx) in navigation"
:key="navIdx"
v-slot="{ active, close }"
hydrate-on-visible
as="div"
>
<NuxtLink
:to="nav.route"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'w-full text-left transition block px-4 py-2 text-sm',
]"
@click="close"
>
{{ nav.label }}
</NuxtLink>
</MenuItem>
<MenuItem v-slot="{ active, close }" hydrate-on-visible as="div">
<NuxtLink
to="/auth/signout"
:class="[
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400',
'w-full text-left transition block px-4 py-2 text-sm',
]"
:data-comment="'external=true is required because we implemented the signout as a route on the server for performance'"
:external="true"
@click="close"
>
{{ $t("auth.signout") }}
</NuxtLink>
</MenuItem>
</div>
</PanelWidget>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
const user = useUser();
const navigation = computed<NavigationItem[]>(() =>
[
user.value?.admin
? {
label: $t("userHeader.profile.admin"),
route: "/admin",
prefix: "",
}
: undefined,
{
label: $t("userHeader.profile.settings"),
route: "/account",
prefix: "",
},
{
label: "Authorize client",
route: "/client/code",
prefix: "",
},
].filter((e) => e !== undefined),
);
</script>

View File

@ -0,0 +1,37 @@
<template>
<div
:class="[
'transition inline-flex items-center justify-center cursor-pointer rounded-sm px-4 py-2 relative',
showNotifications
? 'bg-blue-300 text-zinc-900 hover:bg-blue-200 hover:text-zinc-950'
: 'bg-zinc-900 text-zinc-600 hover:bg-zinc-800 hover:text-zinc-300',
]"
>
<slot />
<TransitionRoot
:show="showNotifications"
enter="ease-out duration-150"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-150"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="w-4 h-4 absolute top-0 right-0 translate-x-[30%] translate-y-[-30%] rounded-full bg-blue-300 inline-flex items-center justify-center text-xs text-zinc-900 font-bold"
>
{{ props.notifications }}
</div>
</TransitionRoot>
</div>
</template>
<script setup lang="ts">
import { TransitionRoot } from "@headlessui/vue";
const props = defineProps<{
notifications?: number;
}>();
const showNotifications = computed(() => !!props.notifications);
</script>

View File

@ -0,0 +1,48 @@
import type {
CollectionModel,
CollectionEntryModel,
GameModel,
} from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
type FullCollection = CollectionModel & {
entries: Array<CollectionEntryModel & { game: SerializeObject<GameModel> }>;
};
export const useCollections = async () => {
// @ts-expect-error undefined is used to tell if value has been fetched or not
const state = useState<FullCollection[]>("collections", () => undefined);
if (state.value === undefined) {
state.value = await $dropFetch<FullCollection[]>("/api/v1/collection");
}
return state;
};
export async function refreshCollection(id: string) {
const state = useState<FullCollection[]>("collections");
const collection = await $dropFetch<FullCollection>(
`/api/v1/collection/${id}`,
);
const index = state.value.findIndex((e) => e.id == id);
if (index == -1) {
state.value.push(collection);
return;
}
state.value[index] = collection;
}
export const useLibrary = async () => {
// @ts-expect-error undefined is used to tell if value has been fetched or not
const state = useState<FullCollection>("library", () => undefined);
if (state.value === undefined) {
await refreshLibrary();
}
return state;
};
export async function refreshLibrary() {
const state = useState<FullCollection>("library");
state.value = await $dropFetch<FullCollection>("/api/v1/collection/default");
}

View File

@ -0,0 +1,30 @@
import type { RouteLocationNormalized } from "vue-router";
import type { NavigationItem } from "./types";
export const useCurrentNavigationIndex = (
navigation: Array<NavigationItem>,
) => {
const router = useRouter();
const route = useRoute();
const currentNavigation = ref(-1);
function calculateCurrentNavIndex(to: RouteLocationNormalized) {
const validOptions = navigation
.map((e, i) => ({ ...e, index: i }))
.filter((e) => to.fullPath.startsWith(e.prefix));
const bestOption = validOptions
.sort((a, b) => b.route.length - a.route.length)
.at(0);
return bestOption?.index ?? -1;
}
currentNavigation.value = calculateCurrentNavIndex(route);
router.afterEach((to) => {
currentNavigation.value = calculateCurrentNavIndex(to);
});
return currentNavigation;
};

41
app/composables/news.ts Normal file
View File

@ -0,0 +1,41 @@
import type { ArticleModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
export const useNews = () =>
useState<
| Array<
SerializeObject<
ArticleModel & {
tags: Array<{ id: string; name: string }>;
author: { displayName: string; id: string } | null;
}
>
>
| undefined
>("news", () => undefined);
export const fetchNews = async (options?: {
limit?: number;
skip?: number;
orderBy?: "asc" | "desc";
tags?: string[];
search?: string;
}) => {
const query = new URLSearchParams();
if (options?.limit) query.set("limit", options.limit.toString());
if (options?.skip) query.set("skip", options.skip.toString());
if (options?.orderBy) query.set("order", options.orderBy);
if (options?.tags?.length) query.set("tags", options.tags.join(","));
if (options?.search) query.set("search", options.search);
const news = useNews();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore forget why this ignor exists
const newValue = await $dropFetch(`/api/v1/news?${query.toString()}`);
news.value = newValue;
return newValue;
};

View File

@ -0,0 +1,12 @@
import type { NotificationModel } from "~~/prisma/client/models";
const ws = new WebSocketHandler("/api/v1/notifications/ws");
export const useNotifications = () =>
useState<Array<NotificationModel>>("notifications", () => []);
ws.listen((e) => {
const notification = JSON.parse(e) as NotificationModel;
const notifications = useNotifications();
notifications.value.push(notification);
});

View File

@ -0,0 +1 @@
export const useObject = (id: string) => `/api/v1/object/${id}`;

View File

@ -0,0 +1,36 @@
import type { UserPlatform } from "~~/prisma/client/client";
import { HardwarePlatform } from "~~/prisma/client/enums";
export type PlatformRenderable = {
name: string;
param: string;
platformIcon: { key: string; fallback?: string };
};
export function renderPlatforms(
userPlatforms: { platformName: string; id: string; iconSvg: string }[],
): PlatformRenderable[] {
return [
...Object.values(HardwarePlatform).map((e) => ({
name: e,
param: e,
platformIcon: { key: e },
})),
...userPlatforms.map((e) => ({
name: e.platformName,
param: e.id,
platformIcon: { key: e.id, fallback: e.iconSvg },
})),
];
}
const rawUseAdminPlatforms = () => useState<Array<UserPlatform> | null>('adminPlatforms', () => null);
export async function useAdminPlatforms() {
const platforms = rawUseAdminPlatforms();
if(platforms.value === null){
platforms.value = await $dropFetch("/api/v1/admin/platforms");
}
return platforms.value!
}

View File

@ -0,0 +1,94 @@
import type {
ExtractedRouteMethod,
NitroFetchOptions,
NitroFetchRequest,
TypedInternalResponse,
} from "nitropack/types";
import { FetchError } from "ofetch";
interface DropFetch<
DefaultT = unknown,
DefaultR extends NitroFetchRequest = NitroFetchRequest,
> {
<
T = DefaultT,
R extends NitroFetchRequest = DefaultR,
O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>(
request: R,
opts?: O & { failTitle?: string },
): Promise<
// sometimes there is an error, other times there isn't
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
TypedInternalResponse<
R,
T,
NitroFetchOptions<R> extends O ? "get" : ExtractedRouteMethod<R, O>
>
>;
}
export const $dropFetch: DropFetch = async (rawRequest, opts) => {
const requestParts = rawRequest.toString().split("/");
requestParts.forEach((part, index) => {
if (!part.startsWith(":")) {
return;
}
const partName = part.slice(1);
const replacement = opts?.params?.[partName] as string | undefined;
if (!replacement) {
return;
}
requestParts[index] = replacement;
delete opts?.params?.[partName];
});
const request = requestParts.join("/");
// If not in setup
if (!getCurrentInstance()?.proxy) {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts);
} catch (e) {
if (import.meta.client && opts?.failTitle) {
console.warn(e);
createModal(
ModalType.Notification,
{
title: opts.failTitle,
description:
(e as FetchError)?.message ?? (e as string).toString(),
//buttonText: $t("common.close"),
},
(_, c) => c(),
);
}
if(e instanceof FetchError) {
e.message = e.data.message ?? e.message;
}
throw e;
}
}
const id = request.toString();
const state = useState(id);
if (state.value) {
// Deep copy
const object = JSON.parse(JSON.stringify(state.value));
// Never use again on client
if (import.meta.client) state.value = undefined;
return object;
}
const headers = useRequestHeaders(["cookie", "authorization"]);
const data = await $fetch(request, {
...opts,
headers: { ...headers, ...opts?.headers },
});
if (import.meta.server) state.value = data;
return data;
};

12
app/composables/store.ts Normal file
View File

@ -0,0 +1,12 @@
export type StoreFilterOption = {
name: string;
param: string;
options: Array<StoreSortOption>;
multiple?: boolean;
};
export type StoreSortOption = {
name: string;
param: string;
platformIcon?: { key: string; fallback?: string };
};

78
app/composables/task.ts Normal file
View File

@ -0,0 +1,78 @@
import type { TaskMessage } from "~~/server/internal/tasks";
import { WebSocketHandler } from "./ws";
const websocketHandler = new WebSocketHandler("/api/v1/task");
// const taskStates: { [key: string]: } = {};
const taskStates = new Map<string, Ref<TaskMessage | undefined>>();
function handleUpdateMessage(msg: TaskMessage) {
const taskStates = useTaskStates();
const state = taskStates.get(msg.id);
if (!state) return;
if (!state.value || msg.reset) {
state.value = msg;
return;
}
state.value.log.push(...msg.log);
Object.assign(state.value, { ...msg, log: state.value.log });
}
websocketHandler.listen((message) => {
try {
// If it's an object, it's an update message
const msg = JSON.parse(message) as TaskMessage;
handleUpdateMessage(msg);
} catch {
// Otherwise it's control message
const taskStates = useTaskStates();
const [action, ...data] = message.split("/");
switch (action) {
case "connect": {
const taskReady = useTaskReady();
taskReady.value = true;
break;
}
case "disconnect": {
const disconnectTaskId = data[0];
taskStates.delete(disconnectTaskId);
console.log(`disconnected from ${disconnectTaskId}`);
break;
}
case "error": {
const [taskId, title, description] = data;
const state = taskStates.get(taskId);
if (!state) break;
state.value ??= {
id: taskId,
name: "Unknown task",
success: false,
progress: 0,
error: undefined,
log: [],
};
state.value.error = { title, description };
break;
}
}
}
});
const useTaskStates = () => taskStates;
export const useTaskReady = () => useState("taskready", () => false);
export const useTask = (taskId: string): Ref<TaskMessage | undefined> => {
if (import.meta.server) return ref(undefined);
const taskStates = useTaskStates();
const task = taskStates.get(taskId);
if (task && task.value && !task.value.error) return task;
taskStates.set(taskId, ref(undefined));
console.log("connecting to " + taskId);
websocketHandler.send(`connect/${taskId}`);
// TODO: this may have changed behavior
return taskStates.get(taskId) ?? ref(undefined);
};

13
app/composables/types.ts Normal file
View File

@ -0,0 +1,13 @@
import type { Component } from "vue";
export type NavigationItem = {
prefix: string;
route: string;
label: string;
};
export type QuickActionNav = {
icon: Component;
notifications?: Ref<number>;
action: () => Promise<void>;
};

13
app/composables/user.ts Normal file
View File

@ -0,0 +1,13 @@
import type { UserModel } from "~~/prisma/client/models";
// undefined = haven't check
// null = check, no user
// {} = check, user
export const useUser = () => useState<UserModel | undefined | null>(undefined);
export const updateUser = async () => {
const user = useUser();
if (user.value === null) return;
user.value = await $dropFetch<UserModel | null>("/api/v1/user");
};

25
app/composables/users.ts Normal file
View File

@ -0,0 +1,25 @@
import type { SerializeObject } from "nitropack";
import type { UserModel } from "~~/prisma/client/models";
import type { AuthMec } from "~~/prisma/client/enums";
export const useUsers = () =>
useState<
| Array<
SerializeObject<
UserModel & {
authMecs?: Array<{ id: string; mec: AuthMec }>;
}
>
>
| undefined
>("users", () => undefined);
export const fetchUsers = async () => {
const users = useUsers();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore forget why this ignor exists
const newValue: User[] = await $dropFetch("/api/v1/admin/users");
users.value = newValue;
return newValue;
};

72
app/composables/ws.ts Normal file
View File

@ -0,0 +1,72 @@
import type { NuxtError } from "#app";
export type WebSocketCallback = (message: string) => void;
export type WebSocketErrorHandler = (error: NuxtError<unknown>) => void;
export class WebSocketHandler {
private listeners: Array<WebSocketCallback> = [];
private outQueue: Array<string> = [];
private inQueue: Array<string> = [];
private ws: WebSocket | undefined = undefined;
private connected: boolean = false;
private errorHandler: WebSocketErrorHandler | undefined = undefined;
constructor(route: string) {
if (import.meta.server) return;
const isSecure = location.protocol === "https:";
const url = (isSecure ? "wss://" : "ws://") + location.host + route;
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.connected = true;
for (const message of this.outQueue) {
this.ws?.send(message);
}
};
this.ws.onmessage = (e) => {
const message = e.data;
switch (message) {
case "unauthenticated": {
const error = createError({
statusCode: 403,
message: "Unable to connect to websocket - unauthenticated",
});
if (this.errorHandler) {
return this.errorHandler(error);
} else {
throw error;
}
}
}
if (this.listeners.length == 0) {
this.inQueue.push(message);
return;
}
for (const listener of this.listeners) {
listener(message);
}
};
}
error(handler: WebSocketErrorHandler) {
this.errorHandler = handler;
}
listen(callback: WebSocketCallback) {
this.listeners.push(callback);
}
send(message: string) {
if (!this.connected || !this.ws) {
this.outQueue.push(message);
return;
}
this.ws.send(message);
}
}

128
app/error.vue Normal file
View File

@ -0,0 +1,128 @@
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps({
error: {
type: Object as () => NuxtError,
default: () => ({}),
},
});
await updateUser();
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 showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false;
async function signIn() {
clearError({
redirect: `/auth/signin?redirect=${encodeURIComponent(route.fullPath)}`,
});
}
switch (statusCode) {
case 401:
case 403:
await signIn();
}
useHead({
title: t("errors.pageTitle", [statusCode ?? message]),
});
if (import.meta.client) {
console.warn(props.error);
}
</script>
<template>
<div
class="grid min-h-screen grid-cols-1 grid-rows-[1fr,auto,1fr] bg-zinc-950 lg:grid-cols-[max(50%,36rem),1fr]"
>
<header
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
>
<DropLogo class="h-10 w-auto sm:h-12" />
</header>
<main
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
>
<div class="max-w-lg">
<p class="text-base font-semibold leading-8 text-blue-600">
{{ error?.statusCode }}
</p>
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
{{ $t("errors.ohNo") }}
</h1>
<p
v-if="message"
class="mt-3 font-bold text-base leading-7 text-red-500"
>
{{ 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="/"
class="text-sm font-semibold leading-7 text-blue-600"
>
<i18n-t keypath="errors.backHome" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
v-else
class="text-sm font-semibold leading-7 text-blue-600"
@click="signIn"
>
<i18n-t keypath="errors.signIn" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</button>
</div>
</div>
</main>
<footer class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3">
<div class="border-t border-zinc-700 bg-zinc-900 py-10">
<nav
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
>
<NuxtLink href="/docs">{{ $t("footer.documentation") }}</NuxtLink>
<svg
viewBox="0 0 2 2"
aria-hidden="true"
class="h-0.5 w-0.5 fill-zinc-600"
>
<circle cx="1" cy="1" r="1" />
</svg>
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
{{ $t("errors.support") }}
</NuxtLink>
</nav>
</div>
</footer>
<div
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
>
<img
src="/wallpapers/error-wallpaper.jpg"
class="absolute inset-0 h-full w-full object-cover"
alt=""
/>
</div>
</div>
</template>

237
app/layouts/admin.vue Normal file
View File

@ -0,0 +1,237 @@
<template>
<div>
<TransitionRoot as="template" :show="sidebarOpen">
<Dialog class="relative z-50 lg:hidden" @close="sidebarOpen = false">
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-zinc-900/80" />
</TransitionChild>
<div class="fixed inset-0 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<DialogPanel class="relative mr-16 flex w-full max-w-xs flex-1">
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="absolute left-full top-0 flex w-16 justify-center pt-5"
>
<button
type="button"
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">{{
$t("userHeader.closeSidebar")
}}</span>
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
</button>
</div>
</TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<div
class="flex grow flex-col gap-y-5 overflow-y-auto bg-zinc-950 px-4 pb-4"
>
<div class="inline-flex items-center py-4 px-4">
<DropWordmark class="h-full w-auto" alt="Drop`" />
</div>
<nav>
<ul
role="list"
class="grid grid-cols-2 items-stretch gap-4 px-5"
>
<li v-for="(item, itemIdx) in navigation" :key="item.route">
<NuxtLink
:href="item.route"
:class="[
itemIdx === currentNavigationIndex
? 'bg-zinc-900 text-white'
: 'text-zinc-400 hover:bg-zinc-900 hover:text-white',
'transition group flex flex-col items-center grow gap-x-3 rounded-md px-2 py-3 text-sm font-semibold leading-6',
]"
>
<component
:is="item.icon"
class="h-6 w-6 shrink-0"
aria-hidden="true"
/>
<span class="text-xs text-center">{{
item.label
}}</span>
</NuxtLink>
</li>
</ul>
</nav>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<!-- Static sidebar for desktop -->
<div
class="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:z-50 lg:block lg:w-20 lg:overflow-y-auto lg:bg-zinc-950 lg:pb-4"
>
<div class="flex flex-col h-24 shrink-0 items-center justify-center">
<DropLogo class="h-8 w-auto" />
<span
class="mt-1 bg-blue-400 px-1 py-0.5 rounded-md text-xs font-bold font-display"
>{{ $t("header.admin.admin") }}</span
>
</div>
<nav class="mt-8">
<ul role="list" class="flex flex-col items-stretch space-y-4 mx-2">
<li v-for="(item, itemIdx) in navigation" :key="item.route">
<NuxtLink
:href="item.route"
:class="[
itemIdx === currentNavigationIndex
? 'bg-zinc-900 text-white'
: 'text-zinc-400 hover:bg-zinc-900 hover:text-white',
'transition group flex flex-col items-center grow gap-x-3 rounded-md px-2 py-3 text-sm font-semibold leading-6',
]"
>
<component
:is="item.icon"
class="h-6 w-6 shrink-0"
aria-hidden="true"
/>
<span class="text-xs text-center">{{ item.label }}</span>
</NuxtLink>
</li>
</ul>
</nav>
</div>
<div
class="sticky top-0 z-40 flex items-center gap-x-6 bg-zinc-900 px-4 py-4 shadow-sm sm:px-6 lg:hidden"
>
<button
type="button"
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
<main class="lg:pl-20 min-h-screen bg-zinc-900 flex flex-col">
<div
class="flex flex-col grow px-4 py-2 sm:py-10 sm:px-6 lg:px-8 lg:py-6"
>
<!-- Main area -->
<NuxtPage />
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, type Component } from "vue";
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import {
Bars3Icon,
ServerStackIcon,
HomeIcon,
Cog6ToothIcon,
UserGroupIcon,
RectangleStackIcon,
DocumentIcon,
} from "@heroicons/vue/24/outline";
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
import { XMarkIcon } from "@heroicons/vue/24/solid";
const i18nHead = useLocaleHead();
const navigation: Array<NavigationItem & { icon: Component }> = [
{ label: $t("home"), route: "/admin", prefix: "/admin", icon: HomeIcon },
{
label: $t("userHeader.links.library"),
route: "/admin/library",
prefix: "/admin/library",
icon: ServerStackIcon,
},
{
label: $t("header.admin.metadata"),
route: "/admin/metadata",
prefix: "/admin/metadata",
icon: DocumentIcon,
},
{
label: $t("header.admin.users"),
route: "/admin/users",
prefix: "/admin/users",
icon: UserGroupIcon,
},
{
label: $t("header.admin.tasks"),
route: "/admin/task",
prefix: "/admin/task",
icon: RectangleStackIcon,
},
{
label: $t("header.admin.settings.title"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: Cog6ToothIcon,
},
{
label: $t("header.back"),
route: "/store",
prefix: ".",
icon: ArrowLeftIcon,
},
];
// const notifications = useNotifications();
// const unreadNotifications = computed(() =>
// notifications.value.filter((e) => !e.read)
// );
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
const sidebarOpen = ref(false);
const router = useRouter();
router.afterEach(() => {
sidebarOpen.value = false;
});
useHead({
htmlAttrs: {
lang: i18nHead.value.htmlAttrs.lang,
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
dir: i18nHead.value.htmlAttrs.dir,
},
titleTemplate(title) {
return title ? $t("adminTitleTemplate", [title]) : $t("adminTitle");
},
});
</script>

34
app/layouts/default.vue Normal file
View File

@ -0,0 +1,34 @@
<template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
<UserHeader class="z-50" hydrate-on-idle />
<div class="grow flex">
<NuxtPage />
</div>
<UserFooter class="z-50" hydrate-on-interaction />
</div>
<div v-else class="flex w-full min-h-screen bg-zinc-900">
<NuxtPage />
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const i18nHead = useLocaleHead();
const noWrapper = !!route.query.noWrapper;
const { t } = useI18n();
useHead({
htmlAttrs: {
lang: i18nHead.value.htmlAttrs.lang,
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
dir: i18nHead.value.htmlAttrs.dir,
},
// // seo headers
// link: [...i18nHead.value.link],
// meta: [...i18nHead.value.meta],
titleTemplate(title) {
return title ? t("titleTemplate", [title]) : t("title");
},
});
</script>

View File

@ -0,0 +1,27 @@
const whitelistedPrefixes = ["/auth", "/api", "/setup"];
const requireAdmin = ["/admin"];
export default defineNuxtRouteMiddleware(async (to, _from) => {
if (import.meta.server) return;
const error = useError();
if (error.value !== undefined) return;
if (whitelistedPrefixes.findIndex((e) => to.fullPath.startsWith(e)) != -1)
return;
const user = useUser();
if (user === undefined) {
await updateUser();
}
if (!user.value) {
return navigateTo({
path: "/auth/signin",
query: { redirect: to.fullPath },
});
}
if (
requireAdmin.findIndex((e) => to.fullPath.startsWith(e)) != -1 &&
!user.value.admin
) {
return navigateTo({ path: "/" });
}
});

115
app/pages/account.vue Normal file
View File

@ -0,0 +1,115 @@
<template>
<div class="flex flex-col lg:flex-row grow w-screen">
<TransitionRoot as="template" :show="sidebarOpen">
<Dialog class="relative z-50 lg:hidden" @close="sidebarOpen = false">
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-zinc-900/80" />
</TransitionChild>
<div class="fixed inset-0 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="-translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="-translate-x-full"
>
<DialogPanel class="relative mr-16 flex w-full max-w-xs flex-1">
<TransitionChild
as="template"
enter="ease-in-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in-out duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="absolute top-0 left-full flex w-16 justify-center pt-5"
>
<button
type="button"
class="-m-2.5 p-2.5"
@click="sidebarOpen = false"
>
<span class="sr-only">{{
$t("userHeader.closeSidebar")
}}</span>
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
</button>
</div>
</TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<div class="bg-zinc-900 w-full">
<AccountSidebar />
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<!-- Static sidebar for desktop -->
<div
class="hidden lg:flex lg:inset-y-0 lg:z-50 lg:shrink-0 lg:basis-[18rem] lg:flex-col lg:border-r-2 lg:border-zinc-800"
>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<AccountSidebar />
</div>
<div
class="block flex items-center gap-x-2 bg-zinc-950 px-2 py-1 shadow-xs sm:px-4 lg:hidden border-b border-zinc-700"
>
<button
type="button"
class="-m-2.5 p-2.5 text-zinc-400 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">{{ $t("header.openSidebar") }}</span>
<Bars3Icon class="size-6" aria-hidden="true" />
</button>
<div
class="flex-1 text-sm/6 font-semibold uppercase font-display text-zinc-400"
>
{{ $t("account.title") }}
</div>
</div>
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6 w-full">
<NuxtPage />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { Bars3Icon, XMarkIcon } from "@heroicons/vue/24/outline";
const router = useRouter();
const { t } = useI18n();
const sidebarOpen = ref(false);
router.afterEach(() => {
sidebarOpen.value = false;
});
useHead({
title: t("account.title"),
});
</script>

View File

@ -0,0 +1,149 @@
<template>
<div>
<div class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.devices.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.devices.subheader") }}
</p>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.devices.platform") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.devices.capabilities") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.devices.lastConnected") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="client in clients"
:key="client.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ client.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<span
class="inline-flex items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20"
>
{{ client.platform }}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="capability in client.capabilities"
:key="capability"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
<CheckIcon class="size-3" />
{{ capability }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime :date="client.lastConnected" />
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeClientWrapper(client.id)"
>
{{ $t("account.devices.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [client.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="clients.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.devices.noDevices") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from "@heroicons/vue/24/outline";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore pending https://github.com/nitrojs/nitro/issues/2758
const clients = ref(await $dropFetch("/api/v1/user/client"));
const { t } = useI18n();
async function revokeClient(id: string) {
await $dropFetch(`/api/v1/user/client/${id}`, { method: "DELETE" });
}
// clients.value.push({
// id: "example-client",
// userId: "example-user",
// name: "Example Client",
// platform: "Windows",
// capabilities: ["TrackPlaytime"],
// lastConnected: new Date().toISOString(),
// });
function revokeClientWrapper(id: string) {
revokeClient(id)
.then(() => {
const index = clients.value.findIndex((e) => e.id == id);
clients.value.splice(index, 1);
})
.catch((e) => {
createModal(
ModalType.Notification,
{
title: t("errors.revokeClient"),
description: t("errors.revokeClientFull", String(e)),
},
(_, c) => c(),
);
});
}
</script>

View File

@ -0,0 +1,94 @@
<template>
<!-- go away eslint -->
<div />
<!-- I don't want to localize this -->
<!--
<div>
<div v-if="user" class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
Hello, {{ user.displayName }}!
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
Welcome to your Drop account. Here you can view and manage your account
information.
</p>
</div>
<div v-if="user" class="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
<div
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
>
<div class="p-6">
<h3 class="text-base font-semibold text-zinc-100">
Account Information
</h3>
<dl class="mt-4 space-y-4">
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Username</dt>
<dd class="text-sm text-zinc-100">{{ user.username }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Email</dt>
<dd class="text-sm text-zinc-100">{{ user.email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Account Type</dt>
<dd>
<span
:class="[
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
user.admin
? 'bg-blue-400/10 text-blue-400 ring-blue-400/20'
: 'bg-zinc-400/10 text-zinc-400 ring-zinc-400/20',
]"
>
{{ user.admin ? "Administrator" : "Standard User" }}
</span>
</dd>
</div>
</dl>
</div>
</div>
<div
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
>
<div class="p-6">
<h3 class="text-base font-semibold text-zinc-100">Account Actions</h3>
<div class="mt-4 space-y-3">
<button
type="button"
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
Change Password
</button>
<button
type="button"
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
Update Email
</button>
</div>
</div>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-[200px]">
<div class="text-zinc-400">Loading account information...</div>
</div>
</div>
-->
</template>
<script setup lang="ts">
definePageMeta({
layout: "default",
});
useHead({
title: "Account",
});
</script>

View File

@ -0,0 +1,150 @@
<template>
<div>
<div class="border-b border-zinc-800 pb-4 w-full">
<div class="flex items-center justify-between w-full">
<h2
class="text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.notifications.notifications") }}
</h2>
<button
:disabled="notifications.length === 0"
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-zinc-800 disabled:hover:scale-100 disabled:hover:shadow-none"
@click="markAllAsRead"
>
<CheckIcon class="size-4" />
{{ $t("account.notifications.markAllAsRead") }}
</button>
</div>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.notifications.desc") }}
</p>
</div>
<div class="mt-4 space-y-4">
<div
v-for="notification in notifications"
:key="notification.id"
class="group relative overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
:class="{ 'opacity-75': notification.read }"
>
<div class="p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-base font-semibold text-zinc-100">
{{ notification.title }}
</h3>
<p class="mt-1 text-sm text-zinc-400">
{{ notification.description }}
</p>
<div class="mt-4 flex flex-wrap gap-2">
<NuxtLink
v-for="[name, href] in notification.actions.map((v) =>
v.split('|'),
)"
:key="href"
:href="href"
class="inline-flex items-center rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20 transition-all duration-200 hover:bg-blue-400/20 hover:scale-105 active:scale-95"
>
{{ name }}
</NuxtLink>
</div>
</div>
<div class="ml-4 flex flex-shrink-0 items-center gap-x-2">
<span class="text-xs text-zinc-500">
<RelativeTime :date="notification.created" />
</span>
<button
v-if="!notification.read"
type="button"
class="inline-flex gap-x-1 items-center rounded-md bg-zinc-400/10 px-2 py-1 text-xs font-medium text-zinc-400 ring-1 ring-inset ring-zinc-400/20 transition-all duration-200 hover:bg-zinc-400/20 hover:scale-105 active:scale-95"
@click="markAsRead(notification.id)"
>
<CheckIcon class="size-3" />
{{ $t("account.notifications.markAsRead") }}
</button>
<button
type="button"
class="inline-flex gap-x-1 items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="deleteNotification(notification.id)"
>
<TrashIcon class="size-3" />
{{ $t("delete") }}
</button>
</div>
</div>
</div>
</div>
<div
v-if="notifications.length === 0"
class="rounded-xl border border-zinc-800 bg-zinc-900 p-8 text-center"
>
<p class="text-sm text-zinc-400">
{{ $t("account.notifications.none") }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
import type { NotificationModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
definePageMeta({
layout: "default",
});
const { t } = useI18n();
useHead({
title: t("account.notifications.title"),
});
// Fetch notifications
const notifications = ref<SerializeObject<NotificationModel>[]>([]);
async function fetchNotifications() {
const { data } = await useFetch("/api/v1/notifications");
notifications.value = data.value || [];
}
// Initial fetch
await fetchNotifications();
// Mark a notification as read
async function markAsRead(id: string) {
await $dropFetch(`/api/v1/notifications/${id}/read`, { method: "POST" });
const notification = notifications.value.find(
(n: SerializeObject<NotificationModel>) => n.id === id,
);
if (notification) {
notification.read = true;
}
}
// Mark all notifications as read
async function markAllAsRead() {
await $dropFetch("/api/v1/notifications/readall", { method: "POST" });
notifications.value.forEach(
(notification: SerializeObject<NotificationModel>) => {
notification.read = true;
},
);
}
// Delete a notification
async function deleteNotification(id: string) {
await $dropFetch(`/api/v1/notifications/${id}`, { method: "DELETE" });
const index = notifications.value.findIndex(
(n: SerializeObject<NotificationModel>) => n.id === id,
);
if (index !== -1) {
notifications.value.splice(index, 1);
}
}
</script>

View File

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

View File

@ -0,0 +1 @@
<template><div></div></template>

View File

@ -0,0 +1,229 @@
<template>
<div>
<div class="w-full flex justify-between items-center">
<div>
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.token.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.token.subheader") }}
</p>
</div>
<div>
<LoadingButton :loading="false" @click="() => (createOpen = true)">
{{ $t("common.create") }}
</LoadingButton>
</div>
</div>
<div
v-if="newToken"
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
>
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-300">
{{ $t("account.token.success") }}
</p>
<p class="text-xs text-green-300/70">
{{ $t("account.token.successNote") }}
</p>
<p
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
>
{{ newToken }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
@click="() => (newToken = undefined)"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.acls") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.expiry") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="(token, tokenIdx) in tokens"
:key="token.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ token.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="acl in token.acls"
:key="acl"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
{{ acl }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
<span v-else>{{ $t("account.token.noExpiry") }}</span>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeToken(tokenIdx)"
>
{{ $t("account.token.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [token.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="tokens.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.token.noTokens") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ModalCreateToken
v-model="createOpen"
:acls="acls"
:loading="createLoading"
:suggested-name="suggestedName"
:suggested-acls="suggestedAcls"
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
/>
</div>
</template>
<script setup lang="ts">
import { ArkErrors, type } from "arktype";
import { DateTime, type DurationLike } from "luxon";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
const tokens = ref(await $dropFetch("/api/v1/user/token"));
const acls = await $dropFetch("/api/v1/user/token/acls");
const createOpen = ref(false);
const createLoading = ref(false);
const newToken = ref<string | undefined>();
const suggestedName = ref();
const suggestedAcls = ref<string[]>([]);
const payloadParser = type({
name: "string?",
acls: "string[]?",
});
const route = useRoute();
if (route.query.payload) {
try {
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
const payload = payloadParser(rawPayload);
if (payload instanceof ArkErrors) throw payload;
suggestedName.value = payload.name;
suggestedAcls.value = payload.acls ?? [];
createOpen.value = true;
} catch {
throw createError({
statusCode: 400,
message: "Failed to parse the token create payload.",
fatal: true,
});
}
}
async function createToken(
name: string,
acls: string[],
expiry: DurationLike | undefined,
) {
createLoading.value = true;
try {
const result = await $dropFetch("/api/v1/user/token", {
method: "POST",
body: {
name,
acls,
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} finally {
/* empty */
}
createOpen.value = false;
createLoading.value = false;
}
async function revokeToken(index: number) {
const token = tokens.value[index];
if (!token) return;
await $dropFetch("/api/v1/user/token/:id", {
method: "DELETE",
params: {
id: token.id,
},
failTitle: "Failed to revoke token.",
});
tokens.value.splice(index, 1);
}
</script>

11
app/pages/admin/index.vue Normal file
View File

@ -0,0 +1,11 @@
<template><div /></template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
useHead({
title: "Home",
});
</script>

View File

@ -0,0 +1,574 @@
<template>
<div class="flex flex-col gap-y-4">
<Listbox
as="div"
:model-value="currentlySelectedVersion"
class="max-w-lg"
@update:model-value="(value) => updateCurrentlySelectedVersion(value)"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("library.admin.import.version.version")
}}</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"
>
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
versions[currentlySelectedVersion]
}}</span>
<span v-else class="block truncate text-zinc-600">{{
$t("library.admin.import.selectDir")
}}</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-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="(version, versionIdx) in versions"
:key="version"
v-slot="{ active, selected }"
as="template"
:value="versionIdx"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ version }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div v-if="versionGuesses" class="flex flex-col gap-4">
<!-- version name -->
<div class="max-w-lg">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>Version name</label
>
<p class="text-zinc-400 text-xs">
Shown to users when selecting what version to install.
</p>
<div class="mt-2">
<input
id="name"
v-model="versionSettings.name"
name="name"
type="text"
required
placeholder="my version name"
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-800 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>
<!-- install command -->
<div class="max-w-lg">
<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>
<PreloadSelector
:value="versionSettings.install"
:guesses="versionGuesses"
@update="(v) => updateInstallCommand(v)"
/>
<input
id="startup"
v-model="versionSettings.installArgs"
type="text"
name="startup"
class="border-l border-zinc-700 block flex-1 border-0 py-1.5 pl-2 bg-transparent text-zinc-100 placeholder:text-zinc-400 focus:ring-0 sm:text-sm sm:leading-6"
placeholder="--setup"
/>
</div>
</div>
</div>
<!-- setup mode -->
<SwitchGroup as="div" class="max-w-lg 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.setupMode") }}</SwitchLabel
>
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
$t("library.admin.import.version.setupModeDesc")
}}</SwitchDescription>
</span>
<Switch
v-model="versionSettings.onlySetup"
:class="[
versionSettings.onlySetup ? '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="[
versionSettings.onlySetup ? '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>
<!-- launch commands -->
<div class="relative max-w-3xl">
<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 ml-4 flex flex-col gap-y-2 items-start">
<div
v-for="(launch, launchIdx) in versionSettings.launches"
:key="launchIdx"
class="inline-flex items-center gap-x-2"
>
<input
id="launch-name"
v-model="launch.name"
type="text"
name="launch-name"
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-800 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
placeholder="My Launch Command"
/>
<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 pl-3 text-zinc-500 sm:text-sm"
>{{ $t("library.admin.import.version.installDir") }}</span
>
<PreloadSelector
:value="launch.launchCommand"
:guesses="versionGuesses"
@update="(v) => updateLaunchCommand(launchIdx, v)"
/>
<input
id="startup"
v-model="launch.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>
<button
class="transition bg-zinc-800 rounded-sm aspect-square p-1 text-zinc-600 hover:text-red-600 hover:bg-red-600/20"
@click="() => versionSettings.launches!.splice(launchIdx, 1)"
>
<TrashIcon class="size-5" />
</button>
</div>
<p
v-if="versionSettings.launches!.length == 0"
class="uppercase font-display font-bold text-zinc-500 text-xs"
>
No launch commands
</p>
<LoadingButton
:loading="false"
class="inline-flex items-center gap-x-4"
@click="
() =>
versionSettings.launches!.push({
name: '',
description: '',
launchCommand: '',
launchArgs: '',
})
"
>
Add new <PlusIcon class="size-5" />
</LoadingButton>
</div>
<div
v-if="versionSettings.onlySetup"
class="absolute inset-0 bg-zinc-900/50"
/>
</div>
<!-- uninstall command -->
<div class="max-w-lg">
<label
for="startup"
class="block text-sm font-medium leading-6 text-zinc-100"
>Uninstall command</label
>
<p class="text-zinc-400 text-xs">
Executable to be run on uninstalling a game. Useful for installer-only games.
</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>
<PreloadSelector
:value="versionSettings.uninstall"
:guesses="versionGuesses"
@update="(v) => updateUninstallCommand(v)"
/>
<input
id="startup"
v-model="versionSettings.uninstallArgs"
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="--uninstall"
/>
</div>
</div>
</div>
<PlatformSelector
v-model="versionSettings.platform"
class="max-w-lg"
:platforms="allPlatforms"
>
{{ $t("library.admin.import.version.platform") }}
</PlatformSelector>
<SwitchGroup as="div" class="flex items-center justify-between max-w-lg">
<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.updateMode") }}
</SwitchLabel>
<SwitchDescription as="span" class="text-sm text-zinc-400">
{{ $t("library.admin.import.version.updateModeDesc") }}
</SwitchDescription>
</span>
<Switch
v-model="versionSettings.delta"
:class="[
versionSettings.delta ? '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="[
versionSettings.delta ? '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>
<Disclosure v-slot="{ open }" as="div" class="py-2 max-w-lg">
<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 == 'Linux'"
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-800 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"
:loading="importLoading"
@click="startImport_wrapper"
>
{{ $t("library.admin.import.import") }}
</LoadingButton>
<div v-if="importError" class="mt-4 w-fit 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">
{{ importError }}
</h3>
</div>
</div>
</div>
</div>
<div
v-else-if="currentlySelectedVersion != -1"
role="status"
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
>
{{ $t("library.admin.import.version.loadingVersion") }}
<svg
aria-hidden="true"
class="w-6 h-6 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
Switch,
SwitchDescription,
SwitchGroup,
SwitchLabel,
Disclosure,
DisclosureButton,
DisclosurePanel,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { PlusIcon, TrashIcon } from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { ImportVersion } from "~~/server/api/v1/admin/import/version/index.post";
definePageMeta({
layout: "admin",
});
const router = useRouter();
const { t } = useI18n();
const route = useRoute();
const gameId = route.params.id.toString();
const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
);
const userPlatforms = await useAdminPlatforms();
const allPlatforms = renderPlatforms(userPlatforms);
const currentlySelectedVersion = ref(-1);
const versionSettings = ref<Partial<typeof ImportVersion.infer>>({
id: gameId,
launches: [],
});
const versionGuesses =
ref<
Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
>();
function updateLaunchCommand(idx: number, value: string) {
versionSettings.value.launches![idx].launchCommand = value;
autosetPlatform(value);
}
function updateInstallCommand(value: string) {
versionSettings.value.install = value;
autosetPlatform(value);
}
function updateUninstallCommand(value: string) {
versionSettings.value.uninstall = 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.param;
}
const umuIdEnabled = ref(false);
const umuId = computed({
get() {
if (umuIdEnabled.value) return versionSettings.value.umuId;
return undefined;
},
set(v) {
if (umuIdEnabled.value && v) {
versionSettings.value.umuId = v;
}
},
});
const importLoading = ref(false);
const importError = ref<string | undefined>();
async function updateCurrentlySelectedVersion(value: number) {
if (currentlySelectedVersion.value == value) return;
currentlySelectedVersion.value = value;
const version = versions[currentlySelectedVersion.value];
const options = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId,
)}&version=${encodeURIComponent(version)}`,
);
versionGuesses.value = options.map((e) => ({
...e,
platform: allPlatforms.find((v) => v.param === e.platform)!,
}));
versionSettings.value.name = version;
}
async function startImport() {
if (!versionSettings.value) return;
const taskId = await $dropFetch("/api/v1/admin/import/version", {
method: "POST",
body: {
id: gameId,
version: versions[currentlySelectedVersion.value],
...versionSettings.value,
},
});
router.push(`/admin/task/${taskId.taskId}`);
}
function startImport_wrapper() {
importLoading.value = true;
startImport()
.catch((error) => {
importError.value = error.message ?? t("errors.unknown");
})
.finally(() => {
importLoading.value = false;
});
}
</script>

View File

@ -0,0 +1,101 @@
<template>
<div
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"
>
<!--start-->
<div>
<div class="pt-4 inline-flex gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
>
<button
:class="[
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
value == currentMode
? 'bg-zinc-900 text-zinc-100'
: 'bg-transparent text-zinc-500',
]"
@click="() => (currentMode = value as GameEditorMode)"
>
<component :is="icon" class="size-4" />
{{ value }}
</button>
</div>
</div>
</div>
<div>
<!-- open in store button -->
<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"
>
{{ $t("library.admin.openStore") }}
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
</div>
</div>
<component
:is="components[currentMode].editor"
v-model="game"
:unimported-versions="unimportedVersions"
/>
</div>
</template>
<script setup lang="ts">
import { GameEditorMetadata, GameEditorVersion } from "#components";
import {
ArrowTopRightOnSquareIcon,
DocumentIcon,
ServerStackIcon,
} from "@heroicons/vue/24/outline";
import type { Component } from "vue";
const route = useRoute();
const gameId = route.params.id.toString();
const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game/:id`,
{ params: { id: gameId } },
);
const game = ref(rawGame);
definePageMeta({
layout: "admin",
});
enum GameEditorMode {
Metadata = "Metadata",
Versions = "Versions",
}
const components: {
[key in GameEditorMode]: { editor: Component; icon: Component };
} = {
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
[GameEditorMode.Versions]: {
editor: GameEditorVersion,
icon: ServerStackIcon,
},
};
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
useHead({
title: `${currentMode.value} - ${game.value.mName}`,
});
watch(currentMode, (v) => {
useHead({
title: `${v} - ${game.value.mName}`,
});
});
</script>

View File

@ -0,0 +1,357 @@
<template>
<div class="flex flex-col gap-y-6 w-full max-w-md">
<Listbox
as="div"
:model="currentlySelectedGame"
@update:model-value="(value) => updateSelectedGame(value)"
>
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
{{ $t("library.admin.import.selectGame") }}
</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"
>
<span v-if="currentlySelectedGame != -1" class="block truncate"
>{{ games.unimportedGames[currentlySelectedGame].game }}
<span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{
games.unimportedGames[currentlySelectedGame].library.name
}}</span
></span
>
<span v-else class="block truncate text-zinc-400">{{
$t("library.admin.import.selectDir")
}}</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-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="({ game, library }, gameIdx) in games.unimportedGames"
:key="game"
v-slot="{ active }"
as="template"
:value="gameIdx"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
gameIdx === currentlySelectedGame
? 'font-semibold'
: 'font-normal',
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
]"
>{{ game }}
<span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{ library.name }}</span
></span
>
<span
v-if="gameIdx === currentlySelectedGame"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
<div
v-if="games.unimportedGames.length == 0"
class="w-full uppercase font-display font-bold text-zinc-600 p-2 text-center"
>
Nothing to import
</div>
</ListboxOptions>
</transition>
</div>
</Listbox>
<Listbox v-model="currentImportMode" as="div">
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100">
Import as
</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative inline-flex items-center w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<span class="inline-flex items-top gap-x-2">
<component
:is="importModes[currentImportMode].icon"
class="text-blue-600 size-8 p-1 bg-zinc-800 rounded-sm mt-1"
/>
<div>
<h1 class="text-sm font-bold text-zinc-200">
{{ importModes[currentImportMode].name }}
</h1>
<p class="text-xs text-zinc-400 max-w-xs">
{{ importModes[currentImportMode].description }}
</p>
</div>
</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-zinc-800 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="[mode, metadata] in Object.entries(importModes)"
:key="mode"
v-slot="{ active }"
as="template"
:value="mode"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
mode == currentImportMode ? 'font-semibold' : 'font-normal',
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
]"
>{{ metadata.name }}
<span
v-if="mode == currentImportMode"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="flex items-center justify-between gap-x-8">
<span class="flex grow flex-col">
<label
id="bulkImport-label"
class="text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.import.bulkImportTitle") }}</label
>
<span id="bulkImport-description" class="text-sm text-zinc-400">{{
$t("library.admin.import.bulkImportDescription")
}}</span>
</span>
<div
class="group relative inline-flex w-11 shrink-0 rounded-full bg-zinc-800 p-0.5 inset-ring inset-ring-zinc-100/5 outline-offset-2 outline-blue-600 transition-colors duration-200 ease-in-out has-checked:bg-blue-600 has-focus-visible:outline-2"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-zinc-100/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5"
/>
<input
id="bulkImport"
v-model="bulkImportMode"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
name="bulkImport"
aria-labelledby="bulkImport-label"
aria-describedby="bulkImport-description"
/>
</div>
</div>
<component
:is="importModes[currentImportMode].component"
v-if="currentlySelectedGame !== -1"
:game-name="games.unimportedGames[currentlySelectedGame].game"
:loading="importLoading"
:error="importError"
@import="(...v: unknown[]) => importModes[currentImportMode].import(...v)"
/>
</div>
</template>
<script setup lang="ts">
import { ImportGame, ImportRedist } from "#components";
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { PuzzlePieceIcon, ArchiveBoxIcon } from "@heroicons/vue/24/solid";
import type { FetchError } from "ofetch";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
definePageMeta({
layout: "admin",
});
type ImportMode = "Game" | "Redist";
const importModes = shallowRef<{
[key in ImportMode]: {
name: string;
description: string;
icon: Component;
component: Component;
import: (...v: unknown[]) => void;
};
}>({
Game: {
name: "Game",
description: "Games can be added to user libraries, installed, and played.",
icon: PuzzlePieceIcon,
component: ImportGame,
import: importGame_wrapper as (v: unknown) => void,
},
Redist: {
name: "Redistributable",
description:
"Redistributables are packaged dependencies for games, that are installed alongside and required to play certain games.",
icon: ArchiveBoxIcon,
component: ImportRedist,
import: importRedist as (v: unknown, k: unknown) => void,
},
});
const currentImportMode = ref<ImportMode>("Game");
const { t } = useI18n();
const rawGames = await $dropFetch("/api/v1/admin/import/game");
const games = ref(rawGames);
const currentlySelectedGame = ref(-1);
const bulkImportMode = ref(false);
async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value || value == -1) return;
const option = games.value.unimportedGames[value];
if (!option) return;
currentlySelectedGame.value = value;
}
const router = useRouter();
const importLoading = ref(false);
const importError = ref<string | undefined>();
async function importGame(metadata: GameMetadataSearchResult | undefined) {
const option = games.value.unimportedGames[currentlySelectedGame.value];
const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
method: "POST",
body: {
path: option.game,
library: option.library.id,
metadata,
},
});
if (!bulkImportMode.value) {
router.push(`/admin/task/${taskId}`);
} else {
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
currentlySelectedGame.value = -1;
}
}
async function importGame_wrapper(
metadata: GameMetadataSearchResult | undefined,
) {
importLoading.value = true;
importError.value = undefined;
try {
await importGame(metadata);
} catch (error) {
console.warn(error);
importError.value =
(error as FetchError)?.message || t("errors.unknown");
}
importLoading.value = false;
}
async function importRedist(data: object, platform: object | undefined) {
importLoading.value = true;
importError.value = undefined;
try {
const option = games.value.unimportedGames[currentlySelectedGame.value];
const formData = new FormData();
for (const [key, value] of Object.entries(data)) {
formData.append(
key,
value,
);
}
if (platform) {
for (const [key, value] of Object.entries(platform)) {
// Because we know there will be no file, and we need to handle more complex objects for
// the platform, we do this.
// Maybe we shouldn't.
formData.append(
`platform.${key}`,
typeof value === "object" ? JSON.stringify(value) : value,
);
}
}
formData.append("library", option.library.id);
formData.append("path", option.game);
const redist = await $dropFetch("/api/v1/admin/import/redist", {
body: formData,
method: "POST",
});
if (!bulkImportMode.value) {
router.push(`/admin/library/r/${redist.id}`);
} else {
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
currentlySelectedGame.value = -1;
}
} catch (error) {
console.warn(error);
importError.value =
(error as FetchError)?.message || t("errors.unknown");
}
importLoading.value = false;
}
</script>

View File

@ -0,0 +1,496 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.gameLibrary") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.subheader") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/library/sources"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.sources.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-if="toImport" class="rounded-md bg-blue-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
{{ $t("library.admin.detectedGame") }}
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
href="/admin/library/import"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</p>
</div>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.search')"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<div class="flex gap-x-4 text-zinc-300 font-bold uppercase font-display text-sm">
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-blue-600" />
Game</span
>
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-emerald-600" />
Redistributable</span
>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="entry in filteredLibrary"
:key="entry.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
>
<div
v-if="entry.type === 'game'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div
class="absolute top-0 right-0 w-10 bg-blue-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(entry.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ entry.mName }}
<button
type="button"
:class="[
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
entry.featured
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
]"
@click="() => featureGame(entry.id)"
>
<svg
v-if="gameFeatureLoading[entry.id]"
aria-hidden="true"
:class="[
entry.featured ? ' fill-zinc-900' : 'fill-zinc-100',
'size-3 text-transparent animate-spin',
]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<StarIcon v-else class="size-3" aria-hidden="true" />
</button>
<span
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ entry.library.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ entry.mShortDescription }}
</dd>
<dt class="sr-only">
{{ $t("library.admin.metadataProvider") }}
</dt>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/g/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteGame(entry.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
<div
v-else-if="entry.type === 'redist'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div
class="absolute top-0 right-0 w-10 bg-emerald-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(entry.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ entry.mName }}
<span
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ entry.library.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ entry.mShortDescription }}
</dd>
</dl>
<dl
v-if="entry.platform"
class="mt-2 flex items-center text-zinc-200 font-semibold text-sm gap-x-1 p-2 bg-zinc-800 rounded-xl"
>
<IconsPlatform
:platform="entry.platform.id"
:fallback="entry.platform.iconSvg"
class="size-6 text-blue-600"
/>
<span>{{ entry.platform.platformName }}</span>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/r/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteRedist(entry.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
<div v-if="entry.hasNotifications" class="flex flex-col gap-y-2 p-2">
<div
v-if="entry.notifications.toImport"
class="rounded-md bg-blue-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<InformationCircleIcon
class="h-5 w-5 text-blue-400"
aria-hidden="true"
/>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<p class="text-sm text-blue-400">
{{ $t("library.admin.detectedVersion") }}
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
:href="`/admin/library/g/${entry.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</p>
</div>
</div>
</div>
<div
v-if="entry.notifications.noVersions"
class="rounded-md bg-yellow-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<ExclamationTriangleIcon
class="h-5 w-5 text-yellow-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-600">
{{ $t("library.admin.version.noVersions") }}
</h3>
</div>
</div>
</div>
<div
v-if="entry.notifications.offline"
class="rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<ExclamationCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ $t("library.admin.offline") }}
</h3>
</div>
</div>
</div>
</div>
</li>
<p
v-if="filteredLibrary.length == 0 && libraryGames.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="
filteredLibrary.length == 0 &&
libraryGames.length == 0 &&
libraryState.hasLibraries
"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.noGames") }}
</p>
<p
v-else-if="!libraryState.hasLibraries"
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
>
<span class="text-sm font-display font-bold uppercase">{{
$t("library.admin.libraryHint")
}}</span>
<NuxtLink
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
href="https://docs.droposs.org/docs/library"
target="_blank"
>
<i18n-t
keypath="library.admin.libraryHintDocsLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</p>
</ul>
</div>
</template>
<script setup lang="ts">
import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import {
ArrowTopRightOnSquareIcon,
InformationCircleIcon,
StarIcon,
} from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("library.admin.title"),
});
const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library");
const toImport = ref(
Object.values(libraryState.unimportedGames).flat().length > 0,
);
// Potentially make a server-side transformation to make the client lighter
function clientSideTransformation<T, V extends keyof T, K extends string>(
values: Array<T & { status: (typeof libraryState.games)[number]["status"] }>,
expand: V,
type: K,
): Array<
T[V] & {
status: "online" | "offline";
type: K;
hasNotifications?: boolean;
notifications: {
noVersions?: boolean;
toImport?: boolean;
offline?: boolean;
};
}
> {
return values.map((e) => {
if (e.status == "offline") {
return {
...e[expand],
type: type,
status: "offline" as const,
hasNotifications: true,
notifications: {
offline: true,
},
};
}
const noVersions = e.status.noVersions;
const toImport = e.status.unimportedVersions.length > 0;
return {
...e[expand],
type: type,
notifications: {
noVersions,
toImport,
},
hasNotifications: noVersions || toImport,
status: "online" as const,
};
});
}
const libraryGames = ref(
clientSideTransformation(libraryState.games, "value", "game"),
);
const libraryRedists = ref(
clientSideTransformation(libraryState.redists, "value", "redist"),
);
const filteredLibrary = computed(() =>
[...libraryGames.value, ...libraryRedists.value].filter((e) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
return true;
return false;
}),
);
async function deleteGame(id: string) {
await $dropFetch(`/api/v1/admin/game/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryGames.value.findIndex((e) => e.id === id);
libraryGames.value.splice(index, 1);
toImport.value = true;
}
async function deleteRedist(id: string) {
await $dropFetch(`/api/v1/admin/redist/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryRedists.value.findIndex((e) => e.id === id);
libraryRedists.value.splice(index, 1);
toImport.value = true;
}
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
async function featureGame(id: string) {
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);
const game = libraryGames.value[gameIndex];
gameFeatureLoading.value[game.id] = true;
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.id,
},
body: { featured: !game.featured },
failTitle: "Failed to feature/unfeature game",
});
libraryGames.value[gameIndex].featured = !game.featured;
gameFeatureLoading.value[game.id] = false;
}
</script>

View File

@ -0,0 +1,85 @@
<template>
<div
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"
>
<!--start-->
<div>
<div class="pt-4 inline-flex gap-x-2">
<div
v-for="[value, { icon }] in Object.entries(components)"
:key="value"
>
<button
:class="[
'inline-flex items-center gap-x-1 py-2 px-3 rounded-t-md font-semibold text-sm',
value == currentMode
? 'bg-zinc-900 text-zinc-100'
: 'bg-transparent text-zinc-500',
]"
@click="() => (currentMode = value as RedistEditorMode)"
>
<component :is="icon" class="size-4" />
{{ value }}
</button>
</div>
</div>
</div>
<div>
<!-- open in store button -->
</div>
</div>
<component
:is="components[currentMode].editor"
v-model="redist"
:unimported-versions="unimportedVersions"
/>
</div>
</template>
<script setup lang="ts">
import { GameEditorVersion, RedistEditorMetadata } from "#components";
import { DocumentIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
import type { Component } from "vue";
const route = useRoute();
const redistId = route.params.id.toString();
const { redist: rawRedist, unimportedVersions } = await $dropFetch(
`/api/v1/admin/redist/:id`,
{ params: { id: redistId } },
);
const redist = ref(rawRedist);
definePageMeta({
layout: "admin",
});
enum RedistEditorMode {
Metadata = "Metadata",
Versions = "Versions",
}
const components: {
[key in RedistEditorMode]: { editor: Component; icon: Component };
} = {
[RedistEditorMode.Metadata]: { editor: RedistEditorMetadata, icon: DocumentIcon },
[RedistEditorMode.Versions]: {
editor: GameEditorVersion,
icon: ServerStackIcon,
},
};
const currentMode = ref<RedistEditorMode>(RedistEditorMode.Metadata);
useHead({
title: `${currentMode.value} - ${redist.value.mName}`,
});
watch(currentMode, (v) => {
useHead({
title: `${v} - ${redist.value.mName}`,
});
});
</script>

View File

@ -0,0 +1,465 @@
<template>
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.sources.sources") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.sources.desc") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (actionSourceOpen = true)"
>
{{ $t("common.create") }}
</button>
</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="min-w-full divide-y divide-zinc-700">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("type") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.working") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("options") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">{{ $t("common.edit") }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(source, sourceIdx) in sources"
:key="source.id"
class="even:bg-zinc-800"
>
<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"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/>
{{ optionsMetadata[source.backend].title }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon
v-if="source.working"
class="size-5 text-green-500"
/>
<XMarkIcon v-else class="size-5 text-red-500" />
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.options }}
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-3 text-right text-sm font-medium space-x-2"
>
<button
class="text-blue-500 hover:text-blue-400"
@click="() => edit(sourceIdx)"
>
{{ $t("common.edit") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
<button
class="text-red-500 hover:text-red-400"
@click="() => deleteSource(sourceIdx)"
>
{{ $t("delete") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<ModalTemplate v-model="actionSourceOpen">
<template #default>
<div>
<DialogTitle
v-if="createMode"
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.create") }}
</DialogTitle>
<DialogTitle
v-else
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.edit") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.sources.createDesc") }}
</p>
</div>
<form
class="mt-2 space-y-4"
@submit.prevent="() => performActionSource_wrapper()"
>
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("common.name") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.nameDesc") }}
</p>
<div class="mt-2">
<input
id="name"
v-model="sourceName"
name="name"
type="text"
autocomplete="name"
:placeholder="$t('library.admin.sources.namePlaceholder')"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div v-if="createMode">
<label class="block text-sm font-medium leading-6 text-zinc-100">{{
$t("type")
}}</label>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.typeDesc") }}
</p>
<RadioGroup v-model="currentSourceOption" class="mt-2">
<RadioGroupLabel class="sr-only">{{
$t("type")
}}</RadioGroupLabel>
<div class="space-y-4">
<RadioGroupOption
v-for="[source, metadata] in optionsMetadataIter"
:key="source"
v-slot="{ checked }"
as="template"
:value="source"
>
<div
:class="[
'relative block cursor-pointer bg-zinc-800 rounded-lg border border-zinc-900 px-2 py-2 shadow-sm focus:outline-none sm:flex sm:justify-between',
]"
>
<span class="flex items-center gap-x-4">
<div>
<component
:is="metadata.icon"
class="size-12 bg-zinc-900 rounded-xl p-3 text-zinc-400"
/>
</div>
<span class="flex flex-col text-sm">
<RadioGroupLabel
as="span"
class="font-semibold text-zinc-100"
>{{ metadata.title }}
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
source
}}</span>
</RadioGroupLabel>
<RadioGroupDescription
as="span"
class="text-zinc-400 text-xs"
>
{{ metadata.description }}
</RadioGroupDescription>
<NuxtLink
:href="metadata.docsLink"
:external="true"
target="_blank"
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.sources.documentationLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</span>
</span>
<span
:class="[
checked ? 'ring-2 ring-blue-600' : '',
'pointer-events-none absolute -inset-px rounded-lg',
]"
aria-hidden="true"
/>
</div>
</RadioGroupOption>
</div>
</RadioGroup>
</div>
<div class="h-[1px] w-full bg-zinc-700 rounded-full" />
<component
:is="optionUIs[currentSourceOption]"
v-model="sourceConfig"
/>
<input type="submit" class="hidden" />
</form>
<div v-if="modalError" 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">
{{ modalError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="modalLoading"
:disabled="modalLoading"
class="w-full sm:w-fit"
@click="() => performActionSource_wrapper()"
>
{{ createMode ? $t("common.create") : $t("common.save") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="
() => {
editIndex = undefined;
close();
}
"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</div>
</template>
<script setup lang="ts">
/**
* I did something a little cursed for this
* To avoid making a separate modal for saving, we
* instead set the index of the source we want to edit
* and there's a bunch of checks everywhere to switch
* between 'create' and 'edit'
*/
import {
DropLogo,
SourceOptionsFilesystem,
SourceOptionsFlatFilesystem,
} from "#components";
import {
DialogTitle,
RadioGroup,
RadioGroupDescription,
RadioGroupLabel,
RadioGroupOption,
} from "@headlessui/vue";
import {
XCircleIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/vue/20/solid";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { FetchError } from "ofetch";
import type { Component } from "vue";
import type { LibraryBackend } from "~~/prisma/client/enums";
import type { WorkingLibrarySource } from "~~/server/api/v1/admin/library/sources/index.get";
definePageMeta({
layout: "admin",
});
useHead({
title: "Library Sources",
});
const { t } = useI18n();
// Optional token for setup wizard
const { token = undefined } = defineProps<{ token?: string }>();
const headers = token ? { Authorization: token } : undefined;
const sources = ref(
await $dropFetch<WorkingLibrarySource[]>("/api/v1/admin/library/sources", {
headers,
}),
);
const editIndex = ref<undefined | number>(undefined);
const createMode = computed(() => editIndex.value === undefined);
const actionSourceOpen = ref(false);
const currentSourceOption = ref<LibraryBackend>("Filesystem");
const sourceName = ref("");
const sourceConfig = ref<object>({});
const modalError = ref<undefined | string>();
const modalLoading = ref(false);
const optionUIs: { [key in LibraryBackend]: Component } = {
Filesystem: SourceOptionsFilesystem,
FlatFilesystem: SourceOptionsFlatFilesystem,
};
const optionsMetadata: {
[key in LibraryBackend]: {
title: string;
description: string;
docsLink: string;
icon: Component;
};
} = {
Filesystem: {
title: t("library.admin.sources.fsTitle"),
description: t("library.admin.sources.fsDesc"),
docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
},
};
const optionsMetadataIter = Object.entries(optionsMetadata);
async function performActionSource() {
const createMode = editIndex.value === undefined;
const source = await $dropFetch<WorkingLibrarySource>(
"/api/v1/admin/library/sources",
{
body: {
id: createMode ? undefined : sources.value[editIndex.value!].id,
name: sourceName.value,
backend: createMode ? currentSourceOption.value : undefined,
options: sourceConfig.value,
},
method: createMode ? "POST" : "PATCH",
headers,
},
);
if (createMode) {
sources.value.push(source);
} else {
sources.value[editIndex.value!] = source;
}
}
function performActionSource_wrapper() {
modalError.value = undefined;
modalLoading.value = true;
performActionSource()
.then(
() => {
actionSourceOpen.value = false;
sourceConfig.value = {};
sourceName.value = "";
},
(e) => {
if (e instanceof FetchError) {
console.log(e.data.message);
modalError.value = e.message;
} else {
modalError.value = e as string;
}
},
)
.finally(() => {
modalLoading.value = false;
});
}
function edit(index: number) {
const source = sources.value[index];
if (!source) return;
sourceName.value = source.name;
sourceConfig.value = source.options! as object;
editIndex.value = index;
actionSourceOpen.value = true;
}
async function deleteSource(index: number) {
const source = sources.value[index];
if (!source) return;
try {
await $dropFetch("/api/v1/admin/library/sources", {
method: "DELETE",
body: { id: source.id },
headers,
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.library.source.delete.title"),
description: t("errors.library.source.delete.desc", [
// @ts-expect-error attempt to display message on error
e?.message ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
}
sources.value.splice(index, 1);
}
</script>

View File

@ -0,0 +1,422 @@
<template>
<div class="space-y-4">
<div
class="relative overflow-hidden rounded-lg flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2 p-8"
>
<img
:src="useObject(company.mBannerObjectId)"
class="absolute inset-0 w-full h-full object-cover object-center"
/>
<div class="absolute inset-0 bg-zinc-900/80" />
<div class="relative inline-flex items-center gap-4">
<!-- icon image -->
<div class="relative group/iconupload rounded-xl overflow-hidden">
<img :src="useObject(company.mLogoObjectId)" class="size-20" />
<button
class="rounded-xl transition duration-200 absolute inset-0 opacity-0 group-hover/iconupload:opacity-100 focus-visible/iconupload:opacity-100 cursor-pointer bg-zinc-900/80 text-zinc-100 flex flex-col items-center justify-center text-center text-xs font-semibold ring-1 ring-inset ring-zinc-800 px-2"
@click="() => (uploadLogoOpen = true)"
>
<ArrowUpTrayIcon class="size-5" />
<span>{{
$t("library.admin.metadata.companies.editor.uploadIcon")
}}</span>
</button>
</div>
<div class="flex flex-col">
<h1
class="group/name inline-flex items-center gap-x-3 text-5xl font-bold font-display text-zinc-100"
>
{{ company.mName }}
<button @click="() => editName()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/name:opacity-100 size-8"
/>
</button>
</h1>
<p
class="group/description mt-1 inline-flex items-center gap-x-3 text-lg text-zinc-400 max-w-xl"
>
{{
company.mShortDescription ||
$t("library.admin.metadata.companies.editor.noDescription")
}}
<button @click="() => editShortDescription()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/description:opacity-100 size-5"
/>
</button>
</p>
<p
class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3"
>
{{
company.mWebsite ||
$t("library.admin.metadata.companies.editor.websitePlaceholder")
}}
<button @click="() => editWebsite()">
<PencilIcon
class="transition duration-200 xl:opacity-0 group-hover/website:opacity-100 size-4"
/>
</button>
</p>
</div>
</div>
<button
type="button"
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (uploadBannerOpen = true)"
>
{{ $t("library.admin.metadata.companies.editor.uploadBanner") }}
<ArrowUpTrayIcon class="size-4" />
</button>
</div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.editor.libraryTitle") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.editor.libraryDescription") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (addGameModelOpen = true)"
>
<i18n-t
keypath="library.admin.metadata.companies.editor.action"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #plus>
<PlusIcon class="size-4" />
</template>
</i18n-t>
</button>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.admin.metadata.companies.searchGames')"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="game in filteredGames"
:key="game.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border border-zinc-800 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(game.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ game.mName }}
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ game.mShortDescription }}
</dd>
</dl>
<div class="mt-4 flex flex-col gap-y-3">
<div class="flex items-center gap-3">
<div
class="group/published relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-3"
/>
<input
id="published"
v-model="published[game.id]"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="published-label"
/>
</div>
<label
id="published-label"
for="published"
class="font-medium text-xs text-zinc-100"
>{{
$t("library.admin.metadata.companies.editor.published")
}}</label
>
</div>
<div class="flex items-center gap-3">
<div
class="group/developed relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developed:translate-x-3"
/>
<input
id="developed"
v-model="developed[game.id]"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="developed-label"
/>
</div>
<label
id="developed-label"
for="published"
class="font-medium text-xs text-zinc-100"
>{{
$t("library.admin.metadata.companies.editor.developed")
}}</label
>
</div>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => removeGame(game.id)"
>
{{ $t("common.remove") }}
</button>
</div>
</div>
</div>
</li>
<p
v-if="filteredGames.length == 0 && games.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredGames.length == 0 && games.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.metadata.companies.noGames") }}
</p>
</ul>
<ModalAddCompanyGame
v-model="addGameModelOpen"
:exclude="games.map((e) => e.id)"
:company-id="company.id"
@created="appendGame"
/>
<ModalUploadFile
v-model="uploadLogoOpen"
:endpoint="`/api/v1/admin/company/${company.id}/icon`"
accept="image/*"
@upload="updateLogo"
/>
<ModalUploadFile
v-model="uploadBannerOpen"
:endpoint="`/api/v1/admin/company/${company.id}/banner`"
accept="image/*"
@upload="updateBanner"
/>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import { ArrowUpTrayIcon, PencilIcon, PlusIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel } from "~~/prisma/client/models";
definePageMeta({
layout: "admin",
});
const route = useRoute();
const companyId = route.params.id!.toString();
const result = await $dropFetch("/api/v1/admin/company/:id", {
params: { id: companyId },
});
const company = ref(result.company);
const games = ref(result.games);
const addGameModelOpen = ref(false);
const uploadLogoOpen = ref(false);
const uploadBannerOpen = ref(false);
const { t } = useI18n();
useHead({
title: `${company.value.mName} - Company`,
});
const searchQuery = ref("");
const filteredGames = computed(() =>
games.value.filter(
(e: SerializeObject<GameModel>) =>
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
e.mShortDescription.includes(searchQuery.value.toLowerCase()),
),
);
function buildToggleProxy(param: "developed" | "published") {
async function tick(id: string, enabled: boolean) {
if (
company.value.developed.length == 0 &&
company.value.published.length == 0
)
return await removeGame(id);
await $dropFetch("/api/v1/admin/company/:id/game", {
method: "PATCH",
params: {
id: company.value.id,
},
body: {
action: param,
enabled,
id,
},
failTitle: `Failed to update ${param} for game`,
});
}
return new Proxy({} as { [key: string]: boolean }, {
get(_target, prop, _reciever) {
return company.value[param].includes(prop.toString());
},
set(_target, prop, value) {
if (typeof value !== "boolean") return false;
const id = prop.toString();
const exists = company.value[param].findIndex((e) => e === id);
if (value && exists == -1) {
company.value[param].push(id);
}
if (!value && exists != -1) {
company.value[param].splice(exists, 1);
}
tick(id, value);
return true;
},
});
}
const published = buildToggleProxy("published");
const developed = buildToggleProxy("developed");
async function removeGame(gameId: string) {
await $dropFetch("/api/v1/admin/company/:id/game", {
params: {
id: company.value.id,
},
body: {
id: gameId,
},
method: "DELETE",
failTitle: "Failed to remove game",
});
const gameIndex = games.value.findIndex((e) => e.id == gameId);
if (gameIndex == -1) return;
games.value.splice(gameIndex, 1);
const publishedIndex = company.value.published.findIndex((e) => e === gameId);
if (publishedIndex != -1) {
company.value.published.splice(publishedIndex, 1);
}
const developedIndex = company.value.developed.findIndex((e) => e === gameId);
if (developedIndex != -1) {
company.value.developed.splice(developedIndex, 1);
}
}
function appendGame(
game: (typeof games.value)[number],
published: boolean,
developed: boolean,
) {
games.value.push(game);
if (published) {
company.value.published.push(game.id);
}
if (developed) {
company.value.developed.push(game.id);
}
}
function buildFieldEditModal(
field: "mName" | "mShortDescription" | "mWebsite",
title: string,
description: string,
) {
function modal() {
createModal(
ModalType.TextInput,
{
title,
description,
dft: company.value[field],
},
async (e, c, s) => {
switch (e) {
case "cancel": {
c();
break;
}
case "submit": {
const result = await $dropFetch("/api/v1/admin/company/:id", {
method: "PATCH",
params: { id: company.value.id },
body: { [field]: s! },
failTitle: "Failed to update company details",
});
company.value[field] = result[field];
c();
break;
}
}
},
);
}
return modal;
}
const editName = buildFieldEditModal(
"mName",
t("library.admin.metadata.companies.modals.nameTitle"),
t("library.admin.metadata.companies.modals.nameDescription"),
);
const editShortDescription = buildFieldEditModal(
"mShortDescription",
t("library.admin.metadata.companies.modals.shortDeckTitle"),
t("library.admin.metadata.companies.modals.shortDeckDescription"),
);
const editWebsite = buildFieldEditModal(
"mWebsite",
t("library.admin.metadata.companies.modals.websiteTitle"),
t("library.admin.metadata.companies.modals.websiteDescription"),
);
function updateLogo(response: { id: string }) {
company.value.mLogoObjectId = response.id;
}
function updateBanner(response: { id: string }) {
company.value.mBannerObjectId = response.id;
}
</script>

View File

@ -0,0 +1,153 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.description") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createCompanyOpen = true)"
>
{{ $t("common.create") }}
</button>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.admin.metadata.companies.search')"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="company in filteredCompanies"
:key="company.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(company.mLogoObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ company.mName }}
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ company.mShortDescription }}
</dd>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/metadata/companies/${company.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteCompany(company.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
</li>
<p
v-if="filteredCompanies.length == 0 && companies.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredCompanies.length == 0 && companies.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.metadata.companies.noCompanies") }}
</p>
</ul>
<ModalCreateCompany
v-model="createCompanyOpen"
@created="(company) => createCompany(company)"
/>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { CompanyModel } from "~~/prisma/client/models";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("library.admin.metadata.companies.title"),
});
const createCompanyOpen = ref(false);
const searchQuery = ref("");
const rawCompanies = await $dropFetch("/api/v1/admin/company");
const companies = ref(rawCompanies);
const filteredCompanies = computed(() =>
companies.value.filter((e: CompanyModel) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
return true;
return false;
}),
);
async function deleteCompany(id: string) {
await $dropFetch(`/api/v1/admin/company/:id`, {
method: "DELETE",
params: { id },
failTitle: "Failed to delete company",
});
const index = companies.value.findIndex((e) => e.id === id);
companies.value.splice(index, 1);
}
function createCompany(company: (typeof companies.value)[number]) {
companies.value.push(company);
}
</script>

View File

@ -0,0 +1,62 @@
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-16">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.tags.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.tags.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/metadata/tags"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.metadata.tags.action"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/metadata/companies"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.metadata.companies.action"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
</script>

View File

@ -0,0 +1,75 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.tags.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.tags.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createModalOpen = true)"
>
{{ $t("library.admin.metadata.tags.create") }}
</button>
</div>
</div>
<div class="flex flex-wrap gap-3">
<div
v-for="(tag, tagIdx) in tags"
:key="tag.id"
class="py-2 px-3 inline-flex gap-x-3 bg-zinc-950 ring-1 ring-zinc-800 text-zinc-300"
>
{{ tag.name }}
<button @click="() => deleteTag(tagIdx)">
<TrashIcon
class="transition size-4 text-zinc-700 hover:text-red-500"
/>
</button>
</div>
</div>
<ModalCreateTag v-model="createModalOpen" @created="onTagCreate" />
</div>
</template>
<script setup lang="ts">
import { TrashIcon } from "@heroicons/vue/24/outline";
import type { SerializeObject } from "nitropack";
import type { GameTagModel } from "~~/prisma/client/models";
definePageMeta({
layout: "admin",
});
const createModalOpen = ref(false);
const tags = ref(
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/admin/tags"),
);
function sort() {
tags.value.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);
}
sort();
async function onTagCreate(tag: GameTagModel) {
tags.value.push(tag);
sort();
}
async function deleteTag(tagIdx: number) {
const tag = tags.value[tagIdx];
await $dropFetch(`/api/v1/admin/tags/${tag.id}`, {
method: "DELETE",
failTitle: "Failed to delete tag",
});
tags.value.splice(tagIdx, 1);
}
</script>

View File

@ -0,0 +1,68 @@
<template>
<div class="flex flex-col">
<!-- tabs-->
<div>
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex gap-x-2" aria-label="Tabs">
<NuxtLink
v-for="(tab, tabIdx) in navigation"
:key="tab.route"
:href="tab.route"
:class="[
currentNavigationIndex == tabIdx
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
]"
:aria-current="tab ? 'page' : undefined"
>
<component
:is="tab.icon"
:class="[
currentNavigationIndex == tabIdx
? 'text-blue-500 dark:text-blue-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
'mr-2 -ml-0.5 size-5',
]"
aria-hidden="true"
/>
<span>{{ tab.label }}</span>
</NuxtLink>
</nav>
</div>
</div>
<!-- content -->
<div class="mt-4 grow flex">
<NuxtPage />
</div>
</div>
</template>
<script setup lang="ts">
import {
BuildingStorefrontIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
const navigation: Array<NavigationItem & { icon: Component }> = [
{
label: $t("header.admin.settings.store"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: BuildingStorefrontIcon,
},
{
label: $t("header.admin.settings.tokens"),
route: "/admin/settings/tokens",
prefix: "/admin/settings/tokens",
icon: CodeBracketIcon,
},
];
// const notifications = useNotifications();
// const unreadNotifications = computed(() =>
// notifications.value.filter((e) => !e.read)
// );
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
</script>

View File

@ -0,0 +1,108 @@
<template>
<form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="pb-4 border-b border-zinc-700">
<h2 class="text-xl font-semibold text-zinc-100">
{{ $t("settings.admin.store.title") }}
</h2>
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
</h3>
<ul class="flex gap-3">
<li class="inline-block">
<OptionWrapper
:active="showGamePanelTextDecoration"
@click="setShowTitleDescription(true)"
>
<div class="flex">
<GamePanel
:animate="false"
:game="game"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
<li class="inline-block">
<OptionWrapper
:active="!showGamePanelTextDecoration"
@click="setShowTitleDescription(false)"
>
<div class="flex">
<GamePanel
:game="game"
:show-title-description="false"
:animate="false"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
</ul>
</div>
<LoadingButton
type="submit"
class="inline-flex w-full shadow-sm sm:w-auto"
:loading="saving"
:disabled="!allowSave"
>
{{ allowSave ? $t("common.save") : $t("common.saved") }}
</LoadingButton>
</form>
</template>
<script setup lang="ts">
import { FetchError } from "ofetch";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("settings.admin.title"),
});
const settings = await $dropFetch("/api/v1/settings");
const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data");
const allowSave = ref(false);
const showGamePanelTextDecoration = ref<boolean>(
settings.showGamePanelTextDecoration,
);
function setShowTitleDescription(value: boolean) {
showGamePanelTextDecoration.value = value;
allowSave.value = true;
}
const saving = ref<boolean>(false);
async function saveSettings() {
saving.value = true;
try {
await $dropFetch("/api/v1/admin/settings", {
method: "PATCH",
body: {
showGamePanelTextDecoration: showGamePanelTextDecoration.value,
},
});
} catch (e) {
createModal(
ModalType.Notification,
{
title: `Failed to save settings.`,
description:
e instanceof FetchError
? (e.message)
: (e as string).toString(),
},
(_, c) => c(),
);
}
saving.value = false;
allowSave.value = false;
}
</script>

View File

@ -0,0 +1,233 @@
<template>
<div class="w-full">
<div class="w-full flex justify-between items-center">
<div>
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.token.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.token.subheader") }}
</p>
</div>
<div>
<LoadingButton :loading="false" @click="() => (createOpen = true)">
{{ $t("common.create") }}
</LoadingButton>
</div>
</div>
<div
v-if="newToken"
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
>
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-300">
{{ $t("account.token.success") }}
</p>
<p class="text-xs text-green-300/70">
{{ $t("account.token.successNote") }}
</p>
<p
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
>
{{ newToken }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
@click="() => (newToken = undefined)"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.acls") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.expiry") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="(token, tokenIdx) in tokens"
:key="token.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ token.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="acl in token.acls"
:key="acl"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
{{ acl }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
<span v-else>{{ $t("account.token.noExpiry") }}</span>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeToken(tokenIdx)"
>
{{ $t("account.token.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [token.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="tokens.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.token.noTokens") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ModalCreateToken
v-model="createOpen"
:acls="acls"
:loading="createLoading"
:suggested-name="suggestedName"
:suggested-acls="suggestedAcls"
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
/>
</div>
</template>
<script setup lang="ts">
import { ArkErrors, type } from "arktype";
import { DateTime, type DurationLike } from "luxon";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
definePageMeta({
layout: "admin",
});
const tokens = ref(await $dropFetch("/api/v1/admin/token"));
const acls = await $dropFetch("/api/v1/admin/token/acls");
const createOpen = ref(false);
const createLoading = ref(false);
const newToken = ref<string | undefined>();
const suggestedName = ref();
const suggestedAcls = ref<string[]>([]);
const payloadParser = type({
name: "string?",
acls: "string[]?",
});
const route = useRoute();
if (route.query.payload) {
try {
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
const payload = payloadParser(rawPayload);
if (payload instanceof ArkErrors) throw payload;
suggestedName.value = payload.name;
suggestedAcls.value = payload.acls ?? [];
createOpen.value = true;
} catch {
throw createError({
statusCode: 400,
message: "Failed to parse the token create payload.",
fatal: true,
});
}
}
async function createToken(
name: string,
acls: string[],
expiry: DurationLike | undefined,
) {
createLoading.value = true;
try {
const result = await $dropFetch("/api/v1/admin/token", {
method: "POST",
body: {
name,
acls,
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} catch {
/* empty */
}
createOpen.value = false;
createLoading.value = false;
}
async function revokeToken(index: number) {
const token = tokens.value[index];
if (!token) return;
await $dropFetch("/api/v1/admin/token/:id", {
method: "DELETE",
params: {
id: token.id,
},
failTitle: "Failed to revoke token.",
});
tokens.value.splice(index, 1);
}
</script>

View File

@ -0,0 +1,107 @@
<template>
<div>
<NuxtLink
to="/admin/task"
class="mb-2 transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
>
<i18n-t keypath="tasks.admin.back" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
</template>
</i18n-t>
</NuxtLink>
<div
v-if="task && task.error"
class="grow w-full flex items-center justify-center"
>
<div class="flex flex-col items-center">
<ExclamationCircleIcon
class="h-12 w-12 text-red-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1
class="text-3xl font-semibold font-display leading-6 text-zinc-100"
>
{{ task.error.title }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ task.error.description }}
</p>
</div>
</div>
</div>
</div>
<div v-else-if="task" class="flex flex-col w-full gap-y-4">
<h1
class="inline-flex items-center gap-x-3 text-3xl text-zinc-100 font-bold font-display"
>
<div>
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
<div v-else class="size-4 bg-blue-600 rounded-full animate-pulse" />
</div>
{{ task.name }}
</h1>
<div
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
>
<LogLine
v-for="(_, idx) in task.log"
:key="idx"
:log="parseTaskLog(task.log.at(-(idx + 1)))"
/>
</div>
<div class="relative h-5 rounded-xl bg-zinc-950 overflow-hidden">
<div
:style="{ width: `${task.progress}%` }"
class="transition-all bg-blue-600 h-full"
/>
<span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
>{{
$t("tasks.admin.progress", [Math.round(task.progress * 10) / 10])
}}</span
>
</div>
</div>
<div v-else role="status" class="w-full flex items-center justify-center">
<svg
aria-hidden="true"
class="size-8 text-transparent animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span class="sr-only">{{ $t("common.srLoading") }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon } from "@heroicons/vue/16/solid";
import { ExclamationCircleIcon } from "@heroicons/vue/24/solid";
const route = useRoute();
const taskId = route.params.id.toString();
const task = useTask(taskId);
definePageMeta({
layout: "admin",
});
useHead({
title: "Task",
});
</script>

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