feat: add collections - icon support, edit dialog, full CRUD

This commit is contained in:
neon
2026-02-03 14:39:52 -06:00
parent aec4af18a5
commit 734df9a83d
14 changed files with 173 additions and 10 deletions
+13 -2
View File
@@ -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,
} }
+18 -3
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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
} }
+1
View File
@@ -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)