mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
squash: AdenMGB collection design & backend work
Update index.post.ts to implement saving collections functionality Update index.get.ts to verify if collection exists and if user can access it Update index.delete.ts to ask questions and not be so nonchalant Update entry.post.ts Update entry.delete.ts to do it better Update index.vue to add functionality to the add to library button + fidgit with image Update index.vue to also add add to library functionality, but no fidget :( Update entry.post.ts to infact not remove it Update index.ts Update index.vue to manage collections from store page Update index.ts to restrut for ahhhh Update index.vue too add collection control to carosel Update index.vue fix minor issue Update index.vue to fix dropdown modal bug Create library.vue for page layout Create index.vue for library game details pane Create index.vue for viewing collections pane Create DeleteCollectionModal.vue component Create CreateCollectionModal.vue component Update AddLibraryButton.vue with dropdown :D Update index.vue to use new components Update index.vue for more components :O Update entry.post.ts to not not return success, it'll figure it out Update entry.delete.ts to not return...
This commit is contained in:
@ -1,23 +1,34 @@
|
||||
<template>
|
||||
<div class="inline-flex divide-x divide-zinc-900">
|
||||
<div class="flex items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex justify-center items-center gap-x-2 rounded-l-md aspect-[7/2] px-3 py-2 bg-blue-600 grow 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="addToLibrary"
|
||||
:disabled="isProcessing"
|
||||
class="inline-flex items-center gap-x-2 rounded-l-md bg-white/10 backdrop-blur px-2.5 py-3 text-base font-semibold font-display text-white shadow-sm hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add to Library
|
||||
<PlusIcon class="-mr-0.5 size-6" aria-hidden="true" />
|
||||
{{ isProcessing
|
||||
? 'Processing...'
|
||||
: isInLibrary
|
||||
? 'Remove from Library'
|
||||
: 'Add to Library'
|
||||
}}
|
||||
<PlusIcon
|
||||
class="-mr-0.5 h-5 w-5"
|
||||
:class="[
|
||||
{ 'animate-spin': isProcessing },
|
||||
{ 'rotate-45': isInLibrary }
|
||||
]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Menu as="div" class="relative inline-block text-left grow">
|
||||
<div class="h-full">
|
||||
<MenuButton
|
||||
class="inline-flex h-full w-full justify-center items-center rounded-r-md bg-blue-600 p-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"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
|
||||
<!-- Collections dropdown -->
|
||||
<Menu as="div" class="relative">
|
||||
<MenuButton
|
||||
class="inline-flex items-center rounded-r-md border-l border-zinc-950/10 bg-white/10 backdrop-blur py-3.5 w-5 justify-center hover:bg-white/20 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"
|
||||
@ -27,61 +38,37 @@
|
||||
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-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-none"
|
||||
>
|
||||
<div class="py-1">
|
||||
<MenuItem v-slot="{ active }">
|
||||
<a
|
||||
href="#"
|
||||
<MenuItems class="absolute right-0 z-10 mt-2 w-72 origin-top-right rounded-md bg-zinc-800/90 backdrop-blur shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div class="p-2">
|
||||
<div class="px-3 py-2 text-sm font-semibold text-zinc-400">Collections</div>
|
||||
<div v-if="collections.filter(c => !c.isDefault).length === 0" class="px-3 py-2 text-sm text-zinc-500">
|
||||
No custom collections available
|
||||
</div>
|
||||
<MenuItem v-for="collection in collections.filter(c => !c.isDefault)" :key="collection.id" v-slot="{ active }">
|
||||
<button
|
||||
@click="toggleCollection(collection.id)"
|
||||
:class="[
|
||||
active
|
||||
? 'bg-gray-100 text-gray-900 outline-none'
|
||||
: 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
active ? 'bg-zinc-700/90' : '',
|
||||
'group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-zinc-200'
|
||||
]"
|
||||
>Account settings</a
|
||||
>
|
||||
<span>{{ collection.name }}</span>
|
||||
<CheckIcon
|
||||
v-if="collectionStates[collection.id]"
|
||||
class="h-5 w-5 text-blue-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<a
|
||||
href="#"
|
||||
:class="[
|
||||
active
|
||||
? 'bg-gray-100 text-gray-900 outline-none'
|
||||
: 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
]"
|
||||
>Support</a
|
||||
<div class="border-t border-zinc-700 mt-1 pt-1">
|
||||
<button
|
||||
@click="$emit('create-collection')"
|
||||
class="group flex w-full items-center px-3 py-2 text-sm text-blue-400 hover:bg-zinc-700/90 rounded-md"
|
||||
>
|
||||
</MenuItem>
|
||||
<MenuItem v-slot="{ active }">
|
||||
<a
|
||||
href="#"
|
||||
:class="[
|
||||
active
|
||||
? 'bg-gray-100 text-gray-900 outline-none'
|
||||
: 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
]"
|
||||
>License</a
|
||||
>
|
||||
</MenuItem>
|
||||
<form method="POST" action="#">
|
||||
<MenuItem v-slot="{ active }">
|
||||
<button
|
||||
type="submit"
|
||||
:class="[
|
||||
active
|
||||
? 'bg-gray-100 text-gray-900 outline-none'
|
||||
: 'text-gray-700',
|
||||
'block w-full px-4 py-2 text-left text-sm',
|
||||
]"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</MenuItem>
|
||||
</form>
|
||||
<PlusIcon class="mr-2 h-4 w-4" />
|
||||
Add to new collection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
@ -90,6 +77,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||
import { ChevronDownIcon, PlusIcon } from "@heroicons/vue/20/solid";
|
||||
import { PlusIcon, ChevronDownIcon, CheckIcon } from "@heroicons/vue/24/solid";
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
gameId: string
|
||||
isProcessing: boolean
|
||||
isInLibrary: boolean
|
||||
collections: Collection[]
|
||||
collectionStates: { [key: string]: boolean }
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-to-library': [gameId: string]
|
||||
'toggle-collection': [gameId: string, collectionId: string]
|
||||
'create-collection': []
|
||||
}>();
|
||||
|
||||
const addToLibrary = () => {
|
||||
emit('add-to-library', props.gameId);
|
||||
};
|
||||
|
||||
const toggleCollection = (collectionId: string) => {
|
||||
emit('toggle-collection', props.gameId, collectionId);
|
||||
};
|
||||
</script>
|
||||
|
||||
118
components/CreateCollectionModal.vue
Normal file
118
components/CreateCollectionModal.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<TransitionRoot appear :show="show" as="template">
|
||||
<Dialog as="div" @close="$emit('close')" 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" aria-hidden="true" />
|
||||
</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-2xl bg-zinc-900 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
|
||||
Create Collection
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
v-model="collectionName"
|
||||
placeholder="Collection name"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
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 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<LoadingButton
|
||||
:loading="isCreating"
|
||||
:disabled="!collectionName"
|
||||
@click="createCollection"
|
||||
class="inline-flex items-center rounded-md bg-white/10 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-white/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
TransitionRoot,
|
||||
TransitionChild,
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/vue';
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
gameId?: string
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
created: [collectionId: string]
|
||||
}>();
|
||||
|
||||
const collectionName = ref('');
|
||||
const isCreating = ref(false);
|
||||
|
||||
const createCollection = async () => {
|
||||
if (!collectionName.value || isCreating.value) return;
|
||||
|
||||
try {
|
||||
isCreating.value = true;
|
||||
|
||||
// Create the collection
|
||||
const response = await $fetch('/api/v1/collection', {
|
||||
method: 'POST',
|
||||
body: { name: collectionName.value }
|
||||
});
|
||||
|
||||
// Add the game if provided
|
||||
if (props.gameId) {
|
||||
await $fetch(`/api/v1/collection/${response.id}/entry`, {
|
||||
method: 'POST',
|
||||
body: { id: props.gameId }
|
||||
});
|
||||
}
|
||||
|
||||
// Reset and emit
|
||||
collectionName.value = '';
|
||||
emit('created', response.id);
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
console.error('Failed to create collection:', error);
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
88
components/DeleteCollectionModal.vue
Normal file
88
components/DeleteCollectionModal.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<TransitionRoot appear :show="show" as="template">
|
||||
<Dialog as="div" @close="$emit('close')" 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" aria-hidden="true" />
|
||||
</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 shadow-xl transition-all">
|
||||
<DialogTitle as="h3" class="text-lg font-bold font-display text-zinc-100">
|
||||
Delete Collection
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Are you sure you want to delete "{{ collection?.name }}"? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-x-3">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete"
|
||||
class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-red-500"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TransitionRoot,
|
||||
TransitionChild,
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/vue';
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
collection: Collection | null
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
deleted: [collectionId: string]
|
||||
}>();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!props.collection) return;
|
||||
|
||||
try {
|
||||
await $fetch(`/api/v1/collection/${props.collection.id}`, { method: "DELETE" });
|
||||
emit('deleted', props.collection.id);
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
console.error("Failed to delete collection:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
153
pages/library.vue
Normal file
153
pages/library.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex-1 overflow-y-auto h-full no-scrollbar">
|
||||
<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';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Library",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fade transition for main content */
|
||||
|
||||
|
||||
/* List transition animations */
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 8px 20px -6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Springy list animations */
|
||||
.list-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
</style>
|
||||
119
pages/library/collection/[id]/index.vue
Normal file
119
pages/library/collection/[id]/index.vue
Normal file
@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="flex flex-col p-8">
|
||||
<div class="max-w-2xl">
|
||||
<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>
|
||||
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
||||
{{ collection?.name }}
|
||||
</h2>
|
||||
<p class="mt-2 text-zinc-400">
|
||||
{{ collection?.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">
|
||||
<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>
|
||||
</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);
|
||||
}
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: collection.value?.name || 'Collection',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
258
pages/library/game/[id]/index.vue
Normal file
258
pages/library/game/[id]/index.vue
Normal file
@ -0,0 +1,258 @@
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Fade transition for main content */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
</style>
|
||||
620
pages/library/index.vue
Normal file
620
pages/library/index.vue
Normal file
@ -0,0 +1,620 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<!-- 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>
|
||||
</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>
|
||||
</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>
|
||||
</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 { 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 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);
|
||||
}
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Library",
|
||||
});
|
||||
|
||||
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>
|
||||
/* Fade transition for main content */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
/* List transition animations */
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Collection list transitions */
|
||||
.collection-list-enter-active,
|
||||
.collection-list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.collection-list-enter-from,
|
||||
.collection-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
</style>
|
||||
@ -30,13 +30,18 @@
|
||||
class="transition-all duration-300 hover:scale-105 hover:rotate-[-1deg] w-64 h-auto rounded"
|
||||
:src="useObject(game.mCoverId)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-md bg-blue-600 px-3.5 py-2.5 text-xl font-semibold font-display 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"
|
||||
>
|
||||
Add to Library
|
||||
<PlusIcon class="-mr-0.5 h-7 w-7" aria-hidden="true" />
|
||||
</button>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="user?.admin"
|
||||
:href="`/admin/library/${game.id}`"
|
||||
@ -160,18 +165,40 @@
|
||||
</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 } from "@heroicons/vue/20/solid";
|
||||
import { PlusIcon, EllipsisVerticalIcon, TrashIcon, ChevronDownIcon } from "@heroicons/vue/20/solid";
|
||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||
import { StarIcon } from "@heroicons/vue/24/solid";
|
||||
import { StarIcon, CheckIcon } 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 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();
|
||||
@ -218,6 +245,101 @@ 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,
|
||||
|
||||
@ -50,16 +50,19 @@
|
||||
<div class="mt-8 gap-x-4 inline-flex items-center">
|
||||
<NuxtLink
|
||||
:href="`/store/${game.id}`"
|
||||
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"
|
||||
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"
|
||||
>Check it out</NuxtLink
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-x-2 rounded-md px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm hover:bg-zinc-900/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-100"
|
||||
>
|
||||
Add to Library
|
||||
<PlusIcon class="-mr-0.5 h-7 w-7" aria-hidden="true" />
|
||||
</button>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -106,11 +109,23 @@
|
||||
<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 } from "@heroicons/vue/24/solid";
|
||||
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';
|
||||
|
||||
const headers = useRequestHeaders(["cookie"]);
|
||||
const recent = await $fetch("/api/v1/store/recent", { headers });
|
||||
@ -121,6 +136,163 @@ 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",
|
||||
});
|
||||
|
||||
@ -16,11 +16,26 @@ export default defineEventHandler(async (h3) => {
|
||||
});
|
||||
|
||||
const body = await readBody(h3);
|
||||
|
||||
const gameId = body.id;
|
||||
if (!gameId)
|
||||
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
||||
|
||||
await userLibraryManager.collectionRemove(id, gameId);
|
||||
// Verify collection exists and user owns it
|
||||
const collection = await userLibraryManager.fetchCollection(id);
|
||||
if (!collection) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Collection not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (collection.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Not authorized to modify this collection",
|
||||
});
|
||||
}
|
||||
|
||||
const removed = await userLibraryManager.collectionRemove(gameId, id);
|
||||
return {};
|
||||
});
|
||||
|
||||
@ -16,11 +16,26 @@ export default defineEventHandler(async (h3) => {
|
||||
});
|
||||
|
||||
const body = await readBody(h3);
|
||||
|
||||
const gameId = body.id;
|
||||
if (!gameId)
|
||||
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
||||
|
||||
await userLibraryManager.collectionAdd(id, gameId);
|
||||
return {};
|
||||
// Verify collection exists and user owns it
|
||||
const collection = await userLibraryManager.fetchCollection(id);
|
||||
if (!collection) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Collection not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (collection.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Not authorized to modify this collection",
|
||||
});
|
||||
}
|
||||
|
||||
await userLibraryManager.collectionAdd(gameId, id);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@ -15,6 +15,30 @@ export default defineEventHandler(async (h3) => {
|
||||
statusMessage: "ID required in route params",
|
||||
});
|
||||
|
||||
const collection = await userLibraryManager.deleteCollection(id);
|
||||
return collection;
|
||||
// Verify collection exists and user owns it
|
||||
const collection = await userLibraryManager.fetchCollection(id);
|
||||
if (!collection) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Collection not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (collection.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Not authorized to delete this collection",
|
||||
});
|
||||
}
|
||||
|
||||
// Don't allow deleting default collection
|
||||
if (collection.isDefault) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Cannot delete default collection",
|
||||
});
|
||||
}
|
||||
|
||||
await userLibraryManager.deleteCollection(id);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@ -15,6 +15,22 @@ export default defineEventHandler(async (h3) => {
|
||||
statusMessage: "ID required in route params",
|
||||
});
|
||||
|
||||
// Fetch specific collection
|
||||
const collection = await userLibraryManager.fetchCollection(id);
|
||||
if (!collection) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Collection not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user owns this collection
|
||||
if (collection.userId !== userId) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Not authorized to access this collection",
|
||||
});
|
||||
}
|
||||
|
||||
return collection;
|
||||
});
|
||||
|
||||
@ -9,11 +9,22 @@ export default defineEventHandler(async (h3) => {
|
||||
});
|
||||
|
||||
const body = await readBody(h3);
|
||||
|
||||
const gameId = body.id;
|
||||
if (!gameId)
|
||||
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
||||
|
||||
await userLibraryManager.libraryRemove(gameId, userId);
|
||||
// Get the default collection for this user
|
||||
const collections = await userLibraryManager.fetchCollections(userId);
|
||||
const defaultCollection = collections.find(c => c.isDefault);
|
||||
|
||||
if (!defaultCollection) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Default collection not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Add the game to the default collection
|
||||
await userLibraryManager.collectionAdd(gameId, defaultCollection.id);
|
||||
return {};
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@ export default defineEventHandler(async (h3) => {
|
||||
if (!name)
|
||||
throw createError({ statusCode: 400, statusMessage: "Requires name" });
|
||||
|
||||
const collections = await userLibraryManager.fetchCollections(userId);
|
||||
return collections;
|
||||
// Create the collection using the manager
|
||||
const newCollection = await userLibraryManager.collectionCreate(name, userId);
|
||||
return newCollection;
|
||||
});
|
||||
|
||||
@ -64,7 +64,13 @@ class UserLibraryManager {
|
||||
|
||||
async fetchCollections(userId: string) {
|
||||
await this.fetchUserLibrary(userId); // Ensures user library exists, doesn't have much performance impact due to caching
|
||||
return await prisma.collection.findMany({ where: { userId } });
|
||||
return await prisma.collection.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
entries: true,
|
||||
_count: { select: { entries: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async collectionAdd(gameId: string, collectionId: string) {
|
||||
|
||||
Reference in New Issue
Block a user