mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-07-24 00:42:51 +10:00
feat: refactor & redesign parts of UI
This commit is contained in:
@@ -1,31 +1,21 @@
|
||||
<template>
|
||||
<div class="flex items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
@click="addToLibrary"
|
||||
:disabled="isProcessing"
|
||||
class="inline-flex items-center gap-x-2 rounded-l-md bg-white/10 backdrop-blur px-2.5 py-3 text-base font-semibold font-display text-white shadow-sm hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
<div class="inline-flex">
|
||||
<LoadingButton
|
||||
:loading="isLibraryLoading"
|
||||
@click="() => toggleLibrary()"
|
||||
:style="'none'"
|
||||
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
|
||||
? 'Processing...'
|
||||
: isInLibrary
|
||||
? 'Remove from Library'
|
||||
: 'Add to Library'
|
||||
}}
|
||||
<PlusIcon
|
||||
class="-mr-0.5 h-5 w-5"
|
||||
:class="[
|
||||
{ 'animate-spin': isProcessing },
|
||||
{ 'rotate-45': isInLibrary }
|
||||
]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{{ inLibrary ? "In Library" : "Add to Library" }}
|
||||
<CheckIcon v-if="inLibrary" class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||
<PlusIcon v-else class="-mr-0.5 h-5 w-5" aria-hidden="true" />
|
||||
</LoadingButton>
|
||||
|
||||
<!-- Collections dropdown -->
|
||||
<Menu as="div" class="relative">
|
||||
<MenuButton
|
||||
class="inline-flex items-center rounded-r-md border-l border-zinc-950/10 bg-white/10 backdrop-blur py-3.5 w-5 justify-center hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/20"
|
||||
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" />
|
||||
</MenuButton>
|
||||
@@ -38,42 +28,62 @@
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems class="absolute right-0 z-10 mt-2 w-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="px-3 py-2 text-sm font-semibold text-zinc-400">Collections</div>
|
||||
<div v-if="collections.filter(c => !c.isDefault).length === 0" class="px-3 py-2 text-sm text-zinc-500">
|
||||
No custom collections available
|
||||
<div
|
||||
class="font-display uppercase px-3 py-2 text-sm font-semibold text-zinc-500"
|
||||
>
|
||||
Collections
|
||||
</div>
|
||||
<MenuItem v-for="collection in collections.filter(c => !c.isDefault)" :key="collection.id" v-slot="{ active }">
|
||||
<button
|
||||
@click="toggleCollection(collection.id)"
|
||||
:class="[
|
||||
active ? 'bg-zinc-700/90' : '',
|
||||
'group flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-zinc-200'
|
||||
]"
|
||||
<div class="flex flex-col gap-y-2 py-1">
|
||||
<div
|
||||
v-if="collections.length === 0"
|
||||
class="px-3 py-2 text-sm text-zinc-500"
|
||||
>
|
||||
<span>{{ collection.name }}</span>
|
||||
<CheckIcon
|
||||
v-if="collectionStates[collection.id]"
|
||||
class="h-5 w-5 text-blue-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</MenuItem>
|
||||
<div class="border-t border-zinc-700 mt-1 pt-1">
|
||||
<button
|
||||
@click="$emit('create-collection')"
|
||||
class="group flex w-full items-center px-3 py-2 text-sm text-blue-400 hover:bg-zinc-700/90 rounded-md"
|
||||
No collections
|
||||
</div>
|
||||
<MenuItem
|
||||
v-for="(collection, collectionIdx) in collections"
|
||||
:key="collection.id"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
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>
|
||||
<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" />
|
||||
Add to new collection
|
||||
</button>
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</transition>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
<CreateCollectionModal
|
||||
v-model="createCollectionModal"
|
||||
:game-id="props.gameId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
const props = defineProps<{
|
||||
gameId: string
|
||||
isProcessing: boolean
|
||||
isInLibrary: boolean
|
||||
collections: Collection[]
|
||||
collectionStates: { [key: string]: boolean }
|
||||
gameId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-to-library': [gameId: string]
|
||||
'toggle-collection': [gameId: string, collectionId: string]
|
||||
'create-collection': []
|
||||
}>();
|
||||
const isLibraryLoading = ref(false);
|
||||
|
||||
const addToLibrary = () => {
|
||||
emit('add-to-library', props.gameId);
|
||||
};
|
||||
const createCollectionModal = ref(false);
|
||||
const collections = await useCollections();
|
||||
const library = await useLibrary();
|
||||
|
||||
const toggleCollection = (collectionId: string) => {
|
||||
emit('toggle-collection', props.gameId, collectionId);
|
||||
};
|
||||
const inLibrary = computed(
|
||||
() => 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>
|
||||
|
||||
@@ -15,34 +15,28 @@
|
||||
</template>
|
||||
|
||||
<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
|
||||
:loading="createCollectionLoading"
|
||||
:disabled="!collectionName"
|
||||
@click="() => createCollection()"
|
||||
class="inline-flex items-center rounded-md bg-white/10 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-white/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</LoadingButton>
|
||||
<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>
|
||||
</ModalTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import {
|
||||
TransitionRoot,
|
||||
TransitionChild,
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from "@headlessui/vue";
|
||||
import { DialogTitle } from "@headlessui/vue";
|
||||
import ModalTemplate from "~/drop-base/components/ModalTemplate.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -57,6 +51,7 @@ const open = defineModel<boolean>();
|
||||
|
||||
const collectionName = ref("");
|
||||
const createCollectionLoading = ref(false);
|
||||
const collections = await useCollections();
|
||||
|
||||
async function createCollection() {
|
||||
if (!collectionName.value || createCollectionLoading.value) return;
|
||||
@@ -78,6 +73,8 @@ async function createCollection() {
|
||||
});
|
||||
}
|
||||
|
||||
collections.value.push(response);
|
||||
|
||||
// Reset and emit
|
||||
collectionName.value = "";
|
||||
open.value = false;
|
||||
@@ -85,11 +82,13 @@ async function createCollection() {
|
||||
emit("created", response.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to create collection:", error);
|
||||
|
||||
const err = error as { statusMessage?: string };
|
||||
createModal(
|
||||
ModalType.Notification,
|
||||
{
|
||||
title: "Failed to create collection",
|
||||
description: `Drop couldn't create your collection: ${error}`,
|
||||
description: `Drop couldn't create your collection: ${err?.statusMessage}`,
|
||||
},
|
||||
(_, c) => c()
|
||||
);
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
<template>
|
||||
<TransitionRoot appear :show="show" as="template">
|
||||
<Dialog as="div" @close="$emit('close')" class="relative z-50">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="duration-200 ease-in"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-zinc-950/80" aria-hidden="true" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0 scale-95"
|
||||
enter-to="opacity-100 scale-100"
|
||||
leave="duration-200 ease-in"
|
||||
leave-from="opacity-100 scale-100"
|
||||
leave-to="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel class="w-full max-w-md transform overflow-hidden rounded-xl bg-zinc-900 p-6 shadow-xl transition-all">
|
||||
<DialogTitle as="h3" class="text-lg font-bold font-display text-zinc-100">
|
||||
Delete Collection
|
||||
</DialogTitle>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-zinc-400">
|
||||
Are you sure you want to delete "{{ collection?.name }}"? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-x-3">
|
||||
<button
|
||||
@click="$emit('close')"
|
||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete"
|
||||
class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-red-500"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
<ModalTemplate>
|
||||
<template #default>
|
||||
<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>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Collection } from "@prisma/client";
|
||||
import {
|
||||
TransitionRoot,
|
||||
TransitionChild,
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/vue';
|
||||
} from "@headlessui/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
collection: Collection | null
|
||||
collection: Collection | null;
|
||||
}>();
|
||||
|
||||
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 () => {
|
||||
if (!props.collection) return;
|
||||
|
||||
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
await $fetch(`/api/v1/collection/${props.collection.id}`, { method: "DELETE" });
|
||||
emit('deleted', props.collection.id);
|
||||
emit('close');
|
||||
await $fetch(`/api/v1/collection/${props.collection.id}`, {
|
||||
// @ts-ignore
|
||||
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) {
|
||||
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>
|
||||
<NuxtLink
|
||||
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"
|
||||
>
|
||||
<img :src="useObject(game.mCoverId)" class="w-full h-full object-cover" />
|
||||
@@ -37,5 +37,6 @@ const props = defineProps<{
|
||||
mName: string;
|
||||
mShortDescription: string;
|
||||
}>;
|
||||
href?: string;
|
||||
}>();
|
||||
</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>
|
||||
Reference in New Issue
Block a user