20 KiB
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:
- Create migration SQL file with cluster tables
- Update
initializeDatabase()function to run migrations - Add TypeScript interfaces for Cluster and mapping types
- Create database helper functions for cluster operations
Code Snippets:
Migration 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:
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.
// /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.
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.
// /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.
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).
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.
// /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.
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.
// /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.
// /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.
// /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:
- Create cluster list component
- Create cluster form (create/edit)
- Add color picker
- Add icon selector
- Integrate into settings page
Component Structure:
// /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<Cluster[]>([]);
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)