From 407c702e88b9228f8d531ac61a6491bc704212c5 Mon Sep 17 00:00:00 2001 From: tigeren Date: Sun, 31 Aug 2025 08:42:30 +0000 Subject: [PATCH] feat: add text file support and viewer enhancements - Introduced a text viewer for displaying various text file formats, including .txt, .md, and more. - Implemented API routes for fetching text file content with encoding options and error handling. - Enhanced folder viewer to support text file selection and integrated the new text viewer component. - Updated global styles to include custom scrollbar styles for the text viewer. - Added support for hashed folder structure to store thumbnails for better organization. - Included new dependencies for text encoding handling and updated package configurations. --- CLAUDE.md | 4 + data/media.db | Bin 122880 -> 122880 bytes package-lock.json | 23 ++ package.json | 1 + src/app/api/files/content/route.ts | 102 ++++++ src/app/api/files/route.ts | 3 + src/app/api/texts/[id]/route.ts | 92 +++++ src/app/api/texts/route.ts | 78 ++++ src/app/folder-viewer/page.tsx | 180 ++++++++++ src/app/globals.css | 28 ++ src/app/texts/page.tsx | 140 ++++++++ src/components/infinite-virtual-grid.tsx | 26 +- src/components/text-viewer.tsx | 419 ++++++++++++++++++++++ src/components/video-player.tsx | 218 ----------- src/components/virtualized-media-grid.tsx | 38 +- src/lib/scanner.ts | 26 +- 16 files changed, 1134 insertions(+), 244 deletions(-) create mode 100644 src/app/api/files/content/route.ts create mode 100644 src/app/api/texts/[id]/route.ts create mode 100644 src/app/api/texts/route.ts create mode 100644 src/app/texts/page.tsx create mode 100644 src/components/text-viewer.tsx delete mode 100644 src/components/video-player.tsx 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 3c45987f04646f30c010d81e183f8b2530bafae5..d746d9837783c1686b53af27f6938cf50fb7568a 100644 GIT binary patch delta 11209 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 XYjAw1xe{)JRJw(_0)hBDoaFu=lM5C6 delta 11037 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 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 = {