# Library Cluster Implementation Guide ## 📋 Task Breakdown & Implementation Order This guide provides step-by-step instructions for implementing the Library Cluster feature. --- ## Phase 1: Database & Backend Foundation ### Task 1.1: Database Schema Migration **Estimated Time**: 1 hour **Files to Create/Modify**: - `/src/db/migrations/001_add_library_clusters.sql` (create) - `/src/db/index.ts` (modify) #### Steps: 1. Create migration SQL file with cluster tables 2. Update `initializeDatabase()` function to run migrations 3. Add TypeScript interfaces for Cluster and mapping types 4. Create database helper functions for cluster operations #### Code Snippets: **Migration SQL**: ```sql -- Create clusters table CREATE TABLE IF NOT EXISTS clusters ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT, color TEXT DEFAULT '#6366f1', icon TEXT DEFAULT 'folder', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Create mapping table CREATE TABLE IF NOT EXISTS library_cluster_mapping ( id INTEGER PRIMARY KEY AUTOINCREMENT, library_id INTEGER NOT NULL, cluster_id INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (library_id) REFERENCES libraries(id) ON DELETE CASCADE, FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE, UNIQUE(library_id, cluster_id) ); -- Create indexes CREATE INDEX IF NOT EXISTS idx_library_cluster_mapping_library ON library_cluster_mapping(library_id); CREATE INDEX IF NOT EXISTS idx_library_cluster_mapping_cluster ON library_cluster_mapping(cluster_id); CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name); ``` **TypeScript Interfaces**: ```typescript export interface Cluster { id: number; name: string; description?: string; color: string; icon: string; created_at: string; updated_at: string; } export interface ClusterWithStats extends Cluster { library_count: number; media_count: number; video_count: number; photo_count: number; text_count: number; total_size: number; } export interface LibraryClusterMapping { id: number; library_id: number; cluster_id: number; created_at: string; } ``` **Verification**: - [ ] Tables created successfully - [ ] Indexes created - [ ] Foreign keys working - [ ] No errors in console --- ### Task 1.2: Cluster Management APIs **Estimated Time**: 2 hours **Files to Create**: - `/src/app/api/clusters/route.ts` - `/src/app/api/clusters/[id]/route.ts` #### GET /api/clusters Returns all clusters with statistics. ```typescript // /src/app/api/clusters/route.ts import { NextResponse } from 'next/server'; import { getDatabase } from '@/db'; export async function GET() { const db = getDatabase(); const clusters = db.prepare(` SELECT c.*, COUNT(DISTINCT lcm.library_id) as library_count, COUNT(DISTINCT CASE WHEN m.type = 'video' THEN m.id END) as video_count, COUNT(DISTINCT CASE WHEN m.type = 'photo' THEN m.id END) as photo_count, COUNT(DISTINCT CASE WHEN m.type = 'text' THEN m.id END) as text_count, COALESCE(SUM(m.size), 0) as total_size FROM clusters c LEFT JOIN library_cluster_mapping lcm ON c.id = lcm.cluster_id LEFT JOIN libraries l ON lcm.library_id = l.id LEFT JOIN media m ON l.id = m.library_id GROUP BY c.id ORDER BY c.name `).all(); return NextResponse.json(clusters); } ``` #### POST /api/clusters Creates a new cluster. ```typescript export async function POST(request: Request) { const db = getDatabase(); const { name, description, color, icon } = await request.json(); // Validation if (!name || name.trim().length === 0) { return NextResponse.json( { error: 'Cluster name is required' }, { status: 400 } ); } if (name.length > 100) { return NextResponse.json( { error: 'Cluster name must be 100 characters or less' }, { status: 400 } ); } try { const result = db.prepare(` INSERT INTO clusters (name, description, color, icon) VALUES (?, ?, ?, ?) `).run( name.trim(), description || null, color || '#6366f1', icon || 'folder' ); const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?') .get(result.lastInsertRowid); return NextResponse.json(cluster, { status: 201 }); } catch (error: any) { if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { return NextResponse.json( { error: 'A cluster with this name already exists' }, { status: 409 } ); } return NextResponse.json( { error: error.message }, { status: 500 } ); } } ``` #### GET /api/clusters/[id] Get cluster details with libraries. ```typescript // /src/app/api/clusters/[id]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '@/db'; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const db = getDatabase(); const clusterId = parseInt(id); if (isNaN(clusterId)) { return NextResponse.json( { error: 'Invalid cluster ID' }, { status: 400 } ); } const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?') .get(clusterId); if (!cluster) { return NextResponse.json( { error: 'Cluster not found' }, { status: 404 } ); } const libraries = db.prepare(` SELECT l.*, lcm.created_at as assigned_at FROM libraries l INNER JOIN library_cluster_mapping lcm ON l.id = lcm.library_id WHERE lcm.cluster_id = ? ORDER BY l.path `).all(clusterId); return NextResponse.json({ cluster, libraries }); } ``` #### PUT /api/clusters/[id] Update cluster metadata. ```typescript export async function PUT( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const db = getDatabase(); const clusterId = parseInt(id); const { name, description, color, icon } = await request.json(); if (isNaN(clusterId)) { return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 }); } try { const updates: string[] = []; const values: any[] = []; if (name !== undefined) { updates.push('name = ?'); values.push(name.trim()); } if (description !== undefined) { updates.push('description = ?'); values.push(description); } if (color !== undefined) { updates.push('color = ?'); values.push(color); } if (icon !== undefined) { updates.push('icon = ?'); values.push(icon); } updates.push('updated_at = CURRENT_TIMESTAMP'); values.push(clusterId); const result = db.prepare(` UPDATE clusters SET ${updates.join(', ')} WHERE id = ? `).run(...values); if (result.changes === 0) { return NextResponse.json({ error: 'Cluster not found' }, { status: 404 }); } const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?').get(clusterId); return NextResponse.json(cluster); } catch (error: any) { if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { return NextResponse.json( { error: 'A cluster with this name already exists' }, { status: 409 } ); } return NextResponse.json({ error: error.message }, { status: 500 }); } } ``` #### DELETE /api/clusters/[id] Delete a cluster (cascade deletes mappings). ```typescript export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const db = getDatabase(); const clusterId = parseInt(id); if (isNaN(clusterId)) { return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 }); } const result = db.prepare('DELETE FROM clusters WHERE id = ?').run(clusterId); if (result.changes === 0) { return NextResponse.json({ error: 'Cluster not found' }, { status: 404 }); } return NextResponse.json({ message: 'Cluster deleted successfully', deleted_mappings: result.changes }); } ``` **Verification**: - [ ] Can create cluster via API - [ ] Can list all clusters - [ ] Can get single cluster details - [ ] Can update cluster - [ ] Can delete cluster - [ ] Error handling works --- ### Task 1.3: Library-Cluster Mapping APIs **Estimated Time**: 1 hour **Files to Create**: - `/src/app/api/clusters/[id]/libraries/route.ts` - `/src/app/api/clusters/[id]/libraries/[libraryId]/route.ts` #### POST /api/clusters/[id]/libraries Assign libraries to a cluster. ```typescript // /src/app/api/clusters/[id]/libraries/route.ts import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '@/db'; export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const { libraryIds } = await request.json(); const db = getDatabase(); const clusterId = parseInt(id); if (isNaN(clusterId)) { return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 }); } if (!Array.isArray(libraryIds) || libraryIds.length === 0) { return NextResponse.json( { error: 'libraryIds must be a non-empty array' }, { status: 400 } ); } // Check cluster exists const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?').get(clusterId); if (!cluster) { return NextResponse.json({ error: 'Cluster not found' }, { status: 404 }); } let added = 0; const errors: string[] = []; for (const libraryId of libraryIds) { try { db.prepare(` INSERT INTO library_cluster_mapping (library_id, cluster_id) VALUES (?, ?) `).run(libraryId, clusterId); added++; } catch (error: any) { if (error.code !== 'SQLITE_CONSTRAINT_UNIQUE') { errors.push(`Library ${libraryId}: ${error.message}`); } // Skip if already mapped } } return NextResponse.json({ added, total: libraryIds.length, errors: errors.length > 0 ? errors : undefined }); } ``` #### GET /api/clusters/[id]/libraries Get libraries for a cluster. ```typescript export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const db = getDatabase(); const clusterId = parseInt(id); if (isNaN(clusterId)) { return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 }); } const libraries = db.prepare(` SELECT l.*, lcm.created_at as assigned_at FROM libraries l INNER JOIN library_cluster_mapping lcm ON l.id = lcm.library_id WHERE lcm.cluster_id = ? ORDER BY l.path `).all(clusterId); return NextResponse.json(libraries); } ``` #### DELETE /api/clusters/[id]/libraries/[libraryId] Remove library from cluster. ```typescript // /src/app/api/clusters/[id]/libraries/[libraryId]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '@/db'; export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string, libraryId: string }> } ) { const { id, libraryId } = await params; const db = getDatabase(); const clusterId = parseInt(id); const libId = parseInt(libraryId); if (isNaN(clusterId) || isNaN(libId)) { return NextResponse.json({ error: 'Invalid IDs' }, { status: 400 }); } const result = db.prepare(` DELETE FROM library_cluster_mapping WHERE cluster_id = ? AND library_id = ? `).run(clusterId, libId); if (result.changes === 0) { return NextResponse.json( { error: 'Mapping not found' }, { status: 404 } ); } return NextResponse.json({ message: 'Library removed from cluster' }); } ``` **Verification**: - [ ] Can assign libraries to cluster - [ ] Can list cluster libraries - [ ] Can remove library from cluster - [ ] Duplicate assignments handled gracefully --- ### Task 1.4: Cluster Media Query APIs **Estimated Time**: 2 hours **Files to Create**: - `/src/app/api/clusters/[id]/videos/route.ts` - `/src/app/api/clusters/[id]/photos/route.ts` - `/src/app/api/clusters/[id]/texts/route.ts` - `/src/app/api/clusters/[id]/stats/route.ts` #### GET /api/clusters/[id]/videos Get all videos from cluster libraries. ```typescript // /src/app/api/clusters/[id]/videos/route.ts import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '@/db'; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const { searchParams } = new URL(request.url); const db = getDatabase(); const clusterId = parseInt(id); const limit = parseInt(searchParams.get('limit') || '50'); const offset = parseInt(searchParams.get('offset') || '0'); const search = searchParams.get('search'); if (isNaN(clusterId)) { return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 }); } // Check cluster exists const cluster = db.prepare('SELECT * FROM clusters WHERE id = ?').get(clusterId); if (!cluster) { return NextResponse.json({ error: 'Cluster not found' }, { status: 404 }); } let query = ` SELECT m.*, l.path as library_path FROM media m INNER JOIN libraries l ON m.library_id = l.id INNER JOIN library_cluster_mapping lcm ON l.id = lcm.library_id WHERE lcm.cluster_id = ? AND m.type = 'video' `; const params: any[] = [clusterId]; if (search) { query += ' AND (m.title LIKE ? OR m.path LIKE ?)'; params.push(`%${search}%`, `%${search}%`); } query += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?'; params.push(limit, offset); const videos = db.prepare(query).all(...params); // Get total count let countQuery = ` SELECT COUNT(*) as count FROM media m INNER JOIN libraries l ON m.library_id = l.id INNER JOIN library_cluster_mapping lcm ON l.id = lcm.library_id WHERE lcm.cluster_id = ? AND m.type = 'video' `; const countParams: any[] = [clusterId]; if (search) { countQuery += ' AND (m.title LIKE ? OR m.path LIKE ?)'; countParams.push(`%${search}%`, `%${search}%`); } const { count } = db.prepare(countQuery).get(...countParams) as { count: number }; return NextResponse.json({ videos, total: count, limit, offset, hasMore: offset + videos.length < count }); } ``` #### GET /api/clusters/[id]/stats Get cluster statistics. ```typescript // /src/app/api/clusters/[id]/stats/route.ts import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '@/db'; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const db = getDatabase(); const clusterId = parseInt(id); if (isNaN(clusterId)) { return NextResponse.json({ error: 'Invalid cluster ID' }, { status: 400 }); } const stats = db.prepare(` SELECT COUNT(DISTINCT l.id) as library_count, COUNT(DISTINCT CASE WHEN m.type = 'video' THEN m.id END) as video_count, COUNT(DISTINCT CASE WHEN m.type = 'photo' THEN m.id END) as photo_count, COUNT(DISTINCT CASE WHEN m.type = 'text' THEN m.id END) as text_count, COUNT(DISTINCT m.id) as total_media, COALESCE(SUM(m.size), 0) as total_size FROM library_cluster_mapping lcm LEFT JOIN libraries l ON lcm.library_id = l.id LEFT JOIN media m ON l.id = m.library_id WHERE lcm.cluster_id = ? `).get(clusterId); return NextResponse.json(stats); } ``` **Similar implementations for**: - `/api/clusters/[id]/photos/route.ts` - `/api/clusters/[id]/texts/route.ts` **Verification**: - [ ] Can query videos by cluster - [ ] Can query photos by cluster - [ ] Can query texts by cluster - [ ] Statistics accurate - [ ] Pagination works - [ ] Search filtering works --- ## Phase 2: Settings UI Implementation ### Task 2.1: Cluster Management Component **Estimated Time**: 3 hours **Files to Create/Modify**: - `/src/components/cluster-management.tsx` (create) - `/src/app/settings/page.tsx` (modify) #### Steps: 1. Create cluster list component 2. Create cluster form (create/edit) 3. Add color picker 4. Add icon selector 5. Integrate into settings page #### Component Structure: ```typescript // /src/components/cluster-management.tsx 'use client'; import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card } from '@/components/ui/card'; interface Cluster { id: number; name: string; description?: string; color: string; icon: string; library_count?: number; video_count?: number; photo_count?: number; } const COLORS = [ { name: 'Indigo', value: '#6366f1' }, { name: 'Blue', value: '#3b82f6' }, { name: 'Green', value: '#10b981' }, { name: 'Red', value: '#ef4444' }, { name: 'Purple', value: '#a855f7' }, { name: 'Pink', value: '#ec4899' }, ]; const ICONS = ['folder', 'film', 'tv', 'image', 'book']; export function ClusterManagement() { const [clusters, setClusters] = useState([]); const [isCreating, setIsCreating] = useState(false); const [newCluster, setNewCluster] = useState({ name: '', description: '', color: '#6366f1', icon: 'folder' }); // Implementation here... } ``` **Verification**: - [ ] Can create clusters via UI - [ ] Can edit cluster details - [ ] Can delete clusters - [ ] Color picker works - [ ] Icon selector works - [ ] Form validation works --- ### Task 2.2: Library Assignment UI **Estimated Time**: 2 hours **Files to Create**: - `/src/components/library-cluster-assignment.tsx` #### Features: - Multi-select library picker - Show current assignments - Bulk assign/unassign **Verification**: - [ ] Can select multiple libraries - [ ] Can assign to cluster - [ ] Can remove from cluster - [ ] Visual feedback for assignments --- ### Task 2.3: Update Library Cards **Estimated Time**: 1 hour **Files to Modify**: - `/src/app/settings/page.tsx` #### Changes: - Show cluster badges on library cards - Quick cluster assignment button **Verification**: - [ ] Cluster badges visible - [ ] Quick assignment works --- ## Phase 3: Navigation & Viewing ### Task 3.1: Update Sidebar **Estimated Time**: 2 hours **Files to Modify**: - `/src/components/sidebar.tsx` #### Features: - Add "Clusters" section - List all clusters - Color-coded indicators - Click to navigate **Verification**: - [ ] Clusters section visible - [ ] Clusters listed correctly - [ ] Colors displayed - [ ] Navigation works --- ### Task 3.2: Cluster View Page **Estimated Time**: 3 hours **Files to Create**: - `/src/app/clusters/[id]/page.tsx` #### Features: - Display cluster header - Tabbed interface (Videos/Photos/Stats) - Reuse existing media grid components - Pagination **Verification**: - [ ] Page loads correctly - [ ] Media displays from all libraries - [ ] Tabs work - [ ] Pagination works --- ## Testing Checklist ### Manual Testing - [ ] Create cluster - [ ] Assign libraries to cluster - [ ] View cluster media - [ ] Edit cluster - [ ] Delete cluster - [ ] Remove library from cluster - [ ] Navigate via sidebar ### Edge Cases - [ ] Empty cluster (no libraries) - [ ] Cluster with no media - [ ] Very long cluster names - [ ] Special characters in names - [ ] Concurrent modifications ### Performance Testing - [ ] 10+ libraries per cluster - [ ] 1000+ media items - [ ] Multiple clusters loaded --- ## Deployment Checklist - [ ] Database migration tested locally - [ ] All API endpoints tested - [ ] UI components tested - [ ] Documentation updated - [ ] Migration script ready - [ ] Rollback plan prepared - [ ] Monitoring configured --- ## Documentation Updates - [ ] Update README.md - [ ] Create user guide - [ ] Update API documentation - [ ] Add troubleshooting guide --- **Next Steps**: Begin with Phase 1, Task 1.1 (Database Schema Migration)