feat: refactor & redesign parts of UI

This commit is contained in:
DecDuck
2025-01-28 15:16:34 +11:00
parent 934c176974
commit cf0aa948fe
21 changed files with 639 additions and 1478 deletions

View File

@ -1,97 +1,117 @@
<template>
<div class="flex flex-row h-full">
<!-- Left sidebar with game list -->
<div class="w-64 min-w-64 border-r border-zinc-800 flex flex-col min-h-[75vh] h-full">
<div class="flex-1 overflow-y-auto p-3">
<h2 class="text-lg font-semibold tracking-tight text-zinc-100 mb-3">
Your Library
</h2>
<!-- Search bar -->
<div class="relative mb-3">
<input
type="text"
name="search"
id="search"
autocomplete="off"
class="block w-full rounded-md bg-zinc-900 py-1 pl-8 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
placeholder="Search library..."
v-model="searchQuery"
/>
<MagnifyingGlassIcon
class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
aria-hidden="true"
/>
</div>
<TransitionGroup
name="list"
tag="ul"
role="list"
class="space-y-1 min-h-[calc(75vh-8rem)]"
<div class="flex flex-col lg:flex-row grow">
<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"
>
<li v-for="game in filteredGames" :key="game.id" class="flex">
<NuxtLink
:to="`/library/game/${game.id}`"
class="flex flex-row items-center w-full p-1.5 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5 active:scale-[0.98]"
:class="{ 'bg-zinc-800': route.params.id === game.id }"
>
<img
:src="useObject(game.mCoverId)"
class="h-9 w-9 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-xs font-medium text-zinc-100 truncate text-left">
{{ game.mName }}
</p>
</div>
</NuxtLink>
</li>
</TransitionGroup>
<div class="fixed inset-0 bg-gray-900/80" />
</TransitionChild>
<p
v-if="games.length === 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
>
No games in library
</p>
</div>
<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">Close sidebar</span>
<XMarkIcon class="size-6 text-white" aria-hidden="true" />
</button>
</div>
</TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<LibraryDirectory />
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<!-- Static sidebar for desktop -->
<div
class="hidden lg:block lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col lg:border-r-2 lg:border-zinc-800"
>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<LibraryDirectory />
</div>
<!-- Main content area -->
<div class="flex-1 overflow-y-auto h-full no-scrollbar">
<div
class="sticky top-0 z-40 flex items-center gap-x-6 bg-white px-4 py-4 shadow-xs sm:px-6 lg:hidden"
>
<button
type="button"
class="-m-2.5 p-2.5 text-gray-700 lg:hidden"
@click="sidebarOpen = true"
>
<span class="sr-only">Open sidebar</span>
<Bars3Icon class="size-6" aria-hidden="true" />
</button>
<div class="flex-1 text-sm/6 font-semibold text-gray-900">Dashboard</div>
<a href="#">
<span class="sr-only">Your profile</span>
<img
class="size-8 rounded-full bg-gray-50"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
alt=""
/>
</a>
</div>
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6 grow">
<NuxtPage />
</div>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import { type Game, type GameVersion, type Collection } from "@prisma/client";
import { ref as vueRef } from 'vue';
import { ref } from "vue";
import {
Dialog,
DialogPanel,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import {
Bars3Icon,
CalendarIcon,
ChartPieIcon,
DocumentDuplicateIcon,
FolderIcon,
HomeIcon,
UsersIcon,
XMarkIcon,
} from "@heroicons/vue/24/outline";
const router = useRouter();
const route = useRoute();
const headers = useRequestHeaders(["cookie"]);
const { data: gamesData } = await useFetch<(Game & { versions: GameVersion[] })[]>("/api/v1/store/recent", { headers });
const games = ref(gamesData.value || []);
const selectedGame = ref<(Game & { versions: GameVersion[] }) | null>(null);
const searchQuery = ref("");
const filteredGames = computed(() => {
if (!searchQuery.value) return games.value;
const query = searchQuery.value.toLowerCase();
return games.value.filter(game =>
game.mName.toLowerCase().includes(query)
);
});
const selectedGames = computed(() => {
if (!selectedCollection.value?.entries) return [];
return selectedCollection.value.entries.map(entry => entry.game);
});
const sidebarOpen = ref(false);
useHead({
title: "Library",
@ -101,7 +121,6 @@ useHead({
<style scoped>
/* Fade transition for main content */
/* List transition animations */
.list-enter-active,
.list-leave-active {
@ -120,13 +139,13 @@ useHead({
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.hover-lift {
@ -150,4 +169,4 @@ useHead({
.list-move {
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
</style>
</style>

View File

@ -1,10 +1,10 @@
<template>
<div class="flex flex-col p-8">
<div class="flex flex-col">
<div class="max-w-2xl">
<div class="flex items-center gap-x-3 mb-4">
<NuxtLink
<NuxtLink
to="/library"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 transition-all duration-200"
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center"
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to Collections
@ -19,89 +19,32 @@
</div>
<!-- Games grid -->
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<NuxtLink
v-for="entry in collection?.entries"
:key="entry.game.id"
:to="`/library/game/${entry.game.id}`"
class="group relative h-32 rounded-lg overflow-hidden hover:scale-[1.02] transition-all duration-200"
>
<!-- Blurred banner background -->
<div class="absolute inset-0 transition-all duration-300 group-hover:scale-110">
<img
:src="useObject(entry.game.mBannerId)"
class="w-full h-full object-cover blur-[2px] brightness-[40%]"
alt=""
/>
<div class="absolute inset-0 bg-gradient-to-r from-zinc-950/60 to-transparent" />
</div>
<!-- Game content -->
<div class="relative h-full flex items-center p-6">
<div>
<h3 class="text-xl font-bold font-display text-zinc-100">
{{ entry.game.mName }}
</h3>
<p class="mt-2 text-sm text-zinc-300 line-clamp-2 max-w-xl">
{{ entry.game.mShortDescription }}
</p>
</div>
</div>
<!-- Delete button -->
<button
@click.prevent.stop="removeGameFromCollection(entry.game.id)"
class="absolute top-2 right-2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 hover:bg-zinc-700/50 transition-all duration-200 text-zinc-400 hover:text-red-400"
>
<TrashIcon class="h-5 w-5" aria-hidden="true" />
</button>
</NuxtLink>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4"
>
<GamePanel
v-for="entry in collection?.entries"
:game="entry.game"
:href="`/library/game/${entry.game.id}`"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowLeftIcon, TrashIcon } from "@heroicons/vue/20/solid";
import { type Collection, type Game, type GameVersion } from "@prisma/client";
const route = useRoute();
const headers = useRequestHeaders(["cookie"]);
// Define the type for collection with entries and full game data
type CollectionWithEntries = Collection & {
entries: {
game: Game & {
versions: GameVersion[];
};
}[];
};
// Fetch collection data with entries using the route parameter
const { data: collection } = await useFetch<CollectionWithEntries>(`/api/v1/collection/${route.params.id}`, {
headers,
transform: (collection) => {
if (!collection) return null;
return collection as CollectionWithEntries;
}
});
const removeGameFromCollection = async (gameId: string) => {
try {
await $fetch(`/api/v1/collection/${route.params.id}/entry`, {
method: 'DELETE',
body: { id: gameId }
});
// Refresh collection data after removal
const updatedCollection = await $fetch<CollectionWithEntries>(`/api/v1/collection/${route.params.id}`, { headers });
collection.value = updatedCollection;
} catch (error) {
console.error('Failed to remove game from collection:', error);
}
};
const collections = await useCollections();
const collection = computed(() =>
collections.value.find((e) => e.id == route.params.id)
);
if (collection.value === undefined) {
throw createError({ statusCode: 404, statusMessage: "Collection not found" });
}
useHead({
title: collection.value?.name || 'Collection',
title: collection.value?.name || "Collection",
});
</script>
@ -116,4 +59,4 @@ useHead({
opacity: 0;
transform: translateX(30px);
}
</style>
</style>

View File

@ -1,238 +1,94 @@
<template>
<div class="flex flex-row h-full">
<!-- Main content area -->
<div class="flex-1 overflow-y-auto h-full no-scrollbar">
<Transition name="fade" mode="out-in">
<div v-if="game" class="relative h-full">
<!-- Banner image -->
<div class="absolute top-0 h-48 inset-x-0 -z-[20]">
<img :src="useObject(game.mBannerId)" class="w-full h-48 object-cover blur-sm" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-80% to-zinc-950" />
</div>
<!-- Content -->
<div class="relative pt-12 px-8 min-h-full">
<!-- Back button -->
<div class="flex items-center gap-x-3 mb-4">
<NuxtLink
to="/library"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 transition-all duration-200"
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to Collections
</NuxtLink>
</div>
<div class="flex items-start gap-6">
<img
:src="useObject(game.mCoverId)"
class="w-32 h-auto rounded shadow-md transition-all duration-300 hover:scale-105 hover:rotate-[-2deg] hover:shadow-xl"
alt=""
/>
<div>
<h1 class="text-3xl font-bold font-display text-zinc-100">
{{ game.mName }}
</h1>
<p class="mt-2 text-lg text-zinc-400">
{{ game.mShortDescription }}
</p>
<!-- Buttons -->
<div class="mt-4 flex gap-x-3">
<button
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-blue-500/25 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Launcher
<ArrowTopRightOnSquareIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</button>
<button
@click="showAddToCollectionModal = true; gameToAddToCollection = game"
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-105 hover:shadow-lg active:scale-95"
>
Add to Collection
<PlusIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</button>
<NuxtLink
:to="`/store/${game.id}`"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-105 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
View in Store
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</Transition>
<div v-if="game" class="relative">
<!-- Banner image -->
<div class="absolute top-0 inset-0 w-full rounded overflow-hidden">
<img
:src="useObject(game.mBannerId)"
class="w-full h-full object-cover blur-sm"
/>
</div>
</div>
<!-- Add to Collection Modal -->
<TransitionRoot appear :show="showAddToCollectionModal" as="template">
<Dialog as="div" @close="showAddToCollectionModal = false" class="relative z-50">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
<!-- Content -->
<div class="relative p-4">
<!-- Back button -->
<div class="flex items-center gap-x-3 mb-4">
<NuxtLink
to="/library"
class="px-2 py-1 rounded bg-zinc-900 transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center"
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to Collections
</NuxtLink>
</div>
<div
class="flex items-start gap-6 w-fit bg-zinc-900 bg-backdrop-blur p-4 rounded-xl"
>
<div class="fixed inset-0 bg-zinc-950/80" />
</TransitionChild>
<div class="fixed inset-0 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="w-full max-w-md transform overflow-hidden rounded-xl bg-zinc-900 p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle as="h3" class="text-lg font-bold font-display text-zinc-100">
Add to Collection
</DialogTitle>
<div class="mt-4">
<div class="space-y-2">
<button
v-for="collection in collections"
:key="collection.id"
@click="addGameToCollection(game?.id!, collection.id)"
class="w-full text-left px-4 py-2 rounded-lg hover:bg-zinc-800 transition-colors duration-200 text-zinc-100"
>
{{ collection.name }}
<span class="text-sm text-zinc-500 ml-2">
{{ collection._count?.entries || 0 }} games
</span>
</button>
</div>
<p v-if="collections.length === 0" class="text-center text-zinc-500 py-4">
No collections available. Create one first!
</p>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
@click="showAddToCollectionModal = false"
class="inline-flex justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-zinc-400 hover:text-zinc-200 focus:outline-none"
>
Close
</button>
</div>
</DialogPanel>
</TransitionChild>
<img
:src="useObject(game.mCoverId)"
class="w-32 h-auto rounded shadow-md transition-all duration-300 hover:scale-105 hover:rotate-[-2deg] hover:shadow-xl"
alt=""
/>
<div>
<h1 class="text-3xl font-bold font-display text-zinc-100">
{{ game.mName }}
</h1>
<p class="mt-2 text-lg text-zinc-400">
{{ game.mShortDescription }}
</p>
<!-- Buttons -->
<div class="mt-4 flex gap-x-3">
<button
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-blue-500/25 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Launcher
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
</button>
<AddLibraryButton class="hover:scale-105" :gameId="game.id" />
<NuxtLink
:to="`/store/${game.id}`"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-105 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
View in Store
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</NuxtLink>
</div>
</div>
</div>
</Dialog>
</TransitionRoot>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon, ArrowUpRightIcon, TrashIcon, ArrowLeftIcon, PlusIcon } from "@heroicons/vue/20/solid";
import {
ArrowTopRightOnSquareIcon,
ArrowUpRightIcon,
TrashIcon,
ArrowLeftIcon,
PlusIcon,
} from "@heroicons/vue/20/solid";
import { type Game, type GameVersion, type Collection } from "@prisma/client";
import { ref as vueRef } from 'vue';
import { PlusIcon as PlusIconSolid } from "@heroicons/vue/20/solid";
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from "@headlessui/vue";
const route = useRoute();
const headers = useRequestHeaders(["cookie"]);
const { data: gamesData } = await useFetch<(Game & { versions: GameVersion[] })[]>("/api/v1/store/recent", { headers });
const games = ref(gamesData.value || []);
const { data: gamesData } = await useFetch<
(Game & { versions: GameVersion[] })[]
>("/api/v1/store/recent", { headers });
const selectedGame = ref<(Game & { versions: GameVersion[] }) | null>(null);
const collections = ref<Collection[]>([]);
const showCreateModal = ref(false);
const newCollectionName = ref("");
const showDeleteModal = ref(false);
const collectionToDelete = ref<Collection | null>(null);
const selectedCollection = ref<Collection | null>(null);
const showAddToCollectionModal = ref(false);
const gameToAddToCollection = ref<Game | null>(null);
const collections = await useCollections();
const game = collections.value
.map((e) => e.entries.map((e) => e.game))
.flat()
.find((e) => e.id == route.params.id);
// Fetch game data based on route parameter
const { data: game } = await useFetch<Game & { versions: GameVersion[] }>(
`/api/v1/games/${route.params.id}`,
{ headers }
);
const selectedGames = computed(() => {
if (!selectedCollection.value?.entries) return [];
return selectedCollection.value.entries.map(entry => entry.game);
});
// Fetch collections when component mounts
onMounted(async () => {
try {
const fetchedCollections = await $fetch<Collection[]>("/api/v1/collection", { headers });
// Sort collections to put default library first
collections.value = fetchedCollections.sort((a, b) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return 0;
});
} catch (error) {
console.error("Failed to fetch collections:", error);
}
});
useHead({
title: game.value?.mName,
});
const removeGameFromCollection = async (gameId: string, collectionId: string) => {
try {
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'DELETE',
body: { id: gameId }
});
// Update the collection's entries after removal
const updatedCollection = await $fetch<Collection>(`/api/v1/collection/${collectionId}`, { headers });
selectedCollection.value = updatedCollection;
// Refresh the collections list to update the game count
const fetchedCollections = await $fetch<Collection[]>("/api/v1/collection", { headers });
collections.value = fetchedCollections.sort((a, b) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return 0;
});
} catch (error) {
console.error('Failed to remove game from collection:', error);
}
};
const addGameToCollection = async (gameId: string, collectionId: string) => {
try {
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'POST',
body: { id: gameId }
});
// Refresh collections after adding
const fetchedCollections = await $fetch<Collection[]>("/api/v1/collection", { headers });
collections.value = fetchedCollections.sort((a, b) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return 0;
});
showAddToCollectionModal.value = false;
} catch (error) {
console.error('Failed to add game to collection:', error);
}
};
if (game === undefined)
throw createError({ statusCode: 404, statusMessage: "Game not found" });
</script>
<style scoped>
/* Fade transition for main content */
.fade-enter-active,
@ -252,7 +108,7 @@ const addGameToCollection = async (gameId: string, collectionId: string) => {
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>

View File

@ -1,569 +1,91 @@
<template>
<div class="flex flex-row h-full">
<!-- Left sidebar with game list -->
<div class="w-64 min-w-64 border-r border-zinc-800 flex flex-col min-h-[75vh] h-full">
<div class="flex-1 overflow-y-auto p-3">
<h2 class="text-lg font-semibold tracking-tight text-zinc-100 mb-3">
Your Library
</h2>
<!-- Search bar -->
<div class="relative mb-3">
<input
type="text"
name="search"
id="search"
autocomplete="off"
class="block w-full rounded-md bg-zinc-900 py-1 pl-8 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
placeholder="Search library..."
v-model="searchQuery"
/>
<MagnifyingGlassIcon
class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
aria-hidden="true"
/>
</div>
<TransitionGroup
name="list"
tag="ul"
role="list"
class="space-y-1 min-h-[calc(75vh-8rem)]"
>
<li v-for="game in filteredGames" :key="game.id" class="flex">
<button
@click="selectedGame = game"
class="flex flex-row items-center w-full p-1.5 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-[1.02] active:scale-[0.98]"
:class="{ 'bg-zinc-800': selectedGame?.id === game.id }"
>
<img
:src="useObject(game.mCoverId)"
class="h-9 w-9 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-xs font-medium text-zinc-100 truncate text-left">
{{ game.mName }}
</p>
</div>
</button>
</li>
</TransitionGroup>
<div class="flex flex-col p-8">
<div class="max-w-2xl">
<h2 class="text-2xl font-bold font-display text-zinc-100">
Your Collections
</h2>
<p class="mt-2 text-zinc-400">
Organize your games into collections for easy access.
</p>
</div>
<p
v-if="games.length === 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
>
No games in library
<!-- Collections grid -->
<TransitionGroup
name="collection-list"
tag="div"
class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
<!-- Collection buttons (wrap each in a div for grid layout) -->
<NuxtLink
v-for="collection in collections"
:key="collection.id"
:href="`/library/collection/${collection.id}`"
class="group relative rounded-lg bg-zinc-900/50 p-4 hover:bg-zinc-800/50 transition-all duration-200 text-left w-full"
>
<h3 class="text-lg font-semibold text-zinc-100">
{{ collection.name }}
</h3>
<p class="mt-1 text-sm text-zinc-400">
{{ collection.entries.length }} game(s)
</p>
</div>
</div>
<!-- Main content area -->
<div class="flex-1 overflow-y-auto h-full no-scrollbar">
<Transition name="fade" mode="out-in">
<div v-if="selectedGame" class="relative h-full">
<!-- Banner image -->
<div class="absolute top-0 h-48 inset-x-0 -z-[20]">
<img :src="useObject(selectedGame.mBannerId)" class="w-full h-48 object-cover blur-sm" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-80% to-zinc-950" />
</div>
<!-- Delete button (only show for non-default collections) -->
<button
v-if="!collection.isDefault"
@click=""
class="absolute top-1/2 -translate-y-1/2 right-2 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-zinc-700/50 transition-all duration-200"
>
<TrashIcon class="h-5 w-5 text-zinc-400 hover:text-red-400" />
</button>
</NuxtLink>
<!-- Content -->
<div class="relative pt-12 px-8 min-h-full">
<!-- Only show back button when viewing game details -->
<div v-if="selectedGame && !selectedCollection" class="flex items-center gap-x-3 mb-4">
<button
@click="selectedGame = null"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 transition-all duration-200"
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to Collections
</button>
</div>
<div class="flex items-start gap-6">
<img
:src="useObject(selectedGame.mCoverId)"
class="w-32 h-auto rounded shadow-md transition-all duration-300 hover:scale-105 hover:rotate-[-2deg] hover:shadow-xl"
alt=""
/>
<div>
<h1 class="text-3xl font-bold font-display text-zinc-100">
{{ selectedGame.mName }}
</h1>
<p class="mt-2 text-lg text-zinc-400">
{{ selectedGame.mShortDescription }}
</p>
<!-- Buttons -->
<div class="mt-4 flex gap-x-3">
<button
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-blue-500/25 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Launcher
<ArrowTopRightOnSquareIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</button>
<button
@click="showAddToCollectionModal = true; gameToAddToCollection = selectedGame"
type="button"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-105 hover:shadow-lg active:scale-95"
>
Add to Collection
<PlusIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</button>
<NuxtLink
:href="`/store/${selectedGame.id}`"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-105 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
View in Store
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" aria-hidden="true" />
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="selectedCollection" class="flex flex-col p-8">
<div class="max-w-2xl">
<div class="flex items-center gap-x-3 mb-4">
<button
@click="selectedCollection = null"
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 transition-all duration-200"
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to Collections
</button>
</div>
<h2 class="text-2xl font-bold font-display text-zinc-100">
{{ selectedCollection.name }}
</h2>
<p class="mt-2 text-zinc-400">
{{ selectedCollection.entries?.length || 0 }} games
</p>
</div>
<!-- Games grid -->
<div class="mt-8 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<button
v-for="game in selectedGames"
:key="game.id"
@click="selectedCollection = null; selectedGame = game"
class="group relative h-32 rounded-lg overflow-hidden hover:scale-[1.02] transition-all duration-200"
<!-- Create new collection button (also wrap in div) -->
<div>
<button
@click="collectionCreateOpen = true"
class="group relative rounded-lg border-2 border-dashed border-zinc-800 p-4 hover:border-zinc-700 hover:bg-zinc-900/30 transition-all duration-200 text-left w-full"
>
<div class="flex items-center gap-3">
<PlusIcon class="h-5 w-5 text-zinc-400 group-hover:text-zinc-300" />
<h3
class="text-lg font-semibold text-zinc-400 group-hover:text-zinc-300"
>
<!-- Blurred banner background -->
<div class="absolute inset-0 transition-all duration-300 group-hover:scale-110">
<img
:src="useObject(game.mBannerId)"
class="w-full h-full object-cover blur-[2px] brightness-[40%]"
alt=""
/>
<div class="absolute inset-0 bg-gradient-to-r from-zinc-950/60 to-transparent" />
</div>
<!-- Game content -->
<div class="relative h-full flex items-center p-6">
<div>
<h3 class="text-xl font-bold font-display text-zinc-100">
{{ game.mName }}
</h3>
<p class="mt-2 text-sm text-zinc-300 line-clamp-2 max-w-xl">
{{ game.mShortDescription }}
</p>
</div>
</div>
<!-- Delete button -->
<button
@click.stop="removeGameFromCollection(game.id, selectedCollection.id)"
class="absolute top-2 right-2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 hover:bg-zinc-700/50 transition-all duration-200 text-zinc-400 hover:text-red-400"
>
<TrashIcon class="h-5 w-5" aria-hidden="true" />
</button>
</button>
Create Collection
</h3>
</div>
</div>
<div v-else class="flex flex-col p-8">
<div class="max-w-2xl">
<h2 class="text-2xl font-bold font-display text-zinc-100">
Your Collections
</h2>
<p class="mt-2 text-zinc-400">
Organize your games into collections for easy access.
</p>
</div>
<!-- Collections grid -->
<TransitionGroup
name="collection-list"
tag="div"
class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
<!-- Collection buttons (wrap each in a div for grid layout) -->
<div v-for="collection in collections" :key="collection.id">
<button
@click="handleCollectionClick(collection)"
class="group relative rounded-lg bg-zinc-900/50 p-4 hover:bg-zinc-800/50 transition-all duration-200 text-left w-full"
:class="{ 'bg-zinc-800/50': selectedCollection?.id === collection.id }"
>
<h3 class="text-lg font-semibold text-zinc-100">
{{ collection.name }}
</h3>
<p class="mt-1 text-sm text-zinc-400">
{{ collection._count?.entries || 0 }} games
</p>
<!-- Delete button (only show for non-default collections) -->
<button
v-if="!collection.isDefault"
@click.stop="deleteCollection(collection)"
class="absolute top-1/2 -translate-y-1/2 right-2 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-zinc-700/50 transition-all duration-200"
>
<TrashIcon class="h-5 w-5 text-zinc-400 hover:text-red-400" />
</button>
</button>
</div>
<!-- Create new collection button (also wrap in div) -->
<div>
<button
@click="createNewCollection"
class="group relative rounded-lg border-2 border-dashed border-zinc-800 p-4 hover:border-zinc-700 hover:bg-zinc-900/30 transition-all duration-200 text-left w-full"
>
<div class="flex items-center gap-3">
<PlusIcon class="h-5 w-5 text-zinc-400 group-hover:text-zinc-300" />
<h3 class="text-lg font-semibold text-zinc-400 group-hover:text-zinc-300">
Create Collection
</h3>
</div>
<p class="mt-1 text-sm text-zinc-500">
Add a new collection to organize your games
</p>
</button>
</div>
</TransitionGroup>
</div>
</Transition>
</div>
<p class="mt-1 text-sm text-zinc-500">
Add a new collection to organize your games
</p>
</button>
</div>
</TransitionGroup>
</div>
<!-- Add a modal for creating new collections -->
<TransitionRoot appear :show="showCreateModal" as="template">
<Dialog as="div" @close="showCreateModal = false" class="relative z-50">
<div class="fixed inset-0 bg-black/30 transition-opacity duration-300"
:class="{
'opacity-0': !showCreateModal,
'opacity-100': showCreateModal
}"
/>
<div class="fixed inset-0 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<TransitionChild
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="w-full max-w-md rounded-lg bg-zinc-900 p-6">
<DialogTitle class="text-lg font-semibold text-zinc-100">
Create New Collection
</DialogTitle>
<div class="mt-4">
<input
type="text"
v-model="newCollectionName"
placeholder="Collection name"
class="w-full rounded-md bg-zinc-800 px-3 py-2 text-zinc-100 outline-none focus:ring-2 focus:ring-blue-500"
@keyup.enter="handleCreateCollection"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
@click="showCreateModal = false"
class="rounded-md px-3 py-2 text-sm text-zinc-400 hover:text-zinc-300"
>
Cancel
</button>
<button
@click="handleCreateCollection"
class="rounded-md bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-500"
:disabled="!newCollectionName"
>
Create
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
<!-- Add a modal for deleting collections -->
<TransitionRoot appear :show="showDeleteModal" as="template">
<Dialog as="div" @close="showDeleteModal = false" class="relative z-50">
<div class="fixed inset-0 bg-black/30 transition-opacity duration-300"
:class="{
'opacity-0': !showDeleteModal,
'opacity-100': showDeleteModal
}"
/>
<div class="fixed inset-0 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<TransitionChild
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="w-full max-w-md rounded-lg bg-zinc-900 p-6">
<DialogTitle class="text-lg font-semibold text-zinc-100">
Delete Collection
</DialogTitle>
<div class="mt-4">
<p class="text-zinc-400">
Are you sure you want to delete "{{ collectionToDelete?.name }}"? This action cannot be undone.
</p>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
@click="showDeleteModal = false"
class="rounded-md px-3 py-2 text-sm text-zinc-400 hover:text-zinc-300"
>
Cancel
</button>
<button
@click="handleDeleteCollection"
class="rounded-md bg-red-600 px-3 py-2 text-sm text-white hover:bg-red-500"
>
Delete
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
<!-- Add this modal at the bottom of the template -->
<TransitionRoot appear :show="showAddToCollectionModal" as="template">
<Dialog as="div" @close="showAddToCollectionModal = false" class="relative z-50">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-zinc-950/80" />
</TransitionChild>
<div class="fixed inset-0 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="w-full max-w-md transform overflow-hidden rounded-xl bg-zinc-900 p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle as="h3" class="text-lg font-bold font-display text-zinc-100">
Add to Collection
</DialogTitle>
<div class="mt-4">
<div class="space-y-2">
<button
v-for="collection in collections.filter(c => !c.isDefault)"
:key="collection.id"
@click="addGameToCollection(gameToAddToCollection?.id!, collection.id)"
class="w-full text-left px-4 py-2 rounded-lg hover:bg-zinc-800 transition-colors duration-200 text-zinc-100"
>
{{ collection.name }}
<span class="text-sm text-zinc-500 ml-2">
{{ collection._count?.entries || 0 }} games
</span>
</button>
</div>
<p v-if="collections.filter(c => !c.isDefault).length === 0" class="text-center text-zinc-500 py-4">
No collections available. Create one first!
</p>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
@click="showAddToCollectionModal = false"
class="inline-flex justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-zinc-400 hover:text-zinc-200 focus:outline-none"
>
Close
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
<CreateCollectionModal v-model="collectionCreateOpen" />
</template>
<script setup lang="ts">
import { ArrowTopRightOnSquareIcon, ArrowUpRightIcon, TrashIcon, ArrowLeftIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import { type Game, type GameVersion, type Collection } from "@prisma/client";
import { ref as vueRef } from 'vue';
import {
ArrowTopRightOnSquareIcon,
ArrowUpRightIcon,
TrashIcon,
ArrowLeftIcon,
} from "@heroicons/vue/20/solid";
import { type Game, type GameVersion } from "@prisma/client";
import { PlusIcon } from "@heroicons/vue/20/solid";
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from "@headlessui/vue";
const headers = useRequestHeaders(["cookie"]);
const { data: gamesData } = await useFetch<(Game & { versions: GameVersion[] })[]>("/api/v1/store/recent", { headers });
const { data: gamesData } = await useFetch<
(Game & { versions: GameVersion[] })[]
>("/api/v1/store/recent", { headers });
const games = ref(gamesData.value || []);
const selectedGame = ref<(Game & { versions: GameVersion[] }) | null>(null);
const searchQuery = ref("");
const collections = ref<Collection[]>([]);
const showCreateModal = ref(false);
const newCollectionName = ref("");
const showDeleteModal = ref(false);
const collectionToDelete = ref<Collection | null>(null);
const selectedCollection = ref<Collection | null>(null);
const showAddToCollectionModal = ref(false);
const gameToAddToCollection = ref<Game | null>(null);
const filteredGames = computed(() => {
if (!searchQuery.value) return games.value;
const query = searchQuery.value.toLowerCase();
return games.value.filter(game =>
game.mName.toLowerCase().includes(query)
);
});
const selectedGames = computed(() => {
if (!selectedCollection.value?.entries) return [];
return selectedCollection.value.entries.map(entry => entry.game);
});
// Fetch collections when component mounts
onMounted(async () => {
try {
const fetchedCollections = await $fetch<Collection[]>("/api/v1/collection", { headers });
// Sort collections to put default library first
collections.value = fetchedCollections.sort((a, b) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return 0;
});
} catch (error) {
console.error("Failed to fetch collections:", error);
}
});
const collections = await useCollections();
const collectionCreateOpen = ref(false);
useHead({
title: "Library",
title: "Home",
});
const handleCreateCollection = async () => {
if (!newCollectionName.value) return;
try {
const newCollection = await $fetch<Collection>("/api/v1/collection", {
method: "POST",
body: { name: newCollectionName.value },
});
collections.value.push(newCollection);
showCreateModal.value = false;
newCollectionName.value = "";
} catch (error) {
console.error("Failed to create collection:", error);
}
};
const deleteCollection = async (collection: Collection) => {
collectionToDelete.value = collection;
showDeleteModal.value = true;
};
const handleDeleteCollection = async () => {
if (!collectionToDelete.value) return;
try {
await $fetch(`/api/v1/collection/${collectionToDelete.value.id}`, { method: "DELETE" });
collections.value = collections.value.filter(c => c.id !== collectionToDelete.value?.id);
showDeleteModal.value = false;
collectionToDelete.value = null;
} catch (error) {
console.error("Failed to delete collection:", error);
}
};
const createNewCollection = () => {
showCreateModal.value = true;
};
const handleCollectionClick = async (collection: Collection) => {
try {
const fullCollection = await $fetch<Collection>(`/api/v1/collection/${collection.id}`);
selectedCollection.value = fullCollection;
selectedGame.value = null;
} catch (error) {
console.error("Failed to fetch collection details:", error);
}
};
const removeGameFromCollection = async (gameId: string, collectionId: string) => {
try {
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'DELETE',
body: { id: gameId }
});
// Update the collection's entries after removal
const updatedCollection = await $fetch<Collection>(`/api/v1/collection/${collectionId}`, { headers });
selectedCollection.value = updatedCollection;
// Refresh the collections list to update the game count
const fetchedCollections = await $fetch<Collection[]>("/api/v1/collection", { headers });
collections.value = fetchedCollections.sort((a, b) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return 0;
});
} catch (error) {
console.error('Failed to remove game from collection:', error);
}
};
const addGameToCollection = async (gameId: string, collectionId: string) => {
try {
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'POST',
body: { id: gameId }
});
// Refresh collections after adding
const fetchedCollections = await $fetch<Collection[]>("/api/v1/collection", { headers });
collections.value = fetchedCollections.sort((a, b) => {
if (a.isDefault) return -1;
if (b.isDefault) return 1;
return 0;
});
showAddToCollectionModal.value = false;
} catch (error) {
console.error('Failed to add game to collection:', error);
}
};
</script>
<style scoped>
@ -609,12 +131,12 @@ const addGameToCollection = async (gameId: string, collectionId: string) => {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>
</style>

View File

@ -31,16 +31,7 @@
:src="useObject(game.mCoverId)"
/>
<div class="flex items-center gap-x-2">
<AddLibraryButton
:gameId="game.id"
:isProcessing="isAddingToLibrary"
:isInLibrary="isInLibrary"
:collections="collections"
:collectionStates="collectionStates"
@add-to-library="addToLibrary"
@toggle-collection="toggleCollection"
@create-collection="showCreateCollectionModal = true"
/>
<AddLibraryButton :gameId="game.id" />
</div>
<NuxtLink
v-if="user?.admin"
@ -125,7 +116,9 @@
/>
</VueSlide>
<VueSlide v-if="game.mImageCarousel.length == 0">
<div class="h-48 lg:h-96 aspect-[1/2] flex items-center justify-center text-zinc-700 font-bold font-display">
<div
class="h-48 lg:h-96 aspect-[1/2] flex items-center justify-center text-zinc-700 font-bold font-display"
>
No images
</div>
</VueSlide>
@ -165,40 +158,18 @@
</div>
</div>
</div>
<!-- Add this modal at the end of the template -->
<CreateCollectionModal
:show="showCreateCollectionModal"
:gameId="game.id"
@close="showCreateCollectionModal = false"
@created="handleCollectionCreated"
/>
<!-- Delete Collection Confirmation Modal -->
<DeleteCollectionModal
:show="showDeleteModal"
:collection="collectionToDelete"
@close="showDeleteModal = false"
@deleted="handleCollectionDeleted"
/>
</div>
</template>
<script setup lang="ts">
import { IconsLinuxLogo, IconsWindowsLogo } from "#components";
import { PlusIcon, EllipsisVerticalIcon, TrashIcon, ChevronDownIcon } from "@heroicons/vue/20/solid";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
import { StarIcon, CheckIcon } from "@heroicons/vue/24/solid";
import { StarIcon } from "@heroicons/vue/24/solid";
import { type Game, type GameVersion } from "@prisma/client";
import { micromark } from "micromark";
import moment from "moment";
import { PlatformClient } from "~/composables/types";
import { ref, onMounted } from 'vue';
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
import { TransitionRoot, Dialog, DialogPanel, DialogTitle, TransitionChild } from "@headlessui/vue";
import { ref } from "vue";
import AddLibraryButton from "~/components/AddLibraryButton.vue";
import CreateCollectionModal from "~/components/CreateCollectionModal.vue";
import DeleteCollectionModal from "~/components/DeleteCollectionModal.vue";
const route = useRoute();
const gameId = route.params.id.toString();
@ -245,102 +216,6 @@ const ratingArray = Array(5)
.fill(null)
.map((_, i) => i + 1 <= rating);
const isAddingToLibrary = ref(false);
const collections = ref<Collection[]>([]);
const collectionStates = ref<{ [key: string]: boolean }>({});
const isInLibrary = ref(false);
const showCreateCollectionModal = ref(false);
const newCollectionName = ref('');
const isCreatingCollection = ref(false);
const showDeleteModal = ref(false)
const collectionToDelete = ref<Collection | null>(null)
onMounted(async () => {
try {
// Fetch collections with their entries
collections.value = await $fetch<Collection[]>('/api/v1/collection', { headers })
// Check which collections have this game
for (const collection of collections.value) {
const hasGame = collection.entries?.some(entry => entry.gameId === game.id)
collectionStates.value[collection.id] = !!hasGame
// If it's in the default collection, update isInLibrary
if (collection.isDefault && hasGame) {
isInLibrary.value = true
}
}
} catch (error) {
console.error('Failed to fetch collections:', error)
}
});
const toggleCollection = async (collectionId: string) => {
try {
if (collectionStates.value[collectionId]) {
// Remove from collection
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'DELETE',
body: { id: game.id }
})
} else {
// Add to collection
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'POST',
body: { id: game.id }
})
}
// Toggle state
collectionStates.value[collectionId] = !collectionStates.value[collectionId]
} catch (error) {
console.error('Failed to toggle collection:', error)
}
}
const addToLibrary = async () => {
if (isAddingToLibrary.value) return;
try {
isAddingToLibrary.value = true;
const defaultCollection = collections.value.find(c => c.isDefault);
if (!defaultCollection) return;
if (isInLibrary.value) {
// Remove from library
await $fetch(`/api/v1/collection/${defaultCollection.id}/entry`, {
method: "DELETE",
body: { id: game.id }
});
} else {
// Add to library
await $fetch(`/api/v1/collection/default/entry`, {
method: "POST",
body: { id: game.id }
});
}
// Toggle state
isInLibrary.value = !isInLibrary.value;
} catch (error) {
console.error("Failed to modify library:", error);
} finally {
isAddingToLibrary.value = false;
}
};
const handleCollectionCreated = async (collectionId: string) => {
// Refresh collections
collections.value = await $fetch<Collection[]>('/api/v1/collection', { headers });
// Set initial state for the new collection
collectionStates.value[collectionId] = true;
};
const handleCollectionDeleted = (collectionId: string) => {
collections.value = collections.value.filter(c => c.id !== collectionId);
collectionToDelete.value = null;
};
useHead({
title: game.mName,
});

View File

@ -1,17 +1,3 @@
<!--
This example requires some changes to your config:
```
// tailwind.config.js
module.exports = {
// ...
plugins: [
// ...
require('@tailwindcss/forms'),
],
}
```
-->
<template>
<div class="w-full flex flex-col">
<!-- Hero section -->
@ -24,7 +10,7 @@
:pauseAutoplayOnHover="true"
>
<VueSlide v-for="game in recent" :key="game.id">
<div class="w-full h-full relative overflow-hidden">
<div class="w-full h-full relative">
<div class="absolute inset-0">
<img
:src="useObject(game.mBannerId)"
@ -47,22 +33,13 @@
<p class="mt-3 text-lg text-zinc-300 line-clamp-2">
{{ game.mShortDescription }}
</p>
<div class="mt-8 gap-x-4 inline-flex items-center">
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-4">
<NuxtLink
:href="`/store/${game.id}`"
class="block w-full rounded-md border border-transparent bg-white px-8 py-3 h-15 text-base font-medium text-gray-900 hover:bg-gray-100 sm:w-auto"
class="block w-full rounded-md border border-transparent bg-white px-8 py-3 text-base font-medium text-gray-900 hover:bg-gray-100 sm:w-auto"
>Check it out</NuxtLink
>
<AddLibraryButton
:gameId="game.id"
:isProcessing="isAddingToLibrary[game.id]"
:isInLibrary="collectionStates[game.id]?.default"
:collections="collections"
:collectionStates="collectionStates[game.id] || {}"
@add-to-library="addToLibrary"
@toggle-collection="toggleCollection"
@create-collection="showCreateCollectionModal = true; selectedGame = game.id"
/>
<AddLibraryButton :gameId="game.id" />
</div>
</div>
</div>
@ -109,23 +86,11 @@
<GameCarousel :items="updated" :min="12" />
</div>
</div>
<CreateCollectionModal
:show="showCreateCollectionModal"
:gameId="selectedGame"
@close="showCreateCollectionModal = false"
@created="handleCollectionCreated"
/>
</div>
</template>
<script setup lang="ts">
import { PlusIcon, ChevronDownIcon, CheckIcon } from "@heroicons/vue/24/solid";
import { ref, onMounted } from 'vue';
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
import { TransitionRoot, Dialog, DialogPanel, DialogTitle, TransitionChild } from "@headlessui/vue";
import AddLibraryButton from '../components/AddLibraryButton.vue';
import CreateCollectionModal from '../components/CreateCollectionModal.vue';
import { ref, onMounted } from "vue";
const headers = useRequestHeaders(["cookie"]);
const recent = await $fetch("/api/v1/store/recent", { headers });
@ -136,163 +101,6 @@ const released = await $fetch("/api/v1/store/released", {
const developers = await $fetch("/api/v1/store/developers", { headers });
const publishers = await $fetch("/api/v1/store/publishers", { headers });
const collections = ref<Collection[]>([]);
const collectionStates = ref<{ [gameId: string]: { [collectionId: string]: boolean } }>({});
const showCreateCollectionModal = ref(false);
const newCollectionName = ref('');
const isCreatingCollection = ref(false);
const selectedGame = ref<string | null>(null);
const isAddingToLibrary = ref<{ [key: string]: boolean }>({});
const addGameToCollection = async (gameId: string, collectionId: string) => {
try {
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'POST',
body: { id: gameId }
})
// Update state
if (!collectionStates.value[gameId]) {
collectionStates.value[gameId] = {}
}
collectionStates.value[gameId][collectionId] = true
} catch (error) {
console.error('Failed to add game to collection:', error)
}
}
const createCollection = async () => {
if (!newCollectionName.value || isCreatingCollection.value) return
try {
isCreatingCollection.value = true
// Create collection
const response = await $fetch('/api/v1/collection', {
method: 'POST',
body: { name: newCollectionName.value }
})
// Add the game to the new collection
await $fetch(`/api/v1/collection/${response.id}/entry`, {
method: 'POST',
body: { id: selectedGame.value }
})
// Refresh collections
collections.value = await $fetch<Collection[]>('/api/v1/collection', { headers })
// Set initial state for the new collection
if (!collectionStates.value[selectedGame.value!]) {
collectionStates.value[selectedGame.value!] = {}
}
collectionStates.value[selectedGame.value!][response.id] = true
// Reset and close modal
newCollectionName.value = ''
showCreateCollectionModal.value = false
selectedGame.value = null
} catch (error) {
console.error('Failed to create collection:', error)
} finally {
isCreatingCollection.value = false
}
}
const addToLibrary = async (gameId: string) => {
if (isAddingToLibrary.value[gameId]) return;
try {
isAddingToLibrary.value[gameId] = true;
const defaultCollection = collections.value.find(c => c.isDefault);
if (!defaultCollection) return;
if (collectionStates.value[gameId]?.default) {
// Remove from library
await $fetch(`/api/v1/collection/${defaultCollection.id}/entry`, {
method: "DELETE",
body: { id: gameId }
});
} else {
// Add to library
await $fetch(`/api/v1/collection/default/entry`, {
method: "POST",
body: { id: gameId }
});
}
// Toggle state
if (!collectionStates.value[gameId]) {
collectionStates.value[gameId] = {};
}
collectionStates.value[gameId].default = !collectionStates.value[gameId]?.default;
} catch (error) {
console.error("Failed to modify library:", error);
} finally {
isAddingToLibrary.value[gameId] = false;
}
};
const toggleCollection = async (gameId: string, collectionId: string) => {
try {
if (!collectionStates.value[gameId]) {
collectionStates.value[gameId] = {};
}
if (collectionStates.value[gameId][collectionId]) {
// Remove from collection
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'DELETE',
body: { id: gameId }
});
} else {
// Add to collection
await $fetch(`/api/v1/collection/${collectionId}/entry`, {
method: 'POST',
body: { id: gameId }
});
}
// Toggle state
collectionStates.value[gameId][collectionId] = !collectionStates.value[gameId][collectionId];
} catch (error) {
console.error('Failed to toggle collection:', error);
}
};
const handleCollectionCreated = async (collectionId: string) => {
// Refresh collections
collections.value = await $fetch<Collection[]>('/api/v1/collection', { headers });
// Set initial state for the new collection
if (selectedGame.value) {
if (!collectionStates.value[selectedGame.value]) {
collectionStates.value[selectedGame.value] = {};
}
collectionStates.value[selectedGame.value][collectionId] = true;
}
// Reset selected game
selectedGame.value = null;
};
// Fetch collections on mount
onMounted(async () => {
try {
// Fetch collections with their entries
collections.value = await $fetch<Collection[]>('/api/v1/collection', { headers });
// Initialize collection states for each game
for (const game of [...recent, ...updated, ...released]) {
collectionStates.value[game.id] = {};
for (const collection of collections.value) {
const hasGame = collection.entries?.some(entry => entry.gameId === game.id);
collectionStates.value[game.id][collection.id] = !!hasGame;
}
}
} catch (error) {
console.error('Failed to fetch collections:', error);
}
});
useHead({
title: "Store",
});