mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-06-22 04:11:32 +10:00
feat: refactor & redesign parts of UI
This commit is contained in:
+5
-1
@@ -66,4 +66,8 @@ $helvetica: (
|
|||||||
}
|
}
|
||||||
.carousel__pagination-button--active:hover::after {
|
.carousel__pagination-button--active:hover::after {
|
||||||
background-color: #d4d4d8;
|
background-color: #d4d4d8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.carousel__viewport {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,31 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-stretch">
|
<div class="inline-flex">
|
||||||
<button
|
<LoadingButton
|
||||||
type="button"
|
:loading="isLibraryLoading"
|
||||||
@click="addToLibrary"
|
@click="() => toggleLibrary()"
|
||||||
:disabled="isProcessing"
|
:style="'none'"
|
||||||
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"
|
class="transition inline-flex items-center gap-x-2 rounded-l-md bg-white/10 hover:bg-white/30 text-zinc-100 backdrop-blur px-5 py-3"
|
||||||
>
|
>
|
||||||
{{ isProcessing
|
{{ inLibrary ? "In Library" : "Add to Library" }}
|
||||||
? 'Processing...'
|
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||||
: isInLibrary
|
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||||
? 'Remove from Library'
|
</LoadingButton>
|
||||||
: 'Add to Library'
|
|
||||||
}}
|
|
||||||
<PlusIcon
|
|
||||||
class="-mr-0.5 h-5 w-5"
|
|
||||||
:class="[
|
|
||||||
{ 'animate-spin': isProcessing },
|
|
||||||
{ 'rotate-45': isInLibrary }
|
|
||||||
]"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Collections dropdown -->
|
<!-- Collections dropdown -->
|
||||||
<Menu as="div" class="relative">
|
<Menu as="div" class="relative">
|
||||||
<MenuButton
|
<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"
|
as="div"
|
||||||
|
class="transition cursor-pointer inline-flex items-center rounded-r-md h-full ml-[2px] bg-white/10 hover:bg-white/30 backdrop-blur py-3.5 px-2 justify-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/20"
|
||||||
>
|
>
|
||||||
<ChevronDownIcon class="h-5 w-5 text-white" aria-hidden="true" />
|
<ChevronDownIcon class="h-5 w-5 text-white" aria-hidden="true" />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
@@ -38,42 +28,62 @@
|
|||||||
leave-from-class="transform opacity-100 scale-100"
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
leave-to-class="transform opacity-0 scale-95"
|
leave-to-class="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<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">
|
<MenuItems
|
||||||
|
class="absolute right-0 z-50 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="p-2">
|
||||||
<div class="px-3 py-2 text-sm font-semibold text-zinc-400">Collections</div>
|
<div
|
||||||
<div v-if="collections.filter(c => !c.isDefault).length === 0" class="px-3 py-2 text-sm text-zinc-500">
|
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
|
||||||
No custom collections available
|
>
|
||||||
|
Collections
|
||||||
</div>
|
</div>
|
||||||
<MenuItem v-for="collection in collections.filter(c => !c.isDefault)" :key="collection.id" v-slot="{ active }">
|
<div class="flex flex-col gap-y-2 py-1">
|
||||||
<button
|
<div
|
||||||
@click="toggleCollection(collection.id)"
|
v-if="collections.length === 0"
|
||||||
:class="[
|
class="px-3 py-2 text-sm text-zinc-500"
|
||||||
active ? 'bg-zinc-700/90' : '',
|
|
||||||
'group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-zinc-200'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<span>{{ collection.name }}</span>
|
No collections
|
||||||
<CheckIcon
|
</div>
|
||||||
v-if="collectionStates[collection.id]"
|
<MenuItem
|
||||||
class="h-5 w-5 text-blue-400"
|
v-for="(collection, collectionIdx) in collections"
|
||||||
aria-hidden="true"
|
:key="collection.id"
|
||||||
/>
|
v-slot="{ active }"
|
||||||
</button>
|
>
|
||||||
</MenuItem>
|
<button
|
||||||
<div class="border-t border-zinc-700 mt-1 pt-1">
|
:class="[
|
||||||
<button
|
active ? 'bg-zinc-700/90' : '',
|
||||||
@click="$emit('create-collection')"
|
'group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-zinc-200',
|
||||||
class="group flex w-full items-center px-3 py-2 text-sm text-blue-400 hover:bg-zinc-700/90 rounded-md"
|
]"
|
||||||
|
>
|
||||||
|
<span>{{ collection.name }}</span>
|
||||||
|
<CheckIcon
|
||||||
|
v-if="inCollections[collectionIdx]"
|
||||||
|
class="h-5 w-5 text-blue-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-zinc-700 pt-1">
|
||||||
|
<LoadingButton
|
||||||
|
:loading="false"
|
||||||
|
@click="createCollectionModal = true"
|
||||||
|
class="w-full"
|
||||||
>
|
>
|
||||||
<PlusIcon class="mr-2 h-4 w-4" />
|
<PlusIcon class="mr-2 h-4 w-4" />
|
||||||
Add to new collection
|
Add to new collection
|
||||||
</button>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MenuItems>
|
</MenuItems>
|
||||||
</transition>
|
</transition>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CreateCollectionModal
|
||||||
|
v-model="createCollectionModal"
|
||||||
|
:game-id="props.gameId"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -81,24 +91,45 @@ import { PlusIcon, ChevronDownIcon, CheckIcon } from "@heroicons/vue/24/solid";
|
|||||||
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
|
import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
gameId: string
|
gameId: string;
|
||||||
isProcessing: boolean
|
|
||||||
isInLibrary: boolean
|
|
||||||
collections: Collection[]
|
|
||||||
collectionStates: { [key: string]: boolean }
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const isLibraryLoading = ref(false);
|
||||||
'add-to-library': [gameId: string]
|
|
||||||
'toggle-collection': [gameId: string, collectionId: string]
|
|
||||||
'create-collection': []
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const addToLibrary = () => {
|
const createCollectionModal = ref(false);
|
||||||
emit('add-to-library', props.gameId);
|
const collections = await useCollections();
|
||||||
};
|
const library = await useLibrary();
|
||||||
|
|
||||||
const toggleCollection = (collectionId: string) => {
|
const inLibrary = computed(
|
||||||
emit('toggle-collection', props.gameId, collectionId);
|
() => library.value.entries.findIndex((e) => e.gameId == props.gameId) != -1
|
||||||
};
|
);
|
||||||
|
const inCollections = computed(() =>
|
||||||
|
collections.value.filter(
|
||||||
|
(e) => e.entries.findIndex((e) => e.gameId == props.gameId) != -1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function toggleLibrary() {
|
||||||
|
isLibraryLoading.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch("/api/v1/collection/default/entry", {
|
||||||
|
method: inLibrary.value ? "DELETE" : "POST",
|
||||||
|
body: {
|
||||||
|
id: props.gameId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await refreshLibrary();
|
||||||
|
} catch (e: any) {
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: "Failed to add game to library",
|
||||||
|
description: `Drop couldn't add this game to your library: ${e?.statusMessage}`,
|
||||||
|
},
|
||||||
|
(_, c) => c()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isLibraryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -15,34 +15,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #buttons="{ close }">
|
<template #buttons="{ close }">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="() => 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
|
<LoadingButton
|
||||||
:loading="createCollectionLoading"
|
:loading="createCollectionLoading"
|
||||||
:disabled="!collectionName"
|
:disabled="!collectionName"
|
||||||
@click="() => createCollection()"
|
@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
|
Create
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
|
||||||
|
@click="() => close()"
|
||||||
|
ref="cancelButtonRef"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</ModalTemplate>
|
</ModalTemplate>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import {
|
import { DialogTitle } from "@headlessui/vue";
|
||||||
TransitionRoot,
|
|
||||||
TransitionChild,
|
|
||||||
Dialog,
|
|
||||||
DialogPanel,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@headlessui/vue";
|
|
||||||
import ModalTemplate from "~/drop-base/components/ModalTemplate.vue";
|
import ModalTemplate from "~/drop-base/components/ModalTemplate.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -57,6 +51,7 @@ const open = defineModel<boolean>();
|
|||||||
|
|
||||||
const collectionName = ref("");
|
const collectionName = ref("");
|
||||||
const createCollectionLoading = ref(false);
|
const createCollectionLoading = ref(false);
|
||||||
|
const collections = await useCollections();
|
||||||
|
|
||||||
async function createCollection() {
|
async function createCollection() {
|
||||||
if (!collectionName.value || createCollectionLoading.value) return;
|
if (!collectionName.value || createCollectionLoading.value) return;
|
||||||
@@ -78,6 +73,8 @@ async function createCollection() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collections.value.push(response);
|
||||||
|
|
||||||
// Reset and emit
|
// Reset and emit
|
||||||
collectionName.value = "";
|
collectionName.value = "";
|
||||||
open.value = false;
|
open.value = false;
|
||||||
@@ -85,11 +82,13 @@ async function createCollection() {
|
|||||||
emit("created", response.id);
|
emit("created", response.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create collection:", error);
|
console.error("Failed to create collection:", error);
|
||||||
|
|
||||||
|
const err = error as { statusMessage?: string };
|
||||||
createModal(
|
createModal(
|
||||||
ModalType.Notification,
|
ModalType.Notification,
|
||||||
{
|
{
|
||||||
title: "Failed to create collection",
|
title: "Failed to create collection",
|
||||||
description: `Drop couldn't create your collection: ${error}`,
|
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
|
||||||
},
|
},
|
||||||
(_, c) => c()
|
(_, c) => c()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,88 +1,88 @@
|
|||||||
<template>
|
<template>
|
||||||
<TransitionRoot appear :show="show" as="template">
|
<ModalTemplate>
|
||||||
<Dialog as="div" @close="$emit('close')" class="relative z-50">
|
<template #default>
|
||||||
<TransitionChild
|
<DialogTitle as="h3" class="text-lg font-bold font-display text-zinc-100">
|
||||||
as="template"
|
Delete Collection
|
||||||
enter="duration-300 ease-out"
|
</DialogTitle>
|
||||||
enter-from="opacity-0"
|
<div class="mt-2">
|
||||||
enter-to="opacity-100"
|
<p class="text-sm text-zinc-400">
|
||||||
leave="duration-200 ease-in"
|
Are you sure you want to delete "{{ collection?.name }}"? This action
|
||||||
leave-from="opacity-100"
|
cannot be undone.
|
||||||
leave-to="opacity-0"
|
</p>
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</Dialog>
|
</template>
|
||||||
</TransitionRoot>
|
<template #buttons="{ close }">
|
||||||
|
<button
|
||||||
|
@click="() => 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>
|
||||||
|
<LoadingButton
|
||||||
|
:loading="deleteLoading"
|
||||||
|
@click="() => handleDelete()"
|
||||||
|
class="bg-red-600 text-white hover:bg-red-500"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</LoadingButton>
|
||||||
|
</template>
|
||||||
|
</ModalTemplate>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Collection } from "@prisma/client";
|
||||||
import {
|
import {
|
||||||
TransitionRoot,
|
TransitionRoot,
|
||||||
TransitionChild,
|
TransitionChild,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPanel,
|
DialogPanel,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@headlessui/vue';
|
} from "@headlessui/vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
collection: Collection | null;
|
||||||
collection: Collection | null
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
deleted: [collectionId: string];
|
||||||
deleted: [collectionId: string]
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const open = defineModel<boolean>();
|
||||||
|
const deleteLoading = ref(false);
|
||||||
|
|
||||||
|
const collections = await useCollections();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!props.collection) return;
|
if (!props.collection) return;
|
||||||
|
|
||||||
|
deleteLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await $fetch(`/api/v1/collection/${props.collection.id}`, { method: "DELETE" });
|
await $fetch(`/api/v1/collection/${props.collection.id}`, {
|
||||||
emit('deleted', props.collection.id);
|
// @ts-ignore
|
||||||
emit('close');
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
collections.value.splice(
|
||||||
|
collections.value.findIndex((e) => e.id === props.collection?.id),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
open.value = false;
|
||||||
|
emit("deleted", props.collection.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete collection:", error);
|
console.error("Failed to delete collection:", error);
|
||||||
|
|
||||||
|
const err = error as { statusMessage?: string };
|
||||||
|
createModal(
|
||||||
|
ModalType.Notification,
|
||||||
|
{
|
||||||
|
title: "Failed to create collection",
|
||||||
|
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
|
||||||
|
},
|
||||||
|
(_, c) => c()
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
deleteLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="game"
|
v-if="game"
|
||||||
:href="`/store/${game.id}`"
|
:href="props.href ?? `/store/${game.id}`"
|
||||||
class="rounded overflow-hidden w-48 h-64 group relative transition-all duration-300 text-left"
|
class="rounded overflow-hidden w-48 h-64 group relative transition-all duration-300 text-left"
|
||||||
>
|
>
|
||||||
<img :src="useObject(game.mCoverId)" class="w-full h-full object-cover" />
|
<img :src="useObject(game.mCoverId)" class="w-full h-full object-cover" />
|
||||||
@@ -37,5 +37,6 @@ const props = defineProps<{
|
|||||||
mName: string;
|
mName: string;
|
||||||
mShortDescription: string;
|
mShortDescription: string;
|
||||||
}>;
|
}>;
|
||||||
|
href?: string;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex-1 overflow-y-auto px-4 py-5">
|
||||||
|
<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"
|
||||||
|
v-if="filteredLibrary.length > 0"
|
||||||
|
>
|
||||||
|
<li v-for="game in filteredLibrary" :key="game.id" class="flex">
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/library/game/${game.id}`"
|
||||||
|
class="flex flex-row items-center w-full p-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]"
|
||||||
|
>
|
||||||
|
<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-else
|
||||||
|
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
|
||||||
|
>
|
||||||
|
{{ !!searchQuery ? "No results" : "No games in library" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
|
||||||
|
|
||||||
|
const library = await useLibrary();
|
||||||
|
|
||||||
|
const searchQuery = ref("");
|
||||||
|
|
||||||
|
const filteredLibrary = computed(() =>
|
||||||
|
library.value.entries
|
||||||
|
.map((e) => e.game)
|
||||||
|
.filter((e) =>
|
||||||
|
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Collection, CollectionEntry, Game } from "@prisma/client";
|
||||||
|
import type { SerializeObject } from "nitropack";
|
||||||
|
|
||||||
|
type FullCollection = Collection & {
|
||||||
|
entries: Array<CollectionEntry & { game: SerializeObject<Game> }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCollections = async () => {
|
||||||
|
// @ts-expect-error
|
||||||
|
const state = useState<FullCollection[]>("collections", () => undefined);
|
||||||
|
if (state.value === undefined) {
|
||||||
|
const headers = useRequestHeaders(["cookie"]);
|
||||||
|
state.value = await $fetch<FullCollection[]>("/api/v1/collection", {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLibrary = async () => {
|
||||||
|
// @ts-expect-error
|
||||||
|
const state = useState<FullCollection>("library", () => undefined);
|
||||||
|
if (state.value === undefined) {
|
||||||
|
await refreshLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function refreshLibrary() {
|
||||||
|
const state = useState<FullCollection>("library");
|
||||||
|
const headers = useRequestHeaders(["cookie"]);
|
||||||
|
state.value = await $fetch<FullCollection>("/api/v1/collection/default", {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ useHead({
|
|||||||
title: `${statusCode ?? message} | Drop`,
|
title: `${statusCode ?? message} | Drop`,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(props.error);
|
if (import.meta.client) {
|
||||||
|
console.log(props.error);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -48,7 +50,10 @@ console.log(props.error);
|
|||||||
>
|
>
|
||||||
Oh no!
|
Oh no!
|
||||||
</h1>
|
</h1>
|
||||||
<p v-if="message" class="mt-3 font-bold text-base leading-7 text-red-500">
|
<p
|
||||||
|
v-if="message"
|
||||||
|
class="mt-3 font-bold text-base leading-7 text-red-500"
|
||||||
|
>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||||
|
|||||||
+105
-86
@@ -1,97 +1,117 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row h-full">
|
<div class="flex flex-col lg:flex-row grow">
|
||||||
<!-- Left sidebar with game list -->
|
<TransitionRoot as="template" :show="sidebarOpen">
|
||||||
<div class="w-64 min-w-64 border-r border-zinc-800 flex flex-col min-h-[75vh] h-full">
|
<Dialog class="relative z-50 lg:hidden" @close="sidebarOpen = false">
|
||||||
<div class="flex-1 overflow-y-auto p-3">
|
<TransitionChild
|
||||||
<h2 class="text-lg font-semibold tracking-tight text-zinc-100 mb-3">
|
as="template"
|
||||||
Your Library
|
enter="transition-opacity ease-linear duration-300"
|
||||||
</h2>
|
enter-from="opacity-0"
|
||||||
|
enter-to="opacity-100"
|
||||||
<!-- Search bar -->
|
leave="transition-opacity ease-linear duration-300"
|
||||||
<div class="relative mb-3">
|
leave-from="opacity-100"
|
||||||
<input
|
leave-to="opacity-0"
|
||||||
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">
|
<div class="fixed inset-0 bg-gray-900/80" />
|
||||||
<NuxtLink
|
</TransitionChild>
|
||||||
: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
|
<div class="fixed inset-0 flex">
|
||||||
v-if="games.length === 0"
|
<TransitionChild
|
||||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
|
as="template"
|
||||||
>
|
enter="transition ease-in-out duration-300 transform"
|
||||||
No games in library
|
enter-from="-translate-x-full"
|
||||||
</p>
|
enter-to="translate-x-0"
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Main content area -->
|
<div
|
||||||
<div class="flex-1 overflow-y-auto h-full no-scrollbar">
|
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 />
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
import { ref } from "vue";
|
||||||
import { type Game, type GameVersion, type Collection } from "@prisma/client";
|
import {
|
||||||
import { ref as vueRef } from 'vue';
|
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 route = useRoute();
|
||||||
const headers = useRequestHeaders(["cookie"]);
|
const sidebarOpen = ref(false);
|
||||||
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({
|
useHead({
|
||||||
title: "Library",
|
title: "Library",
|
||||||
@@ -101,7 +121,6 @@ useHead({
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
/* Fade transition for main content */
|
/* Fade transition for main content */
|
||||||
|
|
||||||
|
|
||||||
/* List transition animations */
|
/* List transition animations */
|
||||||
.list-enter-active,
|
.list-enter-active,
|
||||||
.list-leave-active {
|
.list-leave-active {
|
||||||
@@ -120,13 +139,13 @@ useHead({
|
|||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for IE, Edge and Firefox */
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
.no-scrollbar {
|
.no-scrollbar {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover-lift {
|
.hover-lift {
|
||||||
@@ -150,4 +169,4 @@ useHead({
|
|||||||
.list-move {
|
.list-move {
|
||||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col p-8">
|
<div class="flex flex-col">
|
||||||
<div class="max-w-2xl">
|
<div class="max-w-2xl">
|
||||||
<div class="flex items-center gap-x-3 mb-4">
|
<div class="flex items-center gap-x-3 mb-4">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/library"
|
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" />
|
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
Back to Collections
|
Back to Collections
|
||||||
@@ -19,89 +19,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Games grid -->
|
<!-- 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">
|
<div
|
||||||
<NuxtLink
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4"
|
||||||
v-for="entry in collection?.entries"
|
>
|
||||||
:key="entry.game.id"
|
<GamePanel
|
||||||
:to="`/library/game/${entry.game.id}`"
|
v-for="entry in collection?.entries"
|
||||||
class="group relative h-32 rounded-lg overflow-hidden hover:scale-[1.02] transition-all duration-200"
|
:game="entry.game"
|
||||||
>
|
:href="`/library/game/${entry.game.id}`"
|
||||||
<!-- 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowLeftIcon, TrashIcon } from "@heroicons/vue/20/solid";
|
import { ArrowLeftIcon, TrashIcon } from "@heroicons/vue/20/solid";
|
||||||
import { type Collection, type Game, type GameVersion } from "@prisma/client";
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const headers = useRequestHeaders(["cookie"]);
|
const collections = await useCollections();
|
||||||
|
const collection = computed(() =>
|
||||||
// Define the type for collection with entries and full game data
|
collections.value.find((e) => e.id == route.params.id)
|
||||||
type CollectionWithEntries = Collection & {
|
);
|
||||||
entries: {
|
if (collection.value === undefined) {
|
||||||
game: Game & {
|
throw createError({ statusCode: 404, statusMessage: "Collection not found" });
|
||||||
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({
|
useHead({
|
||||||
title: collection.value?.name || 'Collection',
|
title: collection.value?.name || "Collection",
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -116,4 +59,4 @@ useHead({
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(30px);
|
transform: translateX(30px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,238 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row h-full">
|
<div v-if="game" class="relative">
|
||||||
<!-- Main content area -->
|
<!-- Banner image -->
|
||||||
<div class="flex-1 overflow-y-auto h-full no-scrollbar">
|
<div class="absolute top-0 inset-0 w-full rounded overflow-hidden">
|
||||||
<Transition name="fade" mode="out-in">
|
<img
|
||||||
<div v-if="game" class="relative h-full">
|
:src="useObject(game.mBannerId)"
|
||||||
<!-- Banner image -->
|
class="w-full h-full object-cover blur-sm"
|
||||||
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add to Collection Modal -->
|
<!-- Content -->
|
||||||
<TransitionRoot appear :show="showAddToCollectionModal" as="template">
|
<div class="relative p-4">
|
||||||
<Dialog as="div" @close="showAddToCollectionModal = false" class="relative z-50">
|
<!-- Back button -->
|
||||||
<TransitionChild
|
<div class="flex items-center gap-x-3 mb-4">
|
||||||
as="template"
|
<NuxtLink
|
||||||
enter="duration-300 ease-out"
|
to="/library"
|
||||||
enter-from="opacity-0"
|
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"
|
||||||
enter-to="opacity-100"
|
>
|
||||||
leave="duration-200 ease-in"
|
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
leave-from="opacity-100"
|
Back to Collections
|
||||||
leave-to="opacity-0"
|
</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" />
|
<img
|
||||||
</TransitionChild>
|
: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"
|
||||||
<div class="fixed inset-0 overflow-y-auto">
|
alt=""
|
||||||
<div class="flex min-h-full items-center justify-center p-4 text-center">
|
/>
|
||||||
<TransitionChild
|
<div>
|
||||||
as="template"
|
<h1 class="text-3xl font-bold font-display text-zinc-100">
|
||||||
enter="duration-300 ease-out"
|
{{ game.mName }}
|
||||||
enter-from="opacity-0 scale-95"
|
</h1>
|
||||||
enter-to="opacity-100 scale-100"
|
<p class="mt-2 text-lg text-zinc-400">
|
||||||
leave="duration-200 ease-in"
|
{{ game.mShortDescription }}
|
||||||
leave-from="opacity-100 scale-100"
|
</p>
|
||||||
leave-to="opacity-0 scale-95"
|
<!-- Buttons -->
|
||||||
>
|
<div class="mt-4 flex gap-x-3">
|
||||||
<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">
|
<button
|
||||||
<DialogTitle as="h3" class="text-lg font-bold font-display text-zinc-100">
|
type="button"
|
||||||
Add to Collection
|
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"
|
||||||
</DialogTitle>
|
>
|
||||||
<div class="mt-4">
|
Open in Launcher
|
||||||
<div class="space-y-2">
|
<ArrowTopRightOnSquareIcon
|
||||||
<button
|
class="-mr-0.5 h-5 w-5"
|
||||||
v-for="collection in collections"
|
aria-hidden="true"
|
||||||
:key="collection.id"
|
/>
|
||||||
@click="addGameToCollection(game?.id!, collection.id)"
|
</button>
|
||||||
class="w-full text-left px-4 py-2 rounded-lg hover:bg-zinc-800 transition-colors duration-200 text-zinc-100"
|
<AddLibraryButton class="hover:scale-105" :gameId="game.id" />
|
||||||
>
|
<NuxtLink
|
||||||
{{ collection.name }}
|
:to="`/store/${game.id}`"
|
||||||
<span class="text-sm text-zinc-500 ml-2">
|
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"
|
||||||
{{ collection._count?.entries || 0 }} games
|
>
|
||||||
</span>
|
View in Store
|
||||||
</button>
|
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||||
</div>
|
</NuxtLink>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</div>
|
||||||
</TransitionRoot>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 route = useRoute();
|
||||||
const headers = useRequestHeaders(["cookie"]);
|
const headers = useRequestHeaders(["cookie"]);
|
||||||
const { data: gamesData } = await useFetch<(Game & { versions: GameVersion[] })[]>("/api/v1/store/recent", { headers });
|
const { data: gamesData } = await useFetch<
|
||||||
const games = ref(gamesData.value || []);
|
(Game & { versions: GameVersion[] })[]
|
||||||
|
>("/api/v1/store/recent", { headers });
|
||||||
|
|
||||||
const selectedGame = ref<(Game & { versions: GameVersion[] }) | null>(null);
|
const collections = await useCollections();
|
||||||
const collections = ref<Collection[]>([]);
|
const game = collections.value
|
||||||
const showCreateModal = ref(false);
|
.map((e) => e.entries.map((e) => e.game))
|
||||||
const newCollectionName = ref("");
|
.flat()
|
||||||
const showDeleteModal = ref(false);
|
.find((e) => e.id == route.params.id);
|
||||||
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
|
if (game === undefined)
|
||||||
const { data: game } = await useFetch<Game & { versions: GameVersion[] }>(
|
throw createError({ statusCode: 404, statusMessage: "Game not found" });
|
||||||
`/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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Fade transition for main content */
|
/* Fade transition for main content */
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
@@ -252,7 +108,7 @@ const addGameToCollection = async (gameId: string, collectionId: string) => {
|
|||||||
|
|
||||||
/* Hide scrollbar for IE, Edge and Firefox */
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
.no-scrollbar {
|
.no-scrollbar {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+72
-550
@@ -1,569 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row h-full">
|
<div class="flex flex-col p-8">
|
||||||
<!-- Left sidebar with game list -->
|
<div class="max-w-2xl">
|
||||||
<div class="w-64 min-w-64 border-r border-zinc-800 flex flex-col min-h-[75vh] h-full">
|
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
||||||
<div class="flex-1 overflow-y-auto p-3">
|
Your Collections
|
||||||
<h2 class="text-lg font-semibold tracking-tight text-zinc-100 mb-3">
|
</h2>
|
||||||
Your Library
|
<p class="mt-2 text-zinc-400">
|
||||||
</h2>
|
Organize your games into collections for easy access.
|
||||||
|
</p>
|
||||||
<!-- Search bar -->
|
</div>
|
||||||
<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
|
<!-- Collections grid -->
|
||||||
v-if="games.length === 0"
|
<TransitionGroup
|
||||||
class="text-zinc-600 text-sm font-display font-bold uppercase text-center mt-8"
|
name="collection-list"
|
||||||
>
|
tag="div"
|
||||||
No games in library
|
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>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main content area -->
|
<!-- Delete button (only show for non-default collections) -->
|
||||||
<div class="flex-1 overflow-y-auto h-full no-scrollbar">
|
<button
|
||||||
<Transition name="fade" mode="out-in">
|
v-if="!collection.isDefault"
|
||||||
<div v-if="selectedGame" class="relative h-full">
|
@click=""
|
||||||
<!-- Banner image -->
|
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"
|
||||||
<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" />
|
<TrashIcon class="h-5 w-5 text-zinc-400 hover:text-red-400" />
|
||||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-80% to-zinc-950" />
|
</button>
|
||||||
</div>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Create new collection button (also wrap in div) -->
|
||||||
<div class="relative pt-12 px-8 min-h-full">
|
<div>
|
||||||
<!-- Only show back button when viewing game details -->
|
<button
|
||||||
<div v-if="selectedGame && !selectedCollection" class="flex items-center gap-x-3 mb-4">
|
@click="collectionCreateOpen = true"
|
||||||
<button
|
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"
|
||||||
@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"
|
<div class="flex items-center gap-3">
|
||||||
>
|
<PlusIcon class="h-5 w-5 text-zinc-400 group-hover:text-zinc-300" />
|
||||||
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
|
<h3
|
||||||
Back to Collections
|
class="text-lg font-semibold text-zinc-400 group-hover:text-zinc-300"
|
||||||
</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 -->
|
Create Collection
|
||||||
<div class="absolute inset-0 transition-all duration-300 group-hover:scale-110">
|
</h3>
|
||||||
<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>
|
<p class="mt-1 text-sm text-zinc-500">
|
||||||
<div v-else class="flex flex-col p-8">
|
Add a new collection to organize your games
|
||||||
<div class="max-w-2xl">
|
</p>
|
||||||
<h2 class="text-2xl font-bold font-display text-zinc-100">
|
</button>
|
||||||
Your Collections
|
</div>
|
||||||
</h2>
|
</TransitionGroup>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Add a modal for creating new collections -->
|
<CreateCollectionModal v-model="collectionCreateOpen" />
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowTopRightOnSquareIcon, ArrowUpRightIcon, TrashIcon, ArrowLeftIcon } from "@heroicons/vue/20/solid";
|
import {
|
||||||
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
|
ArrowTopRightOnSquareIcon,
|
||||||
import { type Game, type GameVersion, type Collection } from "@prisma/client";
|
ArrowUpRightIcon,
|
||||||
import { ref as vueRef } from 'vue';
|
TrashIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
} from "@heroicons/vue/20/solid";
|
||||||
|
import { type Game, type GameVersion } from "@prisma/client";
|
||||||
import { PlusIcon } from "@heroicons/vue/20/solid";
|
import { PlusIcon } from "@heroicons/vue/20/solid";
|
||||||
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from "@headlessui/vue";
|
|
||||||
|
|
||||||
const headers = useRequestHeaders(["cookie"]);
|
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 games = ref(gamesData.value || []);
|
||||||
|
|
||||||
const selectedGame = ref<(Game & { versions: GameVersion[] }) | null>(null);
|
const collections = await useCollections();
|
||||||
const searchQuery = ref("");
|
const collectionCreateOpen = ref(false);
|
||||||
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({
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -609,12 +131,12 @@ const addGameToCollection = async (gameId: string, collectionId: string) => {
|
|||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for IE, Edge and Firefox */
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
.no-scrollbar {
|
.no-scrollbar {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+6
-131
@@ -31,16 +31,7 @@
|
|||||||
:src="useObject(game.mCoverId)"
|
:src="useObject(game.mCoverId)"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<AddLibraryButton
|
<AddLibraryButton :gameId="game.id" />
|
||||||
:gameId="game.id"
|
|
||||||
:isProcessing="isAddingToLibrary"
|
|
||||||
:isInLibrary="isInLibrary"
|
|
||||||
:collections="collections"
|
|
||||||
:collectionStates="collectionStates"
|
|
||||||
@add-to-library="addToLibrary"
|
|
||||||
@toggle-collection="toggleCollection"
|
|
||||||
@create-collection="showCreateCollectionModal = true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="user?.admin"
|
v-if="user?.admin"
|
||||||
@@ -125,7 +116,9 @@
|
|||||||
/>
|
/>
|
||||||
</VueSlide>
|
</VueSlide>
|
||||||
<VueSlide v-if="game.mImageCarousel.length == 0">
|
<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
|
No images
|
||||||
</div>
|
</div>
|
||||||
</VueSlide>
|
</VueSlide>
|
||||||
@@ -165,40 +158,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 { type Game, type GameVersion } from "@prisma/client";
|
||||||
import { micromark } from "micromark";
|
import { micromark } from "micromark";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { PlatformClient } from "~/composables/types";
|
import { PlatformClient } from "~/composables/types";
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref } 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 AddLibraryButton from "~/components/AddLibraryButton.vue";
|
||||||
import CreateCollectionModal from "~/components/CreateCollectionModal.vue";
|
|
||||||
import DeleteCollectionModal from "~/components/DeleteCollectionModal.vue";
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const gameId = route.params.id.toString();
|
const gameId = route.params.id.toString();
|
||||||
@@ -245,102 +216,6 @@ const ratingArray = Array(5)
|
|||||||
.fill(null)
|
.fill(null)
|
||||||
.map((_, i) => i + 1 <= rating);
|
.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({
|
useHead({
|
||||||
title: game.mName,
|
title: game.mName,
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-197
@@ -1,17 +1,3 @@
|
|||||||
<!--
|
|
||||||
This example requires some changes to your config:
|
|
||||||
|
|
||||||
```
|
|
||||||
// tailwind.config.js
|
|
||||||
module.exports = {
|
|
||||||
// ...
|
|
||||||
plugins: [
|
|
||||||
// ...
|
|
||||||
require('@tailwindcss/forms'),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
-->
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full flex flex-col">
|
<div class="w-full flex flex-col">
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
@@ -24,7 +10,7 @@
|
|||||||
:pauseAutoplayOnHover="true"
|
:pauseAutoplayOnHover="true"
|
||||||
>
|
>
|
||||||
<VueSlide v-for="game in recent" :key="game.id">
|
<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">
|
<div class="absolute inset-0">
|
||||||
<img
|
<img
|
||||||
:src="useObject(game.mBannerId)"
|
:src="useObject(game.mBannerId)"
|
||||||
@@ -47,22 +33,13 @@
|
|||||||
<p class="mt-3 text-lg text-zinc-300 line-clamp-2">
|
<p class="mt-3 text-lg text-zinc-300 line-clamp-2">
|
||||||
{{ game.mShortDescription }}
|
{{ game.mShortDescription }}
|
||||||
</p>
|
</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
|
<NuxtLink
|
||||||
:href="`/store/${game.id}`"
|
: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
|
>Check it out</NuxtLink
|
||||||
>
|
>
|
||||||
<AddLibraryButton
|
<AddLibraryButton :gameId="game.id" />
|
||||||
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,23 +86,11 @@
|
|||||||
<GameCarousel :items="updated" :min="12" />
|
<GameCarousel :items="updated" :min="12" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateCollectionModal
|
|
||||||
:show="showCreateCollectionModal"
|
|
||||||
:gameId="selectedGame"
|
|
||||||
@close="showCreateCollectionModal = false"
|
|
||||||
@created="handleCollectionCreated"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { PlusIcon, ChevronDownIcon, CheckIcon } from "@heroicons/vue/24/solid";
|
import { ref, onMounted } from "vue";
|
||||||
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 headers = useRequestHeaders(["cookie"]);
|
||||||
const recent = await $fetch("/api/v1/store/recent", { headers });
|
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 developers = await $fetch("/api/v1/store/developers", { headers });
|
||||||
const publishers = await $fetch("/api/v1/store/publishers", { 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({
|
useHead({
|
||||||
title: "Store",
|
title: "Store",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,22 +20,15 @@ export default defineEventHandler(async (h3) => {
|
|||||||
if (!gameId)
|
if (!gameId)
|
||||||
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
||||||
|
|
||||||
// Verify collection exists and user owns it
|
const successful = await userLibraryManager.collectionRemove(
|
||||||
const collection = await userLibraryManager.fetchCollection(id);
|
gameId,
|
||||||
if (!collection) {
|
id,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
if (!successful)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: "Collection not found",
|
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 {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,22 +20,6 @@ export default defineEventHandler(async (h3) => {
|
|||||||
if (!gameId)
|
if (!gameId)
|
||||||
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
||||||
|
|
||||||
// Verify collection exists and user owns it
|
await userLibraryManager.collectionAdd(gameId, id, userId);
|
||||||
const collection = await userLibraryManager.fetchCollection(id);
|
return;
|
||||||
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 };
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,28 +16,19 @@ export default defineEventHandler(async (h3) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Verify collection exists and user owns it
|
// Verify collection exists and user owns it
|
||||||
|
// Will not return the default collection
|
||||||
const collection = await userLibraryManager.fetchCollection(id);
|
const collection = await userLibraryManager.fetchCollection(id);
|
||||||
if (!collection) {
|
if (!collection)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: "Collection not found",
|
statusMessage: "Collection not found",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (collection.userId !== userId) {
|
if (collection.userId !== userId)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: "Not authorized to delete this collection",
|
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);
|
await userLibraryManager.deleteCollection(id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -16,21 +16,20 @@ export default defineEventHandler(async (h3) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch specific collection
|
// Fetch specific collection
|
||||||
|
// Will not return the default collection
|
||||||
const collection = await userLibraryManager.fetchCollection(id);
|
const collection = await userLibraryManager.fetchCollection(id);
|
||||||
if (!collection) {
|
if (!collection)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: "Collection not found",
|
statusMessage: "Collection not found",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user owns this collection
|
// Verify user owns this collection
|
||||||
if (collection.userId !== userId) {
|
if (collection.userId !== userId)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: "Not authorized to access this collection",
|
statusMessage: "Not authorized to access this collection",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return collection;
|
return collection;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,18 +13,7 @@ export default defineEventHandler(async (h3) => {
|
|||||||
if (!gameId)
|
if (!gameId)
|
||||||
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
throw createError({ statusCode: 400, statusMessage: "Game ID required" });
|
||||||
|
|
||||||
// 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
|
// Add the game to the default collection
|
||||||
await userLibraryManager.collectionAdd(gameId, defaultCollection.id);
|
await userLibraryManager.libraryAdd(gameId, userId);
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import userLibraryManager from "~/server/internal/userlibrary";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const userId = await h3.context.session.getUserId(h3);
|
||||||
|
if (!userId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: "Requires authentication",
|
||||||
|
});
|
||||||
|
|
||||||
|
const collection = await userLibraryManager.fetchLibrary(userId);
|
||||||
|
|
||||||
|
return collection;
|
||||||
|
});
|
||||||
@@ -37,12 +37,12 @@ class UserLibraryManager {
|
|||||||
|
|
||||||
async libraryAdd(gameId: string, userId: string) {
|
async libraryAdd(gameId: string, userId: string) {
|
||||||
const userLibraryId = await this.fetchUserLibrary(userId);
|
const userLibraryId = await this.fetchUserLibrary(userId);
|
||||||
await this.collectionAdd(gameId, userLibraryId);
|
await this.collectionAdd(gameId, userLibraryId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async libraryRemove(gameId: string, userId: string) {
|
async libraryRemove(gameId: string, userId: string) {
|
||||||
const userLibraryId = await this.fetchUserLibrary(userId);
|
const userLibraryId = await this.fetchUserLibrary(userId);
|
||||||
await this.collectionRemove(gameId, userLibraryId);
|
await this.collectionRemove(gameId, userLibraryId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchLibrary(userId: string) {
|
async fetchLibrary(userId: string) {
|
||||||
@@ -55,31 +55,38 @@ class UserLibraryManager {
|
|||||||
return userLibrary;
|
return userLibrary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Will not return the default library
|
||||||
async fetchCollection(collectionId: string) {
|
async fetchCollection(collectionId: string) {
|
||||||
return await prisma.collection.findUnique({
|
return await prisma.collection.findUnique({
|
||||||
where: { id: collectionId },
|
where: { id: collectionId, isDefault: false },
|
||||||
include: { entries: { include: { game: true } } },
|
include: { entries: { include: { game: true } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchCollections(userId: string) {
|
async fetchCollections(userId: string) {
|
||||||
await this.fetchUserLibrary(userId); // Ensures user library exists, doesn't have much performance impact due to caching
|
await this.fetchUserLibrary(userId); // Ensures user library exists, doesn't have much performance impact due to caching
|
||||||
return await prisma.collection.findMany({
|
return await prisma.collection.findMany({
|
||||||
where: { userId },
|
where: { userId, isDefault: false },
|
||||||
include: {
|
include: {
|
||||||
entries: true,
|
entries: {
|
||||||
_count: { select: { entries: true } }
|
include: {
|
||||||
}
|
game: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectionAdd(gameId: string, collectionId: string) {
|
async collectionAdd(gameId: string, collectionId: string, userId: string) {
|
||||||
await prisma.collectionEntry.upsert({
|
await prisma.collectionEntry.upsert({
|
||||||
where: {
|
where: {
|
||||||
collectionId_gameId: {
|
collectionId_gameId: {
|
||||||
collectionId,
|
collectionId,
|
||||||
gameId,
|
gameId,
|
||||||
},
|
},
|
||||||
|
collection: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -89,7 +96,7 @@ class UserLibraryManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectionRemove(gameId: string, collectionId: string) {
|
async collectionRemove(gameId: string, collectionId: string, userId: string) {
|
||||||
// Delete if exists
|
// Delete if exists
|
||||||
return (
|
return (
|
||||||
(
|
(
|
||||||
@@ -97,6 +104,9 @@ class UserLibraryManager {
|
|||||||
where: {
|
where: {
|
||||||
collectionId,
|
collectionId,
|
||||||
gameId,
|
gameId,
|
||||||
|
collection: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).count > 0
|
).count > 0
|
||||||
@@ -109,6 +119,13 @@ class UserLibraryManager {
|
|||||||
name,
|
name,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
entries: {
|
||||||
|
include: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +133,7 @@ class UserLibraryManager {
|
|||||||
await prisma.collection.delete({
|
await prisma.collection.delete({
|
||||||
where: {
|
where: {
|
||||||
id: collectionId,
|
id: collectionId,
|
||||||
|
isDefault: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user