diff --git a/CLAUDE.md b/CLAUDE.md index 60e6e19..8bfc739 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,10 @@ Deployment: Private Docker Image Repo: http://192.168.2.212:3000/tigeren/ +Enhancement: +1. Add text(txt) viewer +2. Add ffmepg transcode for non-mp4 files +3. use hashed folder structure to store thumbnails Development Rules: 1. Everytime after making all the changes, run 'pnpm build' to verify the changes are compiling correct. diff --git a/data/media.db b/data/media.db index 3c45987..d746d98 100644 Binary files a/data/media.db and b/data/media.db differ diff --git a/package-lock.json b/package-lock.json index ccade97..ccf7a47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "clsx": "^2.1.1", "fluent-ffmpeg": "^2.1.3", "glob": "^11.0.3", + "iconv-lite": "^0.7.0", "lucide-react": "^0.541.0", "next": "15.5.0", "react": "19.1.0", @@ -1558,6 +1559,22 @@ "node": ">= 0.4" } }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2399,6 +2416,12 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", diff --git a/package.json b/package.json index cdd8beb..60235be 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "fluent-ffmpeg": "^2.1.3", "glob": "^11.0.3", + "iconv-lite": "^0.7.0", "lucide-react": "^0.541.0", "next": "15.5.0", "react": "19.1.0", diff --git a/src/app/api/files/content/route.ts b/src/app/api/files/content/route.ts new file mode 100644 index 0000000..a010e92 --- /dev/null +++ b/src/app/api/files/content/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; + +const TEXT_EXTENSIONS = ["txt", "md", "json", "xml", "csv", "log", "conf", "ini", "yaml", "yml", "html", "css", "js", "ts", "py", "sh", "bat", "php", "sql"]; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const filePath = searchParams.get("path"); + const encoding = searchParams.get("encoding") || "utf8"; + + if (!filePath) { + return NextResponse.json({ error: "Path is required" }, { status: 400 }); + } + + try { + // Validate file exists + if (!fs.existsSync(filePath)) { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + + // Check if it's a file (not directory) + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + return NextResponse.json({ error: "Path is not a file" }, { status: 400 }); + } + + // Check if it's a text file + const ext = path.extname(filePath).toLowerCase().replace('.', ''); + if (!TEXT_EXTENSIONS.includes(ext)) { + return NextResponse.json({ error: "File type not supported" }, { status: 400 }); + } + + // Check file size (limit to 10MB for text files) + const maxSize = 10 * 1024 * 1024; // 10MB + if (stats.size > maxSize) { + return NextResponse.json({ error: "File too large (max 10MB)" }, { status: 413 }); + } + + // Read file with specified encoding + let content = ''; + try { + if (encoding === 'utf8') { + content = fs.readFileSync(filePath, 'utf-8'); + } else { + // For non-UTF-8 encodings, read as buffer and convert + const buffer = fs.readFileSync(filePath); + const iconv = require('iconv-lite'); + content = iconv.decode(buffer, encoding); + } + } catch (err) { + // If specified encoding fails, try fallback encodings + const fallbackEncodings = ['utf8', 'gbk', 'gb2312', 'big5', 'latin1']; + + for (const fallbackEncoding of fallbackEncodings) { + try { + if (fallbackEncoding === 'utf8') { + content = fs.readFileSync(filePath, 'utf-8'); + } else { + const buffer = fs.readFileSync(filePath); + const iconv = require('iconv-lite'); + content = iconv.decode(buffer, fallbackEncoding); + } + + if (content && content.length > 0) { + break; + } + } catch (fallbackErr) { + continue; + } + } + } + + // If no encoding worked, try reading as buffer and return as base64 + if (!content || content.length === 0) { + try { + const buffer = fs.readFileSync(filePath); + content = buffer.toString('base64'); + return NextResponse.json({ + content, + size: stats.size, + path: filePath, + name: path.basename(filePath), + encoding: 'base64' + }); + } catch (err) { + return NextResponse.json({ error: 'Failed to read file content' }, { status: 500 }); + } + } + + return NextResponse.json({ + content, + size: stats.size, + path: filePath, + name: path.basename(filePath), + encoding: encoding + }); + } catch (error: any) { + console.error('Error reading file:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/files/route.ts b/src/app/api/files/route.ts index f1d8fd9..0ed1f5b 100644 --- a/src/app/api/files/route.ts +++ b/src/app/api/files/route.ts @@ -6,6 +6,7 @@ import { getDatabase } from '@/db'; const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"]; const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"]; +const TEXT_EXTENSIONS = ["txt", "md", "json", "xml", "csv", "log", "conf", "ini", "yaml", "yml", "html", "css", "js", "ts", "py", "sh", "bat", "php", "sql"]; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -37,6 +38,8 @@ export async function GET(request: Request) { type = 'video'; } else if (PHOTO_EXTENSIONS.some(p => p.toLowerCase() === cleanExt)) { type = 'photo'; + } else if (TEXT_EXTENSIONS.some(t => t.toLowerCase() === cleanExt)) { + type = 'text'; } // Find matching media file in database diff --git a/src/app/api/texts/[id]/route.ts b/src/app/api/texts/[id]/route.ts new file mode 100644 index 0000000..aa4fc97 --- /dev/null +++ b/src/app/api/texts/[id]/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server'; +import { getDatabase } from '@/db'; +import fs from 'fs'; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const encoding = searchParams.get("encoding") || "utf8"; + + try { + const textId = parseInt(id); + if (isNaN(textId)) { + return NextResponse.json({ error: 'Invalid text ID' }, { status: 400 }); + } + + const db = getDatabase(); + const text = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(textId, 'text') as { id: number; path: string; title: string; size: number }; + + if (!text) { + return NextResponse.json({ error: 'Text file not found' }, { status: 404 }); + } + + // Check if file exists + if (!fs.existsSync(text.path)) { + return NextResponse.json({ error: 'File not found on filesystem' }, { status: 404 }); + } + + // Read file with specified encoding + let content = ''; + try { + if (encoding === 'utf8') { + content = fs.readFileSync(text.path, 'utf-8'); + } else { + // For non-UTF-8 encodings, read as buffer and convert + const buffer = fs.readFileSync(text.path); + const iconv = require('iconv-lite'); + content = iconv.decode(buffer, encoding); + } + } catch (err) { + // If specified encoding fails, try fallback encodings + const fallbackEncodings = ['utf8', 'gbk', 'gb2312', 'big5', 'latin1']; + + for (const fallbackEncoding of fallbackEncodings) { + try { + if (fallbackEncoding === 'utf8') { + content = fs.readFileSync(text.path, 'utf-8'); + } else { + const buffer = fs.readFileSync(text.path); + const iconv = require('iconv-lite'); + content = iconv.decode(buffer, fallbackEncoding); + } + + if (content && content.length > 0) { + break; + } + } catch (fallbackErr) { + continue; + } + } + } + + // If no encoding worked, try reading as buffer and return as base64 + if (!content || content.length === 0) { + try { + const buffer = fs.readFileSync(text.path); + content = buffer.toString('base64'); + return NextResponse.json({ + id: text.id, + title: text.title, + path: text.path, + content, + size: text.size, + encoding: 'base64' + }); + } catch (err) { + return NextResponse.json({ error: 'Failed to read file content' }, { status: 500 }); + } + } + + return NextResponse.json({ + id: text.id, + title: text.title, + path: text.path, + content, + size: text.size, + encoding: encoding + }); + } catch (error: any) { + console.error('Error reading text file:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/texts/route.ts b/src/app/api/texts/route.ts new file mode 100644 index 0000000..815421e --- /dev/null +++ b/src/app/api/texts/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server'; +import { getDatabase } from '@/db'; +import fs from 'fs'; +import path from 'path'; + +export async function GET(request: Request) { + const db = getDatabase(); + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get('limit') || '50'); + const offset = parseInt(searchParams.get('offset') || '0'); + const search = searchParams.get('search'); + + try { + let query = ` + SELECT m.*, l.path as library_path + FROM media m + JOIN libraries l ON m.library_id = l.id + WHERE m.type = 'text' + `; + let params: any[] = []; + + 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 texts = db.prepare(query).all(...params) as { id: number; title: string; path: string; size: number; thumbnail: string; type: string; bookmark_count: number; avg_rating: number; star_count: number; library_path: string; created_at: string }[]; + + const totalQuery = ` + SELECT COUNT(*) as count + FROM media m + WHERE m.type = 'text' + ${search ? 'AND (m.title LIKE ? OR m.path LIKE ?)' : ''} + `; + const totalParams = search ? [`%${search}%`, `%${search}%`] : []; + const total = (db.prepare(totalQuery).get(...totalParams) as { count: number }).count; + + return NextResponse.json({ + texts, + pagination: { + total, + limit, + offset, + hasMore: offset + limit < total + } + }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + const { mediaId } = await request.json(); + + try { + const db = getDatabase(); + const text = db.prepare('SELECT * FROM media WHERE id = ? AND type = "text"').get(mediaId) as { id: number; path: string; title: string; size: number }; + + if (!text) { + return NextResponse.json({ error: 'Text file not found' }, { status: 404 }); + } + + const content = fs.readFileSync(text.path, 'utf-8'); + + return NextResponse.json({ + id: text.id, + title: text.title, + path: text.path, + content, + size: text.size + }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/folder-viewer/page.tsx b/src/app/folder-viewer/page.tsx index b2ca3f1..27023d3 100644 --- a/src/app/folder-viewer/page.tsx +++ b/src/app/folder-viewer/page.tsx @@ -5,7 +5,11 @@ import { useState, useEffect, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import PhotoViewer from "@/components/photo-viewer"; import VideoViewer from "@/components/video-viewer"; +import TextViewer from "@/components/text-viewer"; import VirtualizedFolderGrid from "@/components/virtualized-media-grid"; +import { createPortal } from "react-dom"; +import { X, Copy, Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; interface FileSystemItem { name: string; @@ -34,6 +38,8 @@ const FolderViewerPage = () => { const [selectedPhoto, setSelectedPhoto] = useState(null); const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false); const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); + const [selectedText, setSelectedText] = useState(null); + const [isTextViewerOpen, setIsTextViewerOpen] = useState(false); const [libraries, setLibraries] = useState<{id: number, path: string}[]>([]); useEffect(() => { @@ -157,8 +163,170 @@ const FolderViewerPage = () => { setSelectedPhoto(null); }; + const handleTextClick = (item: FileSystemItem) => { + if (item.type === 'text') { + setSelectedText(item); + setIsTextViewerOpen(true); + } + }; + + const handleCloseTextViewer = () => { + setIsTextViewerOpen(false); + setSelectedText(null); + }; + const [currentItems, setCurrentItems] = useState([]); + // Custom Text Viewer Component for files without IDs + const FolderTextViewer = ({ + text, + isOpen, + onClose + }: { + text: FileSystemItem; + isOpen: boolean; + onClose: () => void; + }) => { + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [selectedEncoding, setSelectedEncoding] = useState('utf8'); + const [availableEncodings] = useState(['utf8', 'gbk', 'gb2312', 'big5', 'latin1']); + + useEffect(() => { + if (isOpen && text) { + loadTextContent(); + } + }, [isOpen, text, selectedEncoding]); + + const loadTextContent = async () => { + setLoading(true); + setError(''); + setContent(''); + + try { + const response = await fetch(`/api/files/content?path=${encodeURIComponent(text.path)}&encoding=${selectedEncoding}`); + if (!response.ok) { + throw new Error('Failed to load text file'); + } + const data = await response.json(); + + // Handle base64 encoded content + if (data.encoding === 'base64') { + try { + const decodedContent = atob(data.content); + setContent(decodedContent); + } catch (err) { + setError('Failed to decode file content'); + } + } else { + setContent(data.content || ''); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load text file'); + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + return createPortal( +
+
+ {/* Header */} +
+
+

{text.name}

+

{text.path}

+
+ +
+ + {/* Encoding Selection */} +
+ Encoding: + + + {selectedEncoding === 'utf8' ? '(Default)' : + selectedEncoding === 'gbk' || selectedEncoding === 'gb2312' ? '(Chinese)' : + selectedEncoding === 'big5' ? '(Traditional Chinese)' : '(Other)'} + +
+ + {/* Content */} +
+ {loading ? ( +
+
Loading...
+
+ ) : error ? ( +
+
{error}
+
+ ) : ( +
+
+                  {content}
+                
+
+ )} +
+ + {/* Footer */} +
+
+ Size: {formatFileSize(text.size)} +
+
+ + +
+
+
+
, + document.body + ); + }; + const handleNextPhoto = () => { // Navigate to next photo, skipping videos const photos = currentItems.filter(item => item.type === 'photo' && item.id); @@ -219,6 +387,7 @@ const FolderViewerPage = () => { currentPath={path} onVideoClick={handleVideoClick} onPhotoClick={handlePhotoClick} + onTextClick={handleTextClick} onBackClick={handleBackClick} onBreadcrumbClick={handleBreadcrumbClick} breadcrumbs={getBreadcrumbs(path)} @@ -248,6 +417,17 @@ const FolderViewerPage = () => { showRatings={false} formatFileSize={formatFileSize} /> + + {/* Text Viewer */} + + + {/* Custom Text Viewer for files without IDs */} + {selectedText && } ); }; diff --git a/src/app/globals.css b/src/app/globals.css index 2ffdb3f..ce3c37f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -99,6 +99,34 @@ background: transparent; } +/* Text viewer specific scrollbar */ +.custom-scrollbar::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: hsl(var(--muted) / 0.1); + border-radius: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.4); + border-radius: 6px; + border: 2px solid transparent; + background-clip: content-box; + transition: background 0.2s ease; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.6); + background-clip: content-box; +} + +.custom-scrollbar::-webkit-scrollbar-corner { + background: hsl(var(--muted) / 0.1); +} + /* Custom scrollbar for react-window grids */ .custom-scrollbar::-webkit-scrollbar { width: 6px; diff --git a/src/app/texts/page.tsx b/src/app/texts/page.tsx new file mode 100644 index 0000000..ab2c8c9 --- /dev/null +++ b/src/app/texts/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState } from "react"; +import InfiniteVirtualGrid from "@/components/infinite-virtual-grid"; +import { FileText } from "lucide-react"; + +interface TextFile { + id: number; + title: string; + path: string; + size: number; + thumbnail: string; + type: string; + bookmark_count: number; + avg_rating: number; + star_count: number; +} + +const TextsPage = () => { + const [selectedText, setSelectedText] = useState(null); + const [isViewerOpen, setIsViewerOpen] = useState(false); + const [textContent, setTextContent] = useState(""); + + const handleTextClick = async (text: TextFile) => { + try { + const response = await fetch(`/api/texts/${text.id}`); + const data = await response.json(); + setTextContent(data.content); + setSelectedText(text); + setIsViewerOpen(true); + } catch (error) { + console.error('Error loading text file:', error); + } + }; + + const handleCloseViewer = () => { + setIsViewerOpen(false); + setSelectedText(null); + setTextContent(""); + }; + + const handleBookmark = async (textId: number) => { + try { + await fetch(`/api/bookmarks/${textId}`, { method: 'POST' }); + } catch (error) { + console.error('Error bookmarking text:', error); + } + }; + + const handleUnbookmark = async (textId: number) => { + try { + await fetch(`/api/bookmarks/${textId}`, { method: 'DELETE' }); + } catch (error) { + console.error('Error unbookmarking text:', error); + } + }; + + const handleRate = async (textId: number, rating: number) => { + try { + await fetch(`/api/stars/${textId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rating }) + }); + } catch (error) { + console.error('Error rating text:', error); + } + }; + + return ( + <> + + + {/* Text Viewer Modal */} + {isViewerOpen && selectedText && ( +
+
+ {/* Header */} +
+
+

{selectedText.title}

+

{selectedText.path}

+
+ +
+ + {/* Content */} +
+
+                {textContent}
+              
+
+ + {/* Footer */} +
+
+ Size: {(selectedText.size / 1024).toFixed(2)} KB +
+
+ + +
+
+
+
+ )} + + ); +}; + +export default TextsPage; \ No newline at end of file diff --git a/src/components/infinite-virtual-grid.tsx b/src/components/infinite-virtual-grid.tsx index 5aaf87e..16226be 100644 --- a/src/components/infinite-virtual-grid.tsx +++ b/src/components/infinite-virtual-grid.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { FixedSizeGrid } from 'react-window'; import { Card, CardContent } from '@/components/ui/card'; import { StarRating } from '@/components/star-rating'; -import { Film, Image as ImageIcon, HardDrive, Search, Bookmark } from 'lucide-react'; +import { Film, Image as ImageIcon, HardDrive, Search, Bookmark, FileText } from 'lucide-react'; import { Input } from '@/components/ui/input'; interface MediaItem { @@ -20,7 +20,7 @@ interface MediaItem { } interface InfiniteVirtualGridProps { - type: 'video' | 'photo' | 'bookmark'; + type: 'video' | 'photo' | 'text' | 'bookmark'; onItemClick: (item: MediaItem, index?: number) => void; onBookmark: (id: number) => Promise; onUnbookmark: (id: number) => Promise; @@ -357,7 +357,7 @@ export default function InfiniteVirtualGrid({ return (
-
+
@@ -375,11 +375,11 @@ export default function InfiniteVirtualGrid({ >
{item.title} { - (e.target as HTMLImageElement).src = type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg"; + (e.target as HTMLImageElement).src = type === 'video' ? "/placeholder-video.svg" : type === 'text' ? "/placeholder.svg" : "/placeholder-photo.svg"; }} />
@@ -388,6 +388,8 @@ export default function InfiniteVirtualGrid({
{type === 'video' ? : + type === 'text' ? + : }
@@ -397,6 +399,8 @@ export default function InfiniteVirtualGrid({
{type === 'video' ? : + type === 'text' ? + : }
@@ -423,7 +427,7 @@ export default function InfiniteVirtualGrid({
- {type === 'video' && item.bookmark_count > 0 && ( + {(type === 'video' || type === 'text') && item.bookmark_count > 0 && (
@@ -437,7 +441,7 @@ export default function InfiniteVirtualGrid({ {formatFileSize(item.size)}
- {type === 'video' && item.bookmark_count > 0 && ( + {(type === 'video' || type === 'text') && item.bookmark_count > 0 && ( {item.bookmark_count} @@ -472,6 +476,8 @@ export default function InfiniteVirtualGrid({
{type === 'video' ? : + type === 'text' ? + : }
@@ -491,12 +497,15 @@ export default function InfiniteVirtualGrid({
{type === 'video' ? : type === 'photo' ? : + type === 'text' ? + : }
@@ -549,12 +558,15 @@ export default function InfiniteVirtualGrid({
{type === 'video' ? : type === 'photo' ? : + type === 'text' ? + : }
diff --git a/src/components/text-viewer.tsx b/src/components/text-viewer.tsx new file mode 100644 index 0000000..51bf5fe --- /dev/null +++ b/src/components/text-viewer.tsx @@ -0,0 +1,419 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { X, FileText, Download, Copy, Search, ChevronUp, ChevronDown } from 'lucide-react'; +import { createPortal } from 'react-dom'; + +interface TextFile { + id: number; + title: string; + path: string; + size: number; + type: string; +} + +interface FileSystemItem { + name: string; + path: string; + isDirectory: boolean; + size: number; + thumbnail?: string; + type?: string; + id?: number; +} + +interface TextViewerProps { + text: TextFile | FileSystemItem; + isOpen: boolean; + onClose: () => void; + formatFileSize?: (bytes: number) => string; +} + +export default function TextViewer({ + text, + isOpen, + onClose, + formatFileSize +}: TextViewerProps) { + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [currentSearchIndex, setCurrentSearchIndex] = useState(-1); + const [searchResults, setSearchResults] = useState([]); + const [fontSize, setFontSize] = useState(14); + const [showLineNumbers, setShowLineNumbers] = useState(true); + const [wordWrap, setWordWrap] = useState(true); + const [selectedEncoding, setSelectedEncoding] = useState('utf8'); + const [availableEncodings] = useState(['utf8', 'gbk', 'gb2312', 'big5', 'latin1']); + const contentRef = useRef(null); + + useEffect(() => { + if (isOpen && text) { + loadTextContent(); + } + }, [isOpen, text, selectedEncoding]); + + const loadTextContent = async () => { + if (!text || !('id' in text) || !text.id) { + setError('Invalid text file'); + return; + } + + setLoading(true); + setError(''); + setContent(''); + + try { + const response = await fetch(`/api/texts/${text.id}?encoding=${selectedEncoding}`); + if (!response.ok) { + throw new Error('Failed to load text file'); + } + const data = await response.json(); + + // Handle base64 encoded content + if (data.encoding === 'base64') { + try { + const decodedContent = atob(data.content); + setContent(decodedContent); + } catch (err) { + setError('Failed to decode file content'); + } + } else { + setContent(data.content || ''); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load text file'); + } finally { + setLoading(false); + } + }; + + const handleSearch = (term: string) => { + setSearchTerm(term); + if (!term.trim()) { + setSearchResults([]); + setCurrentSearchIndex(-1); + return; + } + + const lines = content.split('\n'); + const results: number[] = []; + + lines.forEach((line, index) => { + if (line.toLowerCase().includes(term.toLowerCase())) { + results.push(index); + } + }); + + setSearchResults(results); + setCurrentSearchIndex(results.length > 0 ? 0 : -1); + }; + + const navigateSearch = (direction: 'next' | 'prev') => { + if (searchResults.length === 0) return; + + if (direction === 'next') { + setCurrentSearchIndex((prev) => (prev + 1) % searchResults.length); + } else { + setCurrentSearchIndex((prev) => (prev - 1 + searchResults.length) % searchResults.length); + } + }; + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(content); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } + }; + + const downloadFile = () => { + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = ('name' in text ? text.name : text.title) || 'text.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const getTextTitle = () => { + if ('name' in text) return text.name; + if ('title' in text) return text.title; + return 'Text File'; + }; + + const getTextSize = () => { + if (!text) return '0 Bytes'; + if (formatFileSize) { + return formatFileSize(text.size); + } + const bytes = text.size; + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const formatContent = () => { + if (!content) return []; + + const lines = content.split('\n'); + return lines.map((line, index) => ({ + lineNumber: index + 1, + content: line, + isHighlighted: searchResults.includes(index) + })); + }; + + const scrollToLine = (lineNumber: number) => { + const element = document.getElementById(`line-${lineNumber}`); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }; + + useEffect(() => { + if (currentSearchIndex >= 0 && searchResults.length > 0) { + const lineNumber = searchResults[currentSearchIndex] + 1; + scrollToLine(lineNumber); + } + }, [currentSearchIndex, searchResults]); + + // Keyboard shortcuts + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key) { + case 'f': + e.preventDefault(); + const searchInput = document.getElementById('text-search') as HTMLInputElement; + searchInput?.focus(); + break; + case 'c': + if (e.shiftKey) { + e.preventDefault(); + copyToClipboard(); + } + break; + case 's': + e.preventDefault(); + downloadFile(); + break; + } + } else { + switch (e.key) { + case 'Escape': + e.preventDefault(); + onClose(); + break; + case 'F3': + e.preventDefault(); + if (e.shiftKey) { + navigateSearch('prev'); + } else { + navigateSearch('next'); + } + break; + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose, searchResults, currentSearchIndex]); + + if (!isOpen || typeof window === 'undefined') return null; + + const formattedLines = formatContent(); + + return createPortal( +
+
+ {/* Header */} +
+
+ +
+

{getTextTitle()}

+

{getTextSize()}

+
+
+ +
+ {/* Search */} +
+ + handleSearch(e.target.value)} + className="pl-10 pr-8 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + {searchResults.length > 0 && ( + + {currentSearchIndex + 1}/{searchResults.length} + + )} +
+ + {/* Search Navigation */} + {searchResults.length > 0 && ( +
+ + +
+ )} + + {/* Actions */} + + + + + +
+
+ + {/* Toolbar */} +
+
+
+ + +
+ +
+ + + + {selectedEncoding === 'utf8' ? '(Default)' : + selectedEncoding === 'gbk' || selectedEncoding === 'gb2312' ? '(Chinese)' : + selectedEncoding === 'big5' ? '(Traditional Chinese)' : '(Other)'} + +
+ + + + +
+ +
+ {formattedLines.length} lines +
+
+ + {/* Content */} +
+ {loading ? ( +
+
Loading...
+
+ ) : error ? ( +
+
{error}
+
+ ) : ( +
+
+ {formattedLines.map(({ lineNumber, content, isHighlighted }) => ( +
+ {showLineNumbers && ( +
+ {lineNumber} +
+ )} +
+ {content || ' '} +
+
+ ))} +
+
+ )} +
+
+
, + document.body + ); +} diff --git a/src/components/video-player.tsx b/src/components/video-player.tsx deleted file mode 100644 index f0f394b..0000000 --- a/src/components/video-player.tsx +++ /dev/null @@ -1,218 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect } from 'react'; -import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX } from 'lucide-react'; - -interface VideoPlayerProps { - video: { - id: number; - title: string; - path: string; - size: number; - thumbnail: string; - }; - isOpen: boolean; - onClose: () => void; -} - -export default function VideoPlayer({ video, isOpen, onClose }: VideoPlayerProps) { - const [isPlaying, setIsPlaying] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); - const [isMuted, setIsMuted] = useState(false); - const [volume, setVolume] = useState(1); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const videoRef = useRef(null); - - useEffect(() => { - if (isOpen && videoRef.current) { - videoRef.current.src = `/api/stream/${video.id}`; - videoRef.current.load(); - } - }, [isOpen, video.id]); - - const handlePlayPause = () => { - if (videoRef.current) { - if (isPlaying) { - videoRef.current.pause(); - } else { - videoRef.current.play(); - } - setIsPlaying(!isPlaying); - } - }; - - const handleFullscreen = () => { - if (videoRef.current) { - if (!isFullscreen) { - videoRef.current.requestFullscreen(); - } else { - document.exitFullscreen(); - } - } - }; - - const handleMute = () => { - if (videoRef.current) { - videoRef.current.muted = !isMuted; - setIsMuted(!isMuted); - } - }; - - const handleVolumeChange = (e: React.ChangeEvent) => { - if (videoRef.current) { - const newVolume = parseFloat(e.target.value); - videoRef.current.volume = newVolume; - setVolume(newVolume); - setIsMuted(newVolume === 0); - } - }; - - const handleTimeUpdate = () => { - if (videoRef.current) { - setCurrentTime(videoRef.current.currentTime); - } - }; - - const handleLoadedMetadata = () => { - if (videoRef.current) { - setDuration(videoRef.current.duration); - } - }; - - const handleProgressClick = (e: React.MouseEvent) => { - if (videoRef.current) { - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const newTime = (clickX / rect.width) * duration; - videoRef.current.currentTime = newTime; - setCurrentTime(newTime); - } - }; - - const formatTime = (time: number) => { - const minutes = Math.floor(time / 60); - const seconds = Math.floor(time % 60); - return `${minutes}:${seconds.toString().padStart(2, '0')}`; - }; - - useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; - - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); - }, []); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - if (e.key === ' ') { - e.preventDefault(); - handlePlayPause(); - } - }; - - if (isOpen) { - document.addEventListener('keydown', handleKeyDown); - } - - return () => document.removeEventListener('keydown', handleKeyDown); - }, [isOpen, onClose]); - - if (!isOpen) return null; - - return ( -
-
- {/* Close button */} - - - {/* Video container */} -
- - - {/* Title overlay */} -
-

{video.title}

-
- - {/* Controls overlay */} -
-
- {/* Progress bar */} -
-
-
- - {/* Controls */} -
-
- - -
- - -
- - - {formatTime(currentTime)} / {formatTime(duration)} - -
- - -
-
-
-
-
-
- ); -} \ No newline at end of file diff --git a/src/components/virtualized-media-grid.tsx b/src/components/virtualized-media-grid.tsx index cb4d79e..b4f496d 100644 --- a/src/components/virtualized-media-grid.tsx +++ b/src/components/virtualized-media-grid.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { FixedSizeGrid } from 'react-window'; import { Card, CardContent } from '@/components/ui/card'; import { StarRating } from '@/components/star-rating'; -import { Film, Image as ImageIcon, HardDrive, Search, Folder, Play, ChevronLeft, Home } from 'lucide-react'; +import { Film, Image as ImageIcon, HardDrive, Search, Folder, Play, ChevronLeft, Home, FileText } from 'lucide-react'; import { Button } from '@/components/ui/button'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; @@ -30,6 +30,7 @@ interface VirtualizedFolderGridProps { currentPath: string; onVideoClick: (item: FileSystemItem) => void; onPhotoClick: (item: FileSystemItem, index: number) => void; + onTextClick: (item: FileSystemItem) => void; onBackClick: () => void; onBreadcrumbClick: (path: string) => void; breadcrumbs: BreadcrumbItem[]; @@ -43,6 +44,7 @@ export default function VirtualizedFolderGrid({ currentPath, onVideoClick, onPhotoClick, + onTextClick, onBackClick, onBreadcrumbClick, breadcrumbs, @@ -161,11 +163,12 @@ export default function VirtualizedFolderGrid({ if (item.isDirectory) return ; if (item.type === 'photo') return ; if (item.type === 'video') return ; + if (item.type === 'text') return ; return ; }; const isMediaFile = (item: FileSystemItem) => { - return item.type === 'video' || item.type === 'photo'; + return item.type === 'video' || item.type === 'photo' || item.type === 'text'; }; // Calculate responsive column count and width @@ -220,7 +223,7 @@ export default function VirtualizedFolderGrid({ return (
-
+
{ @@ -231,6 +234,9 @@ export default function VirtualizedFolderGrid({ e.preventDefault(); const photoIndex = items.filter(i => i.type === 'photo' && i.id).findIndex(i => i.id === item.id); onPhotoClick(item, photoIndex); + } else if (item.type === 'text' && item.id) { + e.preventDefault(); + onTextClick(item); } }}>
@@ -243,11 +249,22 @@ export default function VirtualizedFolderGrid({
) : isMediaFile(item) ? (
- {item.name} + {item.type === 'text' ? ( + // For text files, show a text icon instead of thumbnail +
+
+ +
+
+ ) : ( + // For photos and videos, show thumbnail + {item.name} + )} {item.type === 'video' && (
@@ -258,6 +275,11 @@ export default function VirtualizedFolderGrid({
)} + {item.type === 'text' && ( +
+ +
+ )}
) : (
{ return new Promise((resolve, reject) => { @@ -35,34 +36,37 @@ const generatePhotoThumbnail = (photoPath: string, thumbnailPath: string) => { const scanLibrary = async (library: { id: number; path: string }) => { const db = getDatabase(); - // Scan videos - handle all case variations - const videoFiles = await glob(`${library.path}/**/*.*`, { nodir: true }); - - // Scan photos - handle all case variations - const photoFiles = await glob(`${library.path}/**/*.*`, { nodir: true }); + // Scan all files - handle all case variations + const allFiles = await glob(`${library.path}/**/*.*`, { nodir: true }); // Filter files by extension (case-insensitive) - const filteredVideoFiles = videoFiles.filter(file => { + const filteredVideoFiles = allFiles.filter(file => { const ext = path.extname(file).toLowerCase().replace('.', ''); return VIDEO_EXTENSIONS.includes(ext); }); - const filteredPhotoFiles = photoFiles.filter(file => { + const filteredPhotoFiles = allFiles.filter(file => { const ext = path.extname(file).toLowerCase().replace('.', ''); return PHOTO_EXTENSIONS.includes(ext); }); + + const filteredTextFiles = allFiles.filter(file => { + const ext = path.extname(file).toLowerCase().replace('.', ''); + return TEXT_EXTENSIONS.includes(ext); + }); - const allFiles = [...filteredVideoFiles, ...filteredPhotoFiles]; + const mediaFiles = [...filteredVideoFiles, ...filteredPhotoFiles, ...filteredTextFiles]; - for (const file of allFiles) { + for (const file of mediaFiles) { const stats = fs.statSync(file); const title = path.basename(file); const ext = path.extname(file).toLowerCase(); const cleanExt = ext.replace('.', '').toLowerCase(); const isVideo = VIDEO_EXTENSIONS.some(v => v.toLowerCase() === cleanExt); const isPhoto = PHOTO_EXTENSIONS.some(p => p.toLowerCase() === cleanExt); + const isText = TEXT_EXTENSIONS.some(t => t.toLowerCase() === cleanExt); - const mediaType = isVideo ? "video" : "photo"; + const mediaType = isVideo ? "video" : isPhoto ? "photo" : "text"; const thumbnailFileName = `${path.parse(title).name}_${Date.now()}.png`; const thumbnailPath = path.join(process.cwd(), "public", "thumbnails", thumbnailFileName); const thumbnailUrl = `/thumbnails/${thumbnailFileName}`; @@ -93,7 +97,7 @@ const scanLibrary = async (library: { id: number; path: string }) => { } catch (thumbnailError) { console.warn(`Thumbnail generation failed for ${file}:`, thumbnailError); // Use fallback thumbnail based on media type - finalThumbnailUrl = isVideo ? "/placeholder-video.svg" : "/placeholder-photo.svg"; + finalThumbnailUrl = isVideo ? "/placeholder-video.svg" : isPhoto ? "/placeholder-photo.svg" : "/placeholder.svg"; } const media = {