801 lines
20 KiB
Markdown
801 lines
20 KiB
Markdown
# 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<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)
|