nextav/docs/active/library-clusters/LIBRARY_CLUSTER_IMPLEMENTAT...

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:

  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:

-- 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:

  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:

// /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)