Files
drop/pages/admin/metadata/companies/[id]/index.vue
DecDuck b84d1f20b5 v2 download API and Admin UI fixes (#177)
* fix: small ui fixes

* feat: #171

* fix: improvements to library scanning on admin UI

* feat: v2 download API

* fix: add download context cleanup

* fix: lint
2025-08-09 15:45:39 +10:00

423 lines
15 KiB
Vue

<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>