mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: add collections - icon support, edit dialog, full CRUD
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as Icons from 'lucide-vue-next'
|
import * as Icons from 'lucide-vue-next'
|
||||||
import { Aperture, FolderOpen, Plus } 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 { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
@@ -197,7 +205,11 @@ onMounted(() => {
|
|||||||
class="gap-2.5"
|
class="gap-2.5"
|
||||||
@click="router.push({ name: 'collection', params: { id: collection.id } })"
|
@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'">
|
<span :class="activeCollectionId === collection.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
|
||||||
{{ collection.name }}
|
{{ collection.name }}
|
||||||
</span>
|
</span>
|
||||||
@@ -214,7 +226,6 @@ onMounted(() => {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
import { useCollections } from '../composables/useCollections'
|
import { useCollections } from '../composables/useCollections'
|
||||||
|
import IconPicker from '@/components/IconPicker.vue'
|
||||||
|
|
||||||
defineProps<{ open: boolean }>()
|
defineProps<{ open: boolean }>()
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
@@ -11,6 +12,7 @@ const router = useRouter()
|
|||||||
const { createCollection } = useCollections()
|
const { createCollection } = useCollections()
|
||||||
|
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
|
const icon = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -19,8 +21,9 @@ async function submit() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const collection = await createCollection(name.value.trim())
|
const collection = await createCollection(name.value.trim(), icon.value.trim() || undefined)
|
||||||
name.value = ''
|
name.value = ''
|
||||||
|
icon.value = ''
|
||||||
emit('close')
|
emit('close')
|
||||||
router.push({ name: 'collection', params: { id: collection.id } })
|
router.push({ name: 'collection', params: { id: collection.id } })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -55,6 +58,11 @@ async function submit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 mt-1">
|
<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()
|
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', {
|
const res = await api('/api/collections', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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')
|
if (!res.ok) throw new Error('Failed to create collection')
|
||||||
const created: Collection = await res.json()
|
const created: Collection = await res.json()
|
||||||
@@ -29,6 +29,18 @@ export function useCollections() {
|
|||||||
return created
|
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> {
|
async function addBooksToCollection(collectionId: number, bookIds: number[]): Promise<Collection> {
|
||||||
const res = await api(`/api/collections/${collectionId}/books`, {
|
const res = await api(`/api/collections/${collectionId}/books`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -58,6 +70,7 @@ export function useCollections() {
|
|||||||
fetchCollections,
|
fetchCollections,
|
||||||
fetchCollectionsWithMembership,
|
fetchCollectionsWithMembership,
|
||||||
createCollection,
|
createCollection,
|
||||||
|
updateCollection,
|
||||||
addBooksToCollection,
|
addBooksToCollection,
|
||||||
removeBooksFromCollection,
|
removeBooksFromCollection,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 BookCoverCard from '@/features/book/components/BookCoverCard.vue'
|
||||||
import BookListRow from '@/features/book/components/BookListRow.vue'
|
import BookListRow from '@/features/book/components/BookListRow.vue'
|
||||||
import BookQuickView from '@/features/book/components/BookQuickView.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 AppSidebar from '@/components/AppSidebar.vue'
|
||||||
import SelectionActionBar from '@/components/SelectionActionBar.vue'
|
import SelectionActionBar from '@/components/SelectionActionBar.vue'
|
||||||
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.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 { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useCollections } from '@/features/collection/composables/useCollections'
|
import { useCollections } from '@/features/collection/composables/useCollections'
|
||||||
@@ -39,6 +40,7 @@ function toggleSelectionMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addToCollectionOpen = ref(false)
|
const addToCollectionOpen = ref(false)
|
||||||
|
const editCollectionOpen = ref(false)
|
||||||
let removingInProgress = false
|
let removingInProgress = false
|
||||||
const quickViewBookId = ref<number | null>(null)
|
const quickViewBookId = ref<number | null>(null)
|
||||||
const quickViewOpen = ref(false)
|
const quickViewOpen = ref(false)
|
||||||
@@ -121,6 +123,8 @@ watch(loading, (isLoading) => {
|
|||||||
@done="exitSelectionMode"
|
@done="exitSelectionMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EditCollectionDialog v-if="collection" :open="editCollectionOpen" :collection="collection" @close="editCollectionOpen = false" />
|
||||||
|
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|
||||||
@@ -128,7 +132,7 @@ watch(loading, (isLoading) => {
|
|||||||
<AppHeader />
|
<AppHeader />
|
||||||
<ViewHeader
|
<ViewHeader
|
||||||
:title="collection?.name ?? 'Collection'"
|
:title="collection?.name ?? 'Collection'"
|
||||||
icon="FolderOpen"
|
:icon="collection?.icon || 'FolderOpen'"
|
||||||
:total="total"
|
:total="total"
|
||||||
:loaded="books.length"
|
:loaded="books.length"
|
||||||
v-model:coverSize="coverSize"
|
v-model:coverSize="coverSize"
|
||||||
@@ -136,7 +140,18 @@ watch(loading, (isLoading) => {
|
|||||||
v-model:viewMode="viewMode"
|
v-model:viewMode="viewMode"
|
||||||
:selection-mode="selectionMode"
|
:selection-mode="selectionMode"
|
||||||
@toggle-selection="toggleSelectionMode"
|
@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">
|
<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">
|
<div v-if="!loading && books.length === 0" class="flex flex-col items-center justify-center py-24 gap-3 text-center">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type Collection = {
|
export type Collection = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
bookCount: number;
|
bookCount: number;
|
||||||
memberCount?: number;
|
memberCount?: number;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ CREATE TABLE "collections" (
|
|||||||
"id" serial PRIMARY KEY NOT NULL,
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
"user_id" integer NOT NULL,
|
"user_id" integer NOT NULL,
|
||||||
"name" text NOT NULL,
|
"name" text NOT NULL,
|
||||||
|
"icon" text,
|
||||||
"description" text,
|
"description" text,
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "603b7da1-d34a-4b50-bdc9-c3f70790befb",
|
"id": "b24abd51-bfb7-46d7-a788-d934fb481275",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -947,6 +947,12 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
"icon": {
|
||||||
|
"name": "icon",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"name": "description",
|
"name": "description",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1770185227252,
|
"when": 1770197627779,
|
||||||
"tag": "0000_baseline",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const collections = pgTable('collections', {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: 'cascade' }),
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
|
icon: text('icon'),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const collectionFields = {
|
|||||||
id: collections.id,
|
id: collections.id,
|
||||||
userId: collections.userId,
|
userId: collections.userId,
|
||||||
name: collections.name,
|
name: collections.name,
|
||||||
|
icon: collections.icon,
|
||||||
description: collections.description,
|
description: collections.description,
|
||||||
createdAt: collections.createdAt,
|
createdAt: collections.createdAt,
|
||||||
updatedAt: collections.updatedAt,
|
updatedAt: collections.updatedAt,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export class CollectionService {
|
|||||||
const [inserted] = await this.collectionRepo.insert({
|
const [inserted] = await this.collectionRepo.insert({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
|
icon: dto.icon?.trim() || null,
|
||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
});
|
});
|
||||||
const [collection] = await this.collectionRepo.findById(inserted.id);
|
const [collection] = await this.collectionRepo.findById(inserted.id);
|
||||||
@@ -89,6 +90,7 @@ export class CollectionService {
|
|||||||
|
|
||||||
const [updated] = await this.collectionRepo.update(id, existing.userId, {
|
const [updated] = await this.collectionRepo.update(id, existing.userId, {
|
||||||
...(dto.name !== undefined && { name: dto.name }),
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
...(dto.icon !== undefined && { icon: dto.icon.trim() || null }),
|
||||||
...(dto.description !== undefined && { description: dto.description }),
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ export class CreateCollectionDto {
|
|||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(1000)
|
@MaxLength(1000)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export class UpdateCollectionDto {
|
|||||||
@MaxLength(255)
|
@MaxLength(255)
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(1000)
|
@MaxLength(1000)
|
||||||
|
|||||||
Reference in New Issue
Block a user