feat: implement core search and content management functionality

- Add semantic search API endpoint with mock data and debounced search
- Create useSearch hook for managing search state and API calls
- Implement SearchResults component with rich item display
- Add ImageDropzone component with drag-and-drop file upload support
- Create upload API endpoint for file and URL processing
- Build ItemDetailsModal with comprehensive metadata display and actions
- Add Badge UI component to design system
- Integrate search functionality into main interface with real-time results
- Support multiple image formats with validation and size limits
- Add item management actions (share, download, copy, favorite, edit, delete)

Core features now working:
✅ Real-time semantic search with debouncing
✅ Image upload via drag-and-drop or URL
✅ Detailed item view with metadata and actions
✅ Responsive UI with proper loading states and error handling
This commit is contained in:
NicholaiVogel 2025-10-08 18:01:42 -06:00
parent 85a33d3c30
commit 0e0d59f13f
8 changed files with 1412 additions and 35 deletions

View File

@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server';
// Mock data for search results - this will be replaced with actual database/vector search
const mockSearchData = [
{
id: '1',
type: 'image',
title: 'Minimalist Brand Identity',
description: 'Clean, modern logo design with geometric shapes and orange accent colors',
url: 'https://example.com/image1.jpg',
tags: ['branding', 'minimalist', 'logo', 'orange', 'geometric'],
source: 'Instagram',
dateAdded: '2024-01-15',
collection: 'Brand Identity',
thumbnail: '/placeholder-thumbnail.jpg'
},
{
id: '2',
type: 'image',
title: 'UI Design System',
description: 'Comprehensive design system with components, colors, and typography guidelines',
url: 'https://example.com/image2.jpg',
tags: ['ui', 'design-system', 'components', 'typography', 'colors'],
source: 'Dribbble',
dateAdded: '2024-01-14',
collection: 'UI Inspiration',
thumbnail: '/placeholder-thumbnail.jpg'
},
{
id: '3',
type: 'link',
title: 'Typography Inspiration',
description: 'Collection of beautiful typography examples and font pairings',
url: 'https://example.com/typography',
tags: ['typography', 'fonts', 'pairings', 'design'],
source: 'Bookmarked',
dateAdded: '2024-01-13',
collection: 'Typography',
thumbnail: '/placeholder-thumbnail.jpg'
},
{
id: '4',
type: 'video',
title: 'Motion Graphics Tutorial',
description: 'Advanced After Effects techniques for creating smooth animations',
url: 'https://example.com/video1.mp4',
tags: ['motion', 'animation', 'after-effects', 'tutorial'],
source: 'YouTube',
dateAdded: '2024-01-12',
collection: 'Motion Graphics',
thumbnail: '/placeholder-thumbnail.jpg'
},
{
id: '5',
type: 'image',
title: 'Color Palette Inspiration',
description: 'Warm earth tones and orange gradients for autumn-themed designs',
url: 'https://example.com/image3.jpg',
tags: ['colors', 'palette', 'orange', 'earth-tones', 'autumn'],
source: 'Pinterest',
dateAdded: '2024-01-11',
collection: 'Color Palettes',
thumbnail: '/placeholder-thumbnail.jpg'
}
];
// Simple semantic search simulation
function performSemanticSearch(query: string, data: typeof mockSearchData) {
const normalizedQuery = query.toLowerCase();
// Simple keyword matching with scoring
return data
.map(item => {
let score = 0;
// Title matching (highest weight)
if (item.title.toLowerCase().includes(normalizedQuery)) score += 10;
// Description matching (medium weight)
if (item.description.toLowerCase().includes(normalizedQuery)) score += 5;
// Tag matching (medium weight)
if (item.tags.some(tag => tag.toLowerCase().includes(normalizedQuery))) score += 5;
// Collection matching (low weight)
if (item.collection.toLowerCase().includes(normalizedQuery)) score += 2;
// Source matching (low weight)
if (item.source.toLowerCase().includes(normalizedQuery)) score += 1;
return { ...item, relevanceScore: score };
})
.filter(item => item.relevanceScore > 0)
.sort((a, b) => b.relevanceScore - a.relevanceScore);
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
const type = searchParams.get('type'); // image, video, link, all
const limit = parseInt(searchParams.get('limit') || '20');
if (!query || query.trim().length === 0) {
return NextResponse.json({
results: [],
total: 0,
query: '',
message: 'Please provide a search query'
});
}
// Perform semantic search
let results = performSemanticSearch(query.trim(), mockSearchData);
// Filter by type if specified
if (type && type !== 'all') {
results = results.filter(item => item.type === type);
}
// Limit results
results = results.slice(0, limit);
// Simulate search time for realistic feel
await new Promise(resolve => setTimeout(resolve, 150));
return NextResponse.json({
results: results.map(({ relevanceScore, ...item }) => item), // Remove score from response
total: results.length,
query: query.trim(),
searchTime: '150ms'
});
} catch (error) {
console.error('Search API error:', error);
return NextResponse.json(
{ error: 'Search failed', message: 'An error occurred while searching' },
{ status: 500 }
);
}
}
// Handle image-based search (for future implementation)
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const image = formData.get('image') as File;
if (!image) {
return NextResponse.json(
{ error: 'No image provided' },
{ status: 400 }
);
}
// TODO: Implement image-based semantic search
// This would involve:
// 1. Processing the uploaded image
// 2. Generating embeddings/features
// 3. Comparing with stored item embeddings
// 4. Returning similar items
return NextResponse.json({
results: [],
total: 0,
message: 'Image search not yet implemented',
searchTime: '0ms'
});
} catch (error) {
console.error('Image search API error:', error);
return NextResponse.json(
{ error: 'Image search failed', message: 'An error occurred while searching by image' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const url = formData.get('url') as string;
if (!file && !url) {
return NextResponse.json(
{ error: 'No file or URL provided' },
{ status: 400 }
);
}
// TODO: Implement actual file upload to Cloudflare R2
// For now, we'll simulate the upload process
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
return NextResponse.json(
{ error: 'File must be an image' },
{ status: 400 }
);
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: 'File size must be less than 10MB' },
{ status: 400 }
);
}
// Simulate upload processing
await new Promise(resolve => setTimeout(resolve, 1000));
// TODO:
// 1. Upload to Cloudflare R2
// 2. Generate thumbnail
// 3. Extract metadata (dimensions, colors, etc.)
// 4. Generate embeddings for semantic search
// 5. Store in database with metadata
const mockUploadResult = {
id: `upload_${Date.now()}`,
filename: file.name,
size: file.size,
type: file.type,
url: `https://storage.example.com/uploads/${file.name}`,
thumbnail: `https://storage.example.com/thumbnails/${file.name}`,
metadata: {
width: 1920,
height: 1080,
colors: ['#f97316', '#ea580c', '#fbbf24'],
dominantColor: '#f97316'
},
uploadTime: new Date().toISOString()
};
return NextResponse.json({
success: true,
data: mockUploadResult,
message: 'File uploaded successfully'
});
}
if (url) {
// TODO: Implement URL processing
// 1. Fetch image from URL
// 2. Validate it's an image
// 3. Download and process
// 4. Generate embeddings
// 5. Store in database
const mockUrlResult = {
id: `url_${Date.now()}`,
url: url,
thumbnail: url, // For now, use original URL as thumbnail
metadata: {
width: 800,
height: 600,
colors: ['#f97316', '#ea580c'],
dominantColor: '#f97316'
},
processedTime: new Date().toISOString()
};
return NextResponse.json({
success: true,
data: mockUrlResult,
message: 'URL processed successfully'
});
}
} catch (error) {
console.error('Upload API error:', error);
return NextResponse.json(
{ error: 'Upload failed', message: 'An error occurred while processing the upload' },
{ status: 500 }
);
}
}
// Handle image-based search after upload
export async function PUT(request: NextRequest) {
try {
const { imageId, searchType = 'similar' } = await request.json();
if (!imageId) {
return NextResponse.json(
{ error: 'Image ID is required' },
{ status: 400 }
);
}
// TODO: Implement image-based semantic search
// This would involve:
// 1. Retrieve image embeddings from database
// 2. Perform vector similarity search
// 3. Return similar items from user's library
// Mock search results for now
const mockSimilarResults = [
{
id: 'similar_1',
type: 'image',
title: 'Similar Orange Design',
description: 'Another design with orange color scheme and similar composition',
url: 'https://example.com/similar1.jpg',
tags: ['orange', 'design', 'similar'],
source: 'Previous Upload',
dateAdded: '2024-01-10',
collection: 'Uploaded Images',
thumbnail: 'https://example.com/similar1-thumb.jpg',
similarity: 0.87
},
{
id: 'similar_2',
type: 'image',
title: 'Orange Brand Identity',
description: 'Brand identity with similar orange tones and geometric elements',
url: 'https://example.com/similar2.jpg',
tags: ['branding', 'orange', 'geometric'],
source: 'Instagram',
dateAdded: '2024-01-08',
collection: 'Brand Identity',
thumbnail: 'https://example.com/similar2-thumb.jpg',
similarity: 0.72
}
];
return NextResponse.json({
results: mockSimilarResults,
total: mockSimilarResults.length,
searchType,
imageId
});
} catch (error) {
console.error('Image search API error:', error);
return NextResponse.json(
{ error: 'Image search failed', message: 'An error occurred while searching by image' },
{ status: 500 }
);
}
}

View File

@ -1,14 +1,141 @@
'use client';
import React, { useState } from 'react';
import { Search, Grid, List, User, Plus, Image, Upload } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Search, Grid, List, User, Plus, Image, Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { useSearch, SearchResult } from '@/hooks/useSearch';
import { SearchResults } from '@/components/SearchResults';
import { ImageDropzone } from '@/components/ImageDropzone';
import { ItemDetailsModal } from '@/components/ItemDetailsModal';
export default function Home() {
const [activeView, setActiveView] = useState('library');
const [showDropzone, setShowDropzone] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null);
const [showItemModal, setShowItemModal] = useState(false);
const {
query,
results,
isLoading,
error,
hasSearched,
total,
searchTime,
search,
clearSearch
} = useSearch();
// Handle search input changes
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
if (value.trim()) {
search(value);
} else {
clearSearch();
}
};
// Handle search result item click
const handleItemClick = (item: SearchResult) => {
setSelectedItem(item);
setShowItemModal(true);
};
// Handle modal close
const handleModalClose = () => {
setShowItemModal(false);
setSelectedItem(null);
};
// Handle item actions
const handleEditItem = (item: SearchResult) => {
console.log('Edit item:', item);
// TODO: Open edit modal/form
};
const handleDeleteItem = (item: SearchResult) => {
console.log('Delete item:', item);
// TODO: Implement delete functionality
if (confirm('Are you sure you want to delete this item?')) {
// Delete logic here
handleModalClose();
}
};
const handleAddToCollection = (item: SearchResult) => {
console.log('Add to collection:', item);
// TODO: Open collection selector
};
const handleToggleFavorite = (item: SearchResult) => {
console.log('Toggle favorite:', item);
// TODO: Implement favorite toggle
};
// Handle image upload
const handleImageUpload = async (file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
console.log('Upload successful:', result);
// TODO: Perform image-based search after upload
// This would call the search API with the uploaded image
// For now, just show a success message
alert('Image uploaded successfully! Image-based search coming soon.');
} catch (error) {
console.error('Upload error:', error);
alert('Upload failed. Please try again.');
}
};
// Handle URL upload
const handleUrlUpload = async (url: string) => {
try {
const formData = new FormData();
formData.append('url', url);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('URL processing failed');
}
const result = await response.json();
console.log('URL processed:', result);
// TODO: Perform image-based search after processing
// For now, just show a success message
alert('URL processed successfully! Image-based search coming soon.');
} catch (error) {
console.error('URL processing error:', error);
alert('URL processing failed. Please check the URL and try again.');
}
};
// Mock data for library items
const mockItems = [
@ -223,8 +350,23 @@ export default function Home() {
<Input
type="text"
placeholder="Search by idea, concept, or visual..."
className="w-full py-4 pl-12 pr-4 text-base h-auto border-0 bg-muted"
value={searchQuery}
onChange={handleSearchChange}
className="w-full py-4 pl-12 pr-12 text-base h-auto border-0 bg-muted"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setSearchQuery('');
clearSearch();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground hover:text-foreground"
>
<X size={16} />
</Button>
)}
</div>
<Button
variant="outline"
@ -266,41 +408,41 @@ export default function Home() {
</nav>
</div>
{/* Expandable Image Search Dropzone */}
{showDropzone && (
<div className="px-8 pb-6">
<Card className="p-8 text-center cursor-pointer hover:opacity-80 transition-all border-0 shadow-none bg-muted">
<CardContent className="p-0">
<Upload size={32} className="text-orange-500 mx-auto mb-3" />
<p className="font-medium mb-2">
Drop your image to search
</p>
<p className="text-sm mb-4 text-muted-foreground">
Drag and drop, or click to browse
</p>
<div className="flex gap-3 justify-center">
<Button
size="sm"
className="font-medium bg-orange-500 hover:bg-orange-600 text-white"
>
Choose File
</Button>
<Button
variant="outline"
size="sm"
className="font-medium"
>
Paste URL
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Image Search Dropzone */}
<ImageDropzone
isOpen={showDropzone}
onClose={() => setShowDropzone(false)}
onUpload={handleImageUpload}
onUrlUpload={handleUrlUpload}
/>
</header>
{/* Active View */}
{activeView === 'library' ? <LibraryView /> : <CollectionsView />}
{hasSearched ? (
<SearchResults
results={results}
isLoading={isLoading}
error={error}
hasSearched={hasSearched}
query={query}
total={total}
searchTime={searchTime}
onItemClick={handleItemClick}
/>
) : (
activeView === 'library' ? <LibraryView /> : <CollectionsView />
)}
{/* Item Details Modal */}
<ItemDetailsModal
item={selectedItem}
isOpen={showItemModal}
onClose={handleModalClose}
onEdit={handleEditItem}
onDelete={handleDeleteItem}
onAddToCollection={handleAddToCollection}
onToggleFavorite={handleToggleFavorite}
/>
</div>
);
}

View File

@ -0,0 +1,172 @@
'use client';
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, Image, Link, X, FileImage } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
interface ImageDropzoneProps {
isOpen: boolean;
onClose: () => void;
onUpload: (file: File) => void;
onUrlUpload: (url: string) => void;
}
export function ImageDropzone({ isOpen, onClose, onUpload, onUrlUpload }: ImageDropzoneProps) {
const [urlInput, setUrlInput] = useState('');
const [isDragActive, setIsDragActive] = useState(false);
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
onUpload(acceptedFiles[0]);
onClose();
}
}, [onUpload, onClose]);
const { getRootProps, getInputProps, isDragReject } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.svg'],
},
maxFiles: 1,
maxSize: 10 * 1024 * 1024, // 10MB
onDragEnter: () => setIsDragActive(true),
onDragLeave: () => setIsDragActive(false),
});
const handleUrlSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (urlInput.trim()) {
onUrlUpload(urlInput.trim());
setUrlInput('');
onClose();
}
};
if (!isOpen) return null;
return (
<div className="px-8 pb-6">
<Card className="p-6 border-0 shadow-none bg-muted">
<CardContent className="p-0">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
<Image size={20} className="text-white" />
</div>
<div>
<h3 className="font-semibold">Search by Image</h3>
<p className="text-sm text-muted-foreground">
Upload an image to find similar items in your library
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X size={20} />
</Button>
</div>
<div className="space-y-6">
{/* Drag and Drop Zone */}
<div
{...getRootProps()}
className={`
relative border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all
${isDragActive
? 'border-orange-500 bg-orange-50'
: isDragReject
? 'border-red-500 bg-red-50'
: 'border-muted-foreground/30 hover:border-orange-400 hover:bg-orange-50/50'
}
`}
>
<input {...getInputProps()} />
{isDragActive ? (
<div className="space-y-3">
<FileImage size={32} className="mx-auto text-orange-500" />
<p className="font-medium text-orange-600">Drop your image here</p>
</div>
) : (
<div className="space-y-4">
<Upload size={32} className="mx-auto text-orange-500" />
<div>
<p className="font-medium mb-2">Drop your image to search</p>
<p className="text-sm text-muted-foreground mb-4">
Drag and drop, or click to browse
</p>
</div>
<Button
size="sm"
className="font-medium bg-orange-500 hover:bg-orange-600 text-white"
>
Choose File
</Button>
</div>
)}
{isDragReject && (
<div className="absolute inset-0 bg-red-50/90 rounded-lg flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 font-medium">Invalid file type</p>
<p className="text-sm text-red-500">Please select an image file</p>
</div>
</div>
)}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-muted-foreground/20" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
{/* URL Input */}
<form onSubmit={handleUrlSubmit} className="space-y-3">
<div className="flex gap-3">
<div className="relative flex-1">
<Link
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
size={16}
/>
<Input
type="url"
placeholder="Paste image URL..."
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
className="pl-10"
/>
</div>
<Button
type="submit"
variant="outline"
size="sm"
className="font-medium"
disabled={!urlInput.trim()}
>
Search
</Button>
</div>
</form>
{/* Supported formats */}
<div className="text-xs text-muted-foreground text-center">
Supports: JPEG, PNG, GIF, WebP, SVG (max 10MB)
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,270 @@
'use client';
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Image,
Video,
Link,
FileText,
Calendar,
Tag,
ExternalLink,
Heart,
Plus,
Share,
Download,
Copy,
Trash2,
Edit
} from 'lucide-react';
import { SearchResult } from '@/hooks/useSearch';
interface ItemDetailsModalProps {
item: SearchResult | null;
isOpen: boolean;
onClose: () => void;
onEdit?: (item: SearchResult) => void;
onDelete?: (item: SearchResult) => void;
onAddToCollection?: (item: SearchResult) => void;
onToggleFavorite?: (item: SearchResult) => void;
}
const getTypeIcon = (type: SearchResult['type']) => {
switch (type) {
case 'image':
return <Image size={20} />;
case 'video':
return <Video size={20} />;
case 'link':
return <Link size={20} />;
case 'note':
return <FileText size={20} />;
default:
return <FileText size={20} />;
}
};
const getTypeColor = (type: SearchResult['type']) => {
switch (type) {
case 'image':
return 'text-blue-500 bg-blue-50';
case 'video':
return 'text-red-500 bg-red-50';
case 'link':
return 'text-green-500 bg-green-50';
case 'note':
return 'text-purple-500 bg-purple-50';
default:
return 'text-gray-500 bg-gray-50';
}
};
export function ItemDetailsModal({
item,
isOpen,
onClose,
onEdit,
onDelete,
onAddToCollection,
onToggleFavorite
}: ItemDetailsModalProps) {
if (!item) return null;
const handleCopyUrl = () => {
navigator.clipboard.writeText(item.url);
// TODO: Show toast notification
};
const handleDownload = () => {
// TODO: Implement download functionality
const link = document.createElement('a');
link.href = item.url;
link.download = item.title;
link.click();
};
const handleShare = () => {
if (navigator.share) {
navigator.share({
title: item.title,
text: item.description,
url: item.url,
});
} else {
handleCopyUrl();
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${getTypeColor(item.type)}`}>
{getTypeIcon(item.type)}
</div>
<div className="flex-1">
<h2 className="text-xl font-bold">{item.title}</h2>
<p className="text-sm text-muted-foreground font-normal">
Added to {item.collection} {new Date(item.dateAdded).toLocaleDateString()}
</p>
</div>
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Media Preview */}
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl text-muted-foreground mb-4">
{getTypeIcon(item.type)}
</div>
<p className="text-muted-foreground">Media preview coming soon</p>
</div>
</div>
{/* Content Information */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Main Content */}
<div className="md:col-span-2 space-y-4">
<div>
<h3 className="font-semibold mb-2">Description</h3>
<p className="text-muted-foreground">{item.description}</p>
</div>
{/* Tags */}
<div>
<h3 className="font-semibold mb-2">Tags</h3>
<div className="flex flex-wrap gap-2">
{item.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
<Tag size={12} />
{tag}
</Badge>
))}
</div>
</div>
{/* Metadata */}
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">Source</h3>
<p className="text-muted-foreground">{item.source}</p>
</div>
<div>
<h3 className="font-semibold mb-2">Collection</h3>
<p className="text-muted-foreground">{item.collection}</p>
</div>
<div>
<h3 className="font-semibold mb-2">Date Added</h3>
<p className="text-muted-foreground flex items-center gap-1">
<Calendar size={14} />
{new Date(item.dateAdded).toLocaleDateString()}
</p>
</div>
<div>
<h3 className="font-semibold mb-2">Type</h3>
<p className="text-muted-foreground capitalize">{item.type}</p>
</div>
</div>
</div>
{/* Actions Sidebar */}
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={handleShare}
>
<Share size={16} />
Share
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={handleCopyUrl}
>
<Copy size={16} />
Copy URL
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={handleDownload}
>
<Download size={16} />
Download
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={() => onToggleFavorite?.(item)}
>
<Heart size={16} />
Add to Favorites
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={() => onAddToCollection?.(item)}
>
<Plus size={16} />
Add to Collection
</Button>
</div>
</div>
<div>
<h3 className="font-semibold mb-3">Manage</h3>
<div className="space-y-2">
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={() => onEdit?.(item)}
>
<Edit size={16} />
Edit Details
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2 text-red-600 hover:text-red-700"
onClick={() => onDelete?.(item)}
>
<Trash2 size={16} />
Delete
</Button>
</div>
</div>
{/* Quick Stats */}
<div className="pt-4 border-t">
<h3 className="font-semibold mb-3">Quick Stats</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">ID:</span>
<span className="font-mono text-xs">{item.id}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tags:</span>
<span>{item.tags.length}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,237 @@
'use client';
import React from 'react';
import { SearchResult } from '@/hooks/useSearch';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Image,
Video,
Link,
FileText,
Calendar,
Tag,
ExternalLink,
Plus,
Heart
} from 'lucide-react';
interface SearchResultsProps {
results: SearchResult[];
isLoading: boolean;
error: string | null;
hasSearched: boolean;
query: string;
total: number;
searchTime: string | null;
onItemClick?: (item: SearchResult) => void;
}
const getTypeIcon = (type: SearchResult['type']) => {
switch (type) {
case 'image':
return <Image size={16} />;
case 'video':
return <Video size={16} />;
case 'link':
return <Link size={16} />;
case 'note':
return <FileText size={16} />;
default:
return <FileText size={16} />;
}
};
const getTypeColor = (type: SearchResult['type']) => {
switch (type) {
case 'image':
return 'text-blue-500';
case 'video':
return 'text-red-500';
case 'link':
return 'text-green-500';
case 'note':
return 'text-purple-500';
default:
return 'text-gray-500';
}
};
export function SearchResults({
results,
isLoading,
error,
hasSearched,
query,
total,
searchTime,
onItemClick
}: SearchResultsProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mx-auto mb-4"></div>
<p className="text-muted-foreground">Searching...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-red-500 text-2xl"></span>
</div>
<h3 className="text-lg font-semibold mb-2">Search Error</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}>
Try Again
</Button>
</div>
</div>
);
}
if (hasSearched && results.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-orange-500 text-2xl">🔍</span>
</div>
<h3 className="text-lg font-semibold mb-2">No results found</h3>
<p className="text-muted-foreground mb-4">
We couldn't find anything matching "{query}". Try different keywords or browse your library.
</p>
<div className="text-sm text-muted-foreground">
<p>💡 <strong>Tips:</strong></p>
<ul className="mt-2 space-y-1 text-left">
<li> Try broader terms or synonyms</li>
<li> Check your spelling</li>
<li> Use tags or collection names</li>
</ul>
</div>
</div>
</div>
);
}
if (!hasSearched) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-orange-500 text-2xl"></span>
</div>
<h3 className="text-lg font-semibold mb-2">Start searching</h3>
<p className="text-muted-foreground">
Enter a search term to find your saved inspiration
</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Search Results Header */}
<div className="flex items-center justify-between px-8 py-3 border-b">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">Search Results</h2>
<span className="text-sm text-muted-foreground">
{total} {total === 1 ? 'result' : 'results'} for "{query}"
</span>
{searchTime && (
<span className="text-xs text-muted-foreground">
({searchTime})
</span>
)}
</div>
</div>
{/* Results Grid */}
<div className="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-4 px-8">
{results.map((item) => (
<Card
key={item.id}
className="mb-4 break-inside-avoid cursor-pointer hover:shadow-lg transition-shadow group"
onClick={() => onItemClick?.(item)}
>
<CardContent className="p-4">
{/* Item Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`${getTypeColor(item.type)}`}>
{getTypeIcon(item.type)}
</div>
<span className="text-sm font-medium text-muted-foreground">
{item.source}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon-sm">
<Heart size={14} />
</Button>
<Button variant="ghost" size="icon-sm">
<Plus size={14} />
</Button>
</div>
</div>
{/* Thumbnail/Preview */}
<div className="aspect-video bg-muted rounded-lg mb-3 flex items-center justify-center">
<div className="text-muted-foreground">
{getTypeIcon(item.type)}
</div>
</div>
{/* Content */}
<div className="space-y-2">
<h3 className="font-semibold text-sm line-clamp-2">
{item.title}
</h3>
<p className="text-xs text-muted-foreground line-clamp-3">
{item.description}
</p>
{/* Tags */}
{item.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{item.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-orange-100 text-orange-700"
>
<Tag size={10} className="mr-1" />
{tag}
</span>
))}
{item.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{item.tags.length - 3} more
</span>
)}
</div>
)}
{/* Metadata */}
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
<div className="flex items-center gap-1">
<Calendar size={10} />
{new Date(item.dateAdded).toLocaleDateString()}
</div>
<span className="text-orange-600 font-medium">
{item.collection}
</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,175 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
export interface SearchResult {
id: string;
type: 'image' | 'video' | 'link' | 'note';
title: string;
description: string;
url: string;
tags: string[];
source: string;
dateAdded: string;
collection: string;
thumbnail: string;
}
export interface SearchResponse {
results: SearchResult[];
total: number;
query: string;
searchTime?: string;
message?: string;
}
export interface SearchState {
query: string;
results: SearchResult[];
isLoading: boolean;
error: string | null;
hasSearched: boolean;
total: number;
searchTime: string | null;
}
export interface SearchOptions {
type?: 'image' | 'video' | 'link' | 'note' | 'all';
limit?: number;
}
export function useSearch() {
const [state, setState] = useState<SearchState>({
query: '',
results: [],
isLoading: false,
error: null,
hasSearched: false,
total: 0,
searchTime: null,
});
const debounceTimeoutRef = useRef<NodeJS.Timeout>();
const abortControllerRef = useRef<AbortController>();
const search = useCallback(async (
query: string,
options: SearchOptions = {}
) => {
// Clear previous timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Cancel previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
// Debounce search to avoid too many API calls
return new Promise<SearchResponse>((resolve, reject) => {
debounceTimeoutRef.current = setTimeout(async () => {
if (!query.trim()) {
setState(prev => ({
...prev,
query: '',
results: [],
isLoading: false,
error: null,
hasSearched: false,
total: 0,
searchTime: null,
}));
resolve({ results: [], total: 0, query: '' });
return;
}
setState(prev => ({
...prev,
query: query.trim(),
isLoading: true,
error: null,
}));
try {
const searchParams = new URLSearchParams({
q: query.trim(),
type: options.type || 'all',
limit: (options.limit || 20).toString(),
});
const response = await fetch(`/api/search?${searchParams}`, {
signal: abortControllerRef.current?.signal,
});
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
}
const data: SearchResponse = await response.json();
setState(prev => ({
...prev,
results: data.results,
isLoading: false,
hasSearched: true,
total: data.total,
searchTime: data.searchTime || null,
error: null,
}));
resolve(data);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Request was cancelled, don't update state
return;
}
const errorMessage = error instanceof Error ? error.message : 'Search failed';
setState(prev => ({
...prev,
isLoading: false,
error: errorMessage,
}));
reject(error);
}
}, 300); // 300ms debounce
});
}, []);
// Clear search results
const clearSearch = useCallback(() => {
setState({
query: '',
results: [],
isLoading: false,
error: null,
hasSearched: false,
total: 0,
searchTime: null,
});
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
...state,
search,
clearSearch,
};
}