feat: account pages framework & updates to library

This commit is contained in:
DecDuck
2025-04-01 18:28:34 +11:00
parent d7297707d7
commit 17372a9c06
15 changed files with 298 additions and 75 deletions

View File

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

View File

@ -1,10 +1,10 @@
<template>
<div class="inline-flex group hover:scale-105 transition-all duration-200">
<div class="inline-flex w-full group hover:scale-105 transition-all duration-200">
<LoadingButton
:loading="isLibraryLoading"
@click="() => toggleLibrary()"
:style="'none'"
class="transition w-48 inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
class="transition w-full inline-flex items-center justify-center h-full gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95"
>
{{ inLibrary ? "In Library" : "Add to Library" }}
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
@ -115,22 +115,13 @@ const inCollections = computed(() =>
async function toggleLibrary() {
isLibraryLoading.value = true;
try {
const method = inLibrary.value ? "DELETE" : "POST";
await $dropFetch("/api/v1/collection/default/entry", {
method,
method: inLibrary.value ? "DELETE" : "POST",
body: {
id: props.gameId,
},
});
if (method == "DELETE") {
// In place remove
library.value.entries.splice(
library.value.entries.findIndex((e) => e.gameId == props.gameId),
1
);
} else {
await refreshLibrary();
}
await refreshLibrary();
} catch (e: any) {
createModal(
ModalType.Notification,
@ -147,26 +138,18 @@ async function toggleLibrary() {
async function toggleCollection(id: string) {
try {
const collectionIndex = collections.value.findIndex((e) => e.id == id);
if (collectionIndex == -1) return;
const index = collections.value[collectionIndex].entries.findIndex(
(e) => e.gameId == props.gameId
);
const collection = collections.value.find((e) => e.id == id);
if (!collection) return;
const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
const method = index == -1 ? "POST" : "DELETE";
await $dropFetch(`/api/v1/collection/${id}/entry`, {
method,
method: index == -1 ? "POST" : "DELETE",
body: {
id: props.gameId,
},
});
if (method == "DELETE") {
collections.value[collectionIndex].entries.splice(index, 1);
} else {
// We HAVE to refresh because we need to pull game data
await refreshCollection(id);
}
await refreshCollection(id);
} catch (e: any) {
createModal(
ModalType.Notification,
@ -176,6 +159,7 @@ async function toggleCollection(id: string) {
},
(_, c) => c()
);
} finally {
}
}
</script>

View File

@ -1,17 +1,22 @@
<template>
<VueCarousel :itemsToShow="moveAmount" :itemsToScroll="moveAmount / 2">
<VueSlide
class="justify-start"
v-for="(game, gameIdx) in games"
:key="gameIdx"
>
<GamePanel :game="game" />
</VueSlide>
<div ref="currentComponent">
{{ singlePage }}
<ClientOnly>
<VueCarousel :itemsToShow="singlePage" :itemsToScroll="singlePage">
<VueSlide
class="justify-start"
v-for="(game, gameIdx) in games"
:key="gameIdx"
>
<GamePanel :game="game" />
</VueSlide>
<template #addons>
<VueNavigation />
</template>
</VueCarousel>
<template #addons>
<VueNavigation />
</template>
</VueCarousel>
</ClientOnly>
</div>
</template>
<script setup lang="ts">
@ -21,8 +26,11 @@ import type { SerializeObject } from "nitropack";
const props = defineProps<{
items: Array<SerializeObject<Game>>;
min?: number;
width?: number;
}>();
const currentComponent = ref<HTMLDivElement>();
const min = computed(() => Math.max(props.min ?? 8, props.items.length));
const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() =>
Array(min.value)
@ -30,10 +38,13 @@ const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() =>
.map((_, i) => props.items[i])
);
const moveAmount = ref(1);
const moveFactor = 1.8 / 400;
const singlePage = ref(1);
const sizeOfCard = 192 + 10;
onMounted(() => {
moveAmount.value = moveFactor * window.innerWidth;
singlePage.value =
(props.width ??
currentComponent.value?.parentElement?.clientWidth ??
window.innerWidth) / sizeOfCard;
});
</script>

View File

@ -35,12 +35,12 @@
import type { SerializeObject } from "nitropack";
const props = defineProps<{
game?: SerializeObject<{
game: SerializeObject<{
id: string;
mCoverId: string;
mName: string;
mShortDescription: string;
}>;
}> | undefined;
href?: string;
}>();
</script>

View File

@ -1,22 +1,22 @@
<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>
<div class="flex grow flex-col overflow-y-auto px-6 py-4">
<span class="inline-flex items-center gap-x-2 font-semibold text-zinc-100">
<Bars3Icon class="size-6" /> Library
</span>
<!-- Search bar -->
<div class="relative mb-3">
<div class="mt-5 relative">
<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"
class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600"
placeholder="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"
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-400"
aria-hidden="true"
/>
</div>
@ -25,13 +25,13 @@
name="list"
tag="ul"
role="list"
class="space-y-1"
class="mt-2 space-y-0.5"
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-105 hover:shadow-lg active:scale-95"
class="flex flex-row items-center w-full p-1 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
>
<img
:src="useObject(game.mCoverId)"
@ -39,7 +39,7 @@
alt=""
/>
<div class="min-w-0 flex-1 pl-2.5">
<p class="text-xs font-medium text-zinc-100 truncate text-left">
<p class="text-sm font-semibold text-display text-zinc-200 truncate text-left">
{{ game.mName }}
</p>
</div>
@ -57,7 +57,8 @@
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
import { HomeIcon } from "@heroicons/vue/24/outline";
import { Bars3Icon, MagnifyingGlassIcon } from "@heroicons/vue/24/solid";
const library = await useLibrary();

121
pages/account.vue Normal file
View File

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

1
pages/account/index.vue Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<template>
<div class="flex flex-col lg:flex-row grow">
<div class="flex flex-col lg:flex-row">
<TransitionRoot as="template" :show="sidebarOpen">
<Dialog class="relative z-50 lg:hidden" @close="sidebarOpen = false">
<TransitionChild

View File

@ -7,7 +7,7 @@
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to Collections
Back to Library
</NuxtLink>
</div>
<h2 class="text-2xl font-bold font-display text-zinc-100">

View File

@ -3,13 +3,13 @@
class="mx-auto w-full relative flex flex-col justify-center pt-72 overflow-hidden"
>
<!-- Banner background with gradient overlays -->
<div class="absolute inset-0 z-0">
<div class="absolute inset-0 z-0 rounded-xl overflow-hidden">
<img
:src="useObject(game.mBannerId)"
class="w-full h-[24rem] object-cover blur-sm scale-105"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-zinc-900/80 to-transparent opacity-90"
class="absolute inset-0 bg-gradient-to-t from-zinc-900 to-transparent opacity-90"
/>
<div
class="absolute inset-0 bg-gradient-to-r from-zinc-900/95 via-zinc-900/80 to-transparent opacity-90"
@ -17,28 +17,28 @@
</div>
<div class="relative z-10">
<div class="px-8 pb-4">
<div class="px-4 sm:px-8pb-4">
<div class="flex items-center gap-x-3 mb-4">
<NuxtLink
to="/library"
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
>
<ArrowLeftIcon class="h-4 w-4" aria-hidden="true" />
Back to Collections
Back to Library
</NuxtLink>
</div>
<!-- Game title and description -->
<h1
class="text-5xl text-zinc-100 font-bold font-display drop-shadow-lg"
class="text-3xl sm:text-5xl text-zinc-100 font-bold font-display drop-shadow-lg"
>
{{ game.mName }}
</h1>
<p class="mt-4 mb-8 text-lg text-zinc-400 max-w-3xl">
<p class="mt-4 mb-8 text-sm sm:text-lg text-zinc-400 max-w-3xl">
{{ game.mShortDescription }}
</p>
<div class="flex flex-col lg:flex-row gap-3">
<div class="flex items-stretch flex-col lg:flex-row gap-3">
<button
type="button"
class="inline-flex items-center justify-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"
@ -63,8 +63,8 @@
</div>
<!-- Main content -->
<div class="w-full bg-zinc-900 px-8 py-6">
<div class="mt-8 flex flex-col gap-10">
<div class="w-full bg-zinc-900 px-4 sm:px-8 py-3 sm:py-6">
<div class="mt-8 flex flex-col gap-5 sm:gap-10">
<div class="col-start-1 lg:col-start-2 space-y-6">
<div class="bg-zinc-800/50 rounded-xl p-6 backdrop-blur-sm">
<h2 class="text-xl font-display font-semibold text-zinc-100 mb-4">

View File

@ -1,11 +1,10 @@
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-y-8">
<div class="max-w-2xl">
<h2 class="text-2xl font-bold font-display text-zinc-100">
Your Collections
</h2>
<h2 class="text-2xl font-bold font-display text-zinc-100">Library</h2>
<p class="mt-2 text-zinc-400">
Organize your games into collections for easy access.
Organize your games into collections for easy access, and access all
your games.
</p>
</div>
@ -13,16 +12,16 @@
<TransitionGroup
name="collection-list"
tag="div"
class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
<!-- Collection buttons (wrap each in a div for grid layout) -->
<div
v-for="collection in collections"
:key="collection.id"
class="flex flex-row rounded-lg overflow-hidden transition-all duration-200 text-left w-full hover:scale-105"
class="flex flex-row rounded-lg overflow-hidden transition-all duration-200 text-left w-full hover:scale-105 focus:scale-105"
>
<NuxtLink
class="grow p-4 bg-zinc-800/50 hover:bg-zinc-800"
class="grow p-4 bg-zinc-800/50 hover:bg-zinc-800 focus:bg-zinc-800 focus:outline-none"
:href="`/library/collection/${collection.id}`"
>
<h3 class="text-lg font-semibold text-zinc-100">
@ -36,7 +35,7 @@
<!-- Delete button (only show for non-default collections) -->
<button
@click="() => (currentlyDeleting = collection)"
class="group px-3 ml-[2px] bg-zinc-800/50 hover:bg-zinc-800 group"
class="group px-3 ml-[2px] bg-zinc-800/50 hover:bg-zinc-800 group focus:bg-zinc-800 focus:outline-none"
>
<TrashIcon
class="transition-all size-5 text-zinc-400 group-hover:text-red-400 group-hover:rotate-[8deg]"
@ -70,6 +69,20 @@
</button>
</div>
</TransitionGroup>
<!-- game library grid -->
<div>
<h1 class="text-zinc-100 text-xl font-bold font-display">
All Games
</h1>
<div class="mt-4 flex flex-row flex-wrap justify-left gap-4">
<GamePanel
v-for="game in games"
:game="game"
:href="`/library/game/${game?.id}`"
/>
</div>
</div>
</div>
<CreateCollectionModal v-model="collectionCreateOpen" />
@ -91,6 +104,9 @@ const collectionCreateOpen = ref(false);
const currentlyDeleting = ref<Collection | undefined>();
const library = await useLibrary();
const games = library.value.entries.map((e) => e.game);
useHead({
title: "Home",
});

View File

@ -29,7 +29,7 @@ export default defineEventHandler(async (h3) => {
const description = options.description;
const gameId = options.id;
if (!(id || name || description)) {
if (!id || !name || !description) {
dump();
throw createError({