mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add collections - icon support, edit dialog, full CRUD
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import { Aperture, FolderOpen, Plus } from 'lucide-vue-next'
|
||||
|
||||
function getCollectionIcon(iconName: string | null) {
|
||||
if (iconName) {
|
||||
const found = (Icons as Record<string, unknown>)[iconName]
|
||||
if (found) return found
|
||||
}
|
||||
return FolderOpen
|
||||
}
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
@@ -197,7 +205,11 @@ onMounted(() => {
|
||||
class="gap-2.5"
|
||||
@click="router.push({ name: 'collection', params: { id: collection.id } })"
|
||||
>
|
||||
<FolderOpen :size="15" :class="activeCollectionId === collection.id ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'" />
|
||||
<component
|
||||
:is="getCollectionIcon(collection.icon)"
|
||||
:size="15"
|
||||
:class="activeCollectionId === collection.id ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'"
|
||||
/>
|
||||
<span :class="activeCollectionId === collection.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
|
||||
{{ collection.name }}
|
||||
</span>
|
||||
@@ -214,7 +226,6 @@ onMounted(() => {
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useCollections } from '../composables/useCollections'
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
@@ -11,6 +12,7 @@ const router = useRouter()
|
||||
const { createCollection } = useCollections()
|
||||
|
||||
const name = ref('')
|
||||
const icon = ref('')
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -19,8 +21,9 @@ async function submit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const collection = await createCollection(name.value.trim())
|
||||
const collection = await createCollection(name.value.trim(), icon.value.trim() || undefined)
|
||||
name.value = ''
|
||||
icon.value = ''
|
||||
emit('close')
|
||||
router.push({ name: 'collection', params: { id: collection.id } })
|
||||
} catch {
|
||||
@@ -55,6 +58,11 @@ async function submit() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-foreground"> Icon <span class="text-muted-foreground font-normal">(optional)</span> </label>
|
||||
<IconPicker v-model="icon" placeholder="Choose an icon..." />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-1">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useCollections } from '../composables/useCollections'
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
import type { Collection } from '@projectx/types'
|
||||
|
||||
const props = defineProps<{ open: boolean; collection: Collection }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const { updateCollection } = useCollections()
|
||||
|
||||
const name = ref('')
|
||||
const icon = ref('')
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
name.value = props.collection.name
|
||||
icon.value = props.collection.icon ?? ''
|
||||
error.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (!name.value.trim()) return
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await updateCollection(props.collection.id, name.value.trim(), icon.value)
|
||||
emit('close')
|
||||
} catch {
|
||||
error.value = 'Failed to update collection'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="emit('close')" />
|
||||
<div class="relative z-10 w-full max-w-md mx-4 bg-card border border-border rounded-xl shadow-2xl p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-base font-semibold text-foreground">Edit Collection</h2>
|
||||
<button @click="emit('close')" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-foreground">Name</label>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
autofocus
|
||||
class="h-9 rounded-md border border-input bg-background text-foreground text-sm px-3 focus:outline-none focus:ring-2 focus:ring-primary placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-foreground"> Icon <span class="text-muted-foreground font-normal">(optional)</span> </label>
|
||||
<IconPicker v-model="icon" placeholder="Choose an icon..." />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('close')"
|
||||
class="h-9 px-4 rounded-md border border-input bg-background text-sm text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!name.trim() || saving"
|
||||
class="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -17,11 +17,11 @@ export function useCollections() {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function createCollection(name: string, description?: string): Promise<Collection> {
|
||||
async function createCollection(name: string, icon?: string, description?: string): Promise<Collection> {
|
||||
const res = await api('/api/collections', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description }),
|
||||
body: JSON.stringify({ name, icon: icon || undefined, description }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to create collection')
|
||||
const created: Collection = await res.json()
|
||||
@@ -29,6 +29,18 @@ export function useCollections() {
|
||||
return created
|
||||
}
|
||||
|
||||
async function updateCollection(id: number, name: string, icon: string): Promise<Collection> {
|
||||
const res = await api(`/api/collections/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, icon }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update collection')
|
||||
const updated: Collection = await res.json()
|
||||
collections.value = collections.value.map((c) => (c.id === id ? updated : c))
|
||||
return updated
|
||||
}
|
||||
|
||||
async function addBooksToCollection(collectionId: number, bookIds: number[]): Promise<Collection> {
|
||||
const res = await api(`/api/collections/${collectionId}/books`, {
|
||||
method: 'POST',
|
||||
@@ -58,6 +70,7 @@ export function useCollections() {
|
||||
fetchCollections,
|
||||
fetchCollectionsWithMembership,
|
||||
createCollection,
|
||||
updateCollection,
|
||||
addBooksToCollection,
|
||||
removeBooksFromCollection,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { FolderOpen } from 'lucide-vue-next'
|
||||
import { FolderOpen, Pencil } from 'lucide-vue-next'
|
||||
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||
import BookQuickView from '@/features/book/components/BookQuickView.vue'
|
||||
@@ -10,6 +10,7 @@ import ViewHeader from '@/components/ViewHeader.vue'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import SelectionActionBar from '@/components/SelectionActionBar.vue'
|
||||
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
|
||||
import EditCollectionDialog from '@/features/collection/components/EditCollectionDialog.vue'
|
||||
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useCollections } from '@/features/collection/composables/useCollections'
|
||||
@@ -39,6 +40,7 @@ function toggleSelectionMode() {
|
||||
}
|
||||
|
||||
const addToCollectionOpen = ref(false)
|
||||
const editCollectionOpen = ref(false)
|
||||
let removingInProgress = false
|
||||
const quickViewBookId = ref<number | null>(null)
|
||||
const quickViewOpen = ref(false)
|
||||
@@ -121,6 +123,8 @@ watch(loading, (isLoading) => {
|
||||
@done="exitSelectionMode"
|
||||
/>
|
||||
|
||||
<EditCollectionDialog v-if="collection" :open="editCollectionOpen" :collection="collection" @close="editCollectionOpen = false" />
|
||||
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
|
||||
@@ -128,7 +132,7 @@ watch(loading, (isLoading) => {
|
||||
<AppHeader />
|
||||
<ViewHeader
|
||||
:title="collection?.name ?? 'Collection'"
|
||||
icon="FolderOpen"
|
||||
:icon="collection?.icon || 'FolderOpen'"
|
||||
:total="total"
|
||||
:loaded="books.length"
|
||||
v-model:coverSize="coverSize"
|
||||
@@ -136,7 +140,18 @@ watch(loading, (isLoading) => {
|
||||
v-model:viewMode="viewMode"
|
||||
:selection-mode="selectionMode"
|
||||
@toggle-selection="toggleSelectionMode"
|
||||
/>
|
||||
>
|
||||
<template #toolbar>
|
||||
<button
|
||||
v-if="collection"
|
||||
class="h-8 w-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Edit collection"
|
||||
@click="editCollectionOpen = true"
|
||||
>
|
||||
<Pencil :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
</ViewHeader>
|
||||
|
||||
<main class="flex-1 overflow-y-auto px-4 py-4" :class="backgroundClass">
|
||||
<div v-if="!loading && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
|
||||
|
||||
Reference in New Issue
Block a user