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.
This commit is contained in:
parent
0c8cb78ad2
commit
407c702e88
|
|
@ -36,6 +36,10 @@ Deployment:
|
||||||
Private Docker Image Repo:
|
Private Docker Image Repo:
|
||||||
http://192.168.2.212:3000/tigeren/
|
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:
|
Development Rules:
|
||||||
1. Everytime after making all the changes, run 'pnpm build' to verify the changes are compiling correct.
|
1. Everytime after making all the changes, run 'pnpm build' to verify the changes are compiling correct.
|
||||||
|
|
|
||||||
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
|
|
@ -16,6 +16,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
|
"iconv-lite": "^0.7.0",
|
||||||
"lucide-react": "^0.541.0",
|
"lucide-react": "^0.541.0",
|
||||||
"next": "15.5.0",
|
"next": "15.5.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|
@ -1558,6 +1559,22 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|
@ -2399,6 +2416,12 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.26.0",
|
"version": "0.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
|
"iconv-lite": "^0.7.0",
|
||||||
"lucide-react": "^0.541.0",
|
"lucide-react": "^0.541.0",
|
||||||
"next": "15.5.0",
|
"next": "15.5.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { getDatabase } from '@/db';
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
|
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
|
||||||
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"];
|
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) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -37,6 +38,8 @@ export async function GET(request: Request) {
|
||||||
type = 'video';
|
type = 'video';
|
||||||
} else if (PHOTO_EXTENSIONS.some(p => p.toLowerCase() === cleanExt)) {
|
} else if (PHOTO_EXTENSIONS.some(p => p.toLowerCase() === cleanExt)) {
|
||||||
type = 'photo';
|
type = 'photo';
|
||||||
|
} else if (TEXT_EXTENSIONS.some(t => t.toLowerCase() === cleanExt)) {
|
||||||
|
type = 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find matching media file in database
|
// Find matching media file in database
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,11 @@ import { useState, useEffect, Suspense } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import PhotoViewer from "@/components/photo-viewer";
|
import PhotoViewer from "@/components/photo-viewer";
|
||||||
import VideoViewer from "@/components/video-viewer";
|
import VideoViewer from "@/components/video-viewer";
|
||||||
|
import TextViewer from "@/components/text-viewer";
|
||||||
import VirtualizedFolderGrid from "@/components/virtualized-media-grid";
|
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 {
|
interface FileSystemItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -34,6 +38,8 @@ const FolderViewerPage = () => {
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState<FileSystemItem | null>(null);
|
const [selectedPhoto, setSelectedPhoto] = useState<FileSystemItem | null>(null);
|
||||||
const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false);
|
const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false);
|
||||||
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
|
||||||
|
const [selectedText, setSelectedText] = useState<FileSystemItem | null>(null);
|
||||||
|
const [isTextViewerOpen, setIsTextViewerOpen] = useState(false);
|
||||||
const [libraries, setLibraries] = useState<{id: number, path: string}[]>([]);
|
const [libraries, setLibraries] = useState<{id: number, path: string}[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -157,8 +163,170 @@ const FolderViewerPage = () => {
|
||||||
setSelectedPhoto(null);
|
setSelectedPhoto(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTextClick = (item: FileSystemItem) => {
|
||||||
|
if (item.type === 'text') {
|
||||||
|
setSelectedText(item);
|
||||||
|
setIsTextViewerOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseTextViewer = () => {
|
||||||
|
setIsTextViewerOpen(false);
|
||||||
|
setSelectedText(null);
|
||||||
|
};
|
||||||
|
|
||||||
const [currentItems, setCurrentItems] = useState<FileSystemItem[]>([]);
|
const [currentItems, setCurrentItems] = useState<FileSystemItem[]>([]);
|
||||||
|
|
||||||
|
// Custom Text Viewer Component for files without IDs
|
||||||
|
const FolderTextViewer = ({
|
||||||
|
text,
|
||||||
|
isOpen,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
text: FileSystemItem;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
const [content, setContent] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [selectedEncoding, setSelectedEncoding] = useState<string>('utf8');
|
||||||
|
const [availableEncodings] = useState<string[]>(['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(
|
||||||
|
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="relative w-full max-w-6xl h-[90vh] bg-zinc-900 rounded-xl border border-zinc-800 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-zinc-800 flex-shrink-0">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold text-white truncate">{text.name}</h2>
|
||||||
|
<p className="text-sm text-zinc-400 truncate">{text.path}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors ml-4 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encoding Selection */}
|
||||||
|
<div className="flex items-center gap-3 p-3 border-b border-zinc-800 bg-zinc-800/50 flex-shrink-0">
|
||||||
|
<span className="text-sm text-zinc-300 font-medium">Encoding:</span>
|
||||||
|
<select
|
||||||
|
value={selectedEncoding}
|
||||||
|
onChange={(e) => setSelectedEncoding(e.target.value)}
|
||||||
|
className="bg-zinc-700 border border-zinc-600 text-white text-sm rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
{availableEncodings.map((encoding) => (
|
||||||
|
<option key={encoding} value={encoding} className="bg-zinc-700">
|
||||||
|
{encoding.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-zinc-400 ml-2">
|
||||||
|
{selectedEncoding === 'utf8' ? '(Default)' :
|
||||||
|
selectedEncoding === 'gbk' || selectedEncoding === 'gb2312' ? '(Chinese)' :
|
||||||
|
selectedEncoding === 'big5' ? '(Traditional Chinese)' : '(Other)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-hidden p-4 min-h-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-zinc-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-auto bg-zinc-950 rounded-lg border border-zinc-700 custom-scrollbar">
|
||||||
|
<pre className="text-sm text-zinc-300 font-mono whitespace-pre-wrap break-words leading-relaxed p-4">
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-zinc-800 bg-zinc-800/50 flex-shrink-0">
|
||||||
|
<div className="text-sm text-zinc-400">
|
||||||
|
Size: {formatFileSize(text.size)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigator.clipboard.writeText(content)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-zinc-600 text-zinc-300 hover:bg-zinc-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const blob = new Blob([content], { type: 'text/plain; charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = text.name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleNextPhoto = () => {
|
const handleNextPhoto = () => {
|
||||||
// Navigate to next photo, skipping videos
|
// Navigate to next photo, skipping videos
|
||||||
const photos = currentItems.filter(item => item.type === 'photo' && item.id);
|
const photos = currentItems.filter(item => item.type === 'photo' && item.id);
|
||||||
|
|
@ -219,6 +387,7 @@ const FolderViewerPage = () => {
|
||||||
currentPath={path}
|
currentPath={path}
|
||||||
onVideoClick={handleVideoClick}
|
onVideoClick={handleVideoClick}
|
||||||
onPhotoClick={handlePhotoClick}
|
onPhotoClick={handlePhotoClick}
|
||||||
|
onTextClick={handleTextClick}
|
||||||
onBackClick={handleBackClick}
|
onBackClick={handleBackClick}
|
||||||
onBreadcrumbClick={handleBreadcrumbClick}
|
onBreadcrumbClick={handleBreadcrumbClick}
|
||||||
breadcrumbs={getBreadcrumbs(path)}
|
breadcrumbs={getBreadcrumbs(path)}
|
||||||
|
|
@ -248,6 +417,17 @@ const FolderViewerPage = () => {
|
||||||
showRatings={false}
|
showRatings={false}
|
||||||
formatFileSize={formatFileSize}
|
formatFileSize={formatFileSize}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Text Viewer */}
|
||||||
|
<TextViewer
|
||||||
|
text={selectedText!}
|
||||||
|
isOpen={isTextViewerOpen}
|
||||||
|
onClose={handleCloseTextViewer}
|
||||||
|
formatFileSize={formatFileSize}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Custom Text Viewer for files without IDs */}
|
||||||
|
{selectedText && <FolderTextViewer text={selectedText} isOpen={isTextViewerOpen} onClose={handleCloseTextViewer} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,34 @@
|
||||||
background: transparent;
|
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 for react-window grids */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|
|
||||||
|
|
@ -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<TextFile | null>(null);
|
||||||
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||||
|
const [textContent, setTextContent] = useState<string>("");
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<InfiniteVirtualGrid
|
||||||
|
type="text"
|
||||||
|
onItemClick={handleTextClick}
|
||||||
|
onBookmark={handleBookmark}
|
||||||
|
onUnbookmark={handleUnbookmark}
|
||||||
|
onRate={handleRate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text Viewer Modal */}
|
||||||
|
{isViewerOpen && selectedText && (
|
||||||
|
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="relative w-full max-w-6xl max-h-[90vh] bg-zinc-900 rounded-xl border border-zinc-800">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">{selectedText.title}</h2>
|
||||||
|
<p className="text-sm text-zinc-400">{selectedText.path}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseViewer}
|
||||||
|
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 max-h-[calc(90vh-120px)] overflow-auto">
|
||||||
|
<pre className="text-sm text-zinc-300 font-mono whitespace-pre-wrap break-words leading-relaxed">
|
||||||
|
{textContent}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-zinc-800">
|
||||||
|
<div className="text-sm text-zinc-400">
|
||||||
|
Size: {(selectedText.size / 1024).toFixed(2)} KB
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(textContent)}
|
||||||
|
className="px-3 py-1 text-sm bg-zinc-800 hover:bg-zinc-700 text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const blob = new Blob([textContent], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = selectedText.title;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextsPage;
|
||||||
|
|
@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { FixedSizeGrid } from 'react-window';
|
import { FixedSizeGrid } from 'react-window';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { StarRating } from '@/components/star-rating';
|
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';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
interface MediaItem {
|
interface MediaItem {
|
||||||
|
|
@ -20,7 +20,7 @@ interface MediaItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InfiniteVirtualGridProps {
|
interface InfiniteVirtualGridProps {
|
||||||
type: 'video' | 'photo' | 'bookmark';
|
type: 'video' | 'photo' | 'text' | 'bookmark';
|
||||||
onItemClick: (item: MediaItem, index?: number) => void;
|
onItemClick: (item: MediaItem, index?: number) => void;
|
||||||
onBookmark: (id: number) => Promise<void>;
|
onBookmark: (id: number) => Promise<void>;
|
||||||
onUnbookmark: (id: number) => Promise<void>;
|
onUnbookmark: (id: number) => Promise<void>;
|
||||||
|
|
@ -357,7 +357,7 @@ export default function InfiniteVirtualGrid({
|
||||||
return (
|
return (
|
||||||
<div style={style} className="p-2">
|
<div style={style} className="p-2">
|
||||||
<Card className="h-full animate-pulse bg-muted/50">
|
<Card className="h-full animate-pulse bg-muted/50">
|
||||||
<div className={`${type === 'video' ? 'aspect-video' : 'aspect-square'} bg-muted`} />
|
<div className={`${type === 'video' ? 'aspect-video' : type === 'text' ? 'aspect-[4/3]' : 'aspect-square'} bg-muted`} />
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="h-4 bg-muted rounded mb-2" />
|
<div className="h-4 bg-muted rounded mb-2" />
|
||||||
<div className="h-3 bg-muted rounded mb-1" />
|
<div className="h-3 bg-muted rounded mb-1" />
|
||||||
|
|
@ -375,11 +375,11 @@ export default function InfiniteVirtualGrid({
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden bg-muted aspect-video">
|
<div className="relative overflow-hidden bg-muted aspect-video">
|
||||||
<img
|
<img
|
||||||
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : "/placeholder-photo.svg")}
|
src={item.thumbnail || (type === 'video' ? "/placeholder-video.svg" : type === 'text' ? "/placeholder.svg" : "/placeholder-photo.svg")}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(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";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
|
@ -388,6 +388,8 @@ export default function InfiniteVirtualGrid({
|
||||||
<div className="w-10 h-10 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-lg">
|
<div className="w-10 h-10 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center shadow-lg">
|
||||||
{type === 'video' ?
|
{type === 'video' ?
|
||||||
<Film className="h-5 w-5 text-foreground" /> :
|
<Film className="h-5 w-5 text-foreground" /> :
|
||||||
|
type === 'text' ?
|
||||||
|
<FileText className="h-5 w-5 text-foreground" /> :
|
||||||
<ImageIcon className="h-5 w-5 text-foreground" />
|
<ImageIcon className="h-5 w-5 text-foreground" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -397,6 +399,8 @@ export default function InfiniteVirtualGrid({
|
||||||
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
|
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
|
||||||
{type === 'video' ?
|
{type === 'video' ?
|
||||||
<Film className="h-3 w-3 text-white" /> :
|
<Film className="h-3 w-3 text-white" /> :
|
||||||
|
type === 'text' ?
|
||||||
|
<FileText className="h-3 w-3 text-white" /> :
|
||||||
<ImageIcon className="h-3 w-3 text-white" />
|
<ImageIcon className="h-3 w-3 text-white" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -423,7 +427,7 @@ export default function InfiniteVirtualGrid({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 ml-1 flex-shrink-0">
|
<div className="flex gap-1 ml-1 flex-shrink-0">
|
||||||
{type === 'video' && item.bookmark_count > 0 && (
|
{(type === 'video' || type === 'text') && item.bookmark_count > 0 && (
|
||||||
<div className="text-xs text-yellow-500">
|
<div className="text-xs text-yellow-500">
|
||||||
<Bookmark className="h-2.5 w-2.5 fill-yellow-500" />
|
<Bookmark className="h-2.5 w-2.5 fill-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -437,7 +441,7 @@ export default function InfiniteVirtualGrid({
|
||||||
<HardDrive className="h-2.5 w-2.5" />
|
<HardDrive className="h-2.5 w-2.5" />
|
||||||
<span>{formatFileSize(item.size)}</span>
|
<span>{formatFileSize(item.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
{type === 'video' && item.bookmark_count > 0 && (
|
{(type === 'video' || type === 'text') && item.bookmark_count > 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{item.bookmark_count}
|
{item.bookmark_count}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -472,6 +476,8 @@ export default function InfiniteVirtualGrid({
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-primary to-primary/80 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg">
|
<div className="w-16 h-16 bg-gradient-to-br from-primary to-primary/80 rounded-2xl flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg">
|
||||||
{type === 'video' ?
|
{type === 'video' ?
|
||||||
<Film className="h-8 w-8 text-primary-foreground" /> :
|
<Film className="h-8 w-8 text-primary-foreground" /> :
|
||||||
|
type === 'text' ?
|
||||||
|
<FileText className="h-8 w-8 text-primary-foreground" /> :
|
||||||
<ImageIcon className="h-8 w-8 text-primary-foreground" />
|
<ImageIcon className="h-8 w-8 text-primary-foreground" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -491,12 +497,15 @@ export default function InfiniteVirtualGrid({
|
||||||
<div className={`w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-lg ${
|
<div className={`w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-lg ${
|
||||||
type === 'video' ? 'from-red-500 to-red-600' :
|
type === 'video' ? 'from-red-500 to-red-600' :
|
||||||
type === 'photo' ? 'from-green-500 to-green-600' :
|
type === 'photo' ? 'from-green-500 to-green-600' :
|
||||||
|
type === 'text' ? 'from-purple-500 to-purple-600' :
|
||||||
'from-blue-500 to-blue-600'
|
'from-blue-500 to-blue-600'
|
||||||
}`}>
|
}`}>
|
||||||
{type === 'video' ?
|
{type === 'video' ?
|
||||||
<Film className="h-6 w-6 text-white" /> :
|
<Film className="h-6 w-6 text-white" /> :
|
||||||
type === 'photo' ?
|
type === 'photo' ?
|
||||||
<ImageIcon className="h-6 w-6 text-white" /> :
|
<ImageIcon className="h-6 w-6 text-white" /> :
|
||||||
|
type === 'text' ?
|
||||||
|
<FileText className="h-6 w-6 text-white" /> :
|
||||||
<Bookmark className="h-6 w-6 text-white" />
|
<Bookmark className="h-6 w-6 text-white" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -549,12 +558,15 @@ export default function InfiniteVirtualGrid({
|
||||||
<div className={`w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-lg ${
|
<div className={`w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-lg ${
|
||||||
type === 'video' ? 'from-red-500 to-red-600' :
|
type === 'video' ? 'from-red-500 to-red-600' :
|
||||||
type === 'photo' ? 'from-green-500 to-green-600' :
|
type === 'photo' ? 'from-green-500 to-green-600' :
|
||||||
|
type === 'text' ? 'from-purple-500 to-purple-600' :
|
||||||
'from-blue-500 to-blue-600'
|
'from-blue-500 to-blue-600'
|
||||||
}`}>
|
}`}>
|
||||||
{type === 'video' ?
|
{type === 'video' ?
|
||||||
<Film className="h-6 w-6 text-white" /> :
|
<Film className="h-6 w-6 text-white" /> :
|
||||||
type === 'photo' ?
|
type === 'photo' ?
|
||||||
<ImageIcon className="h-6 w-6 text-white" /> :
|
<ImageIcon className="h-6 w-6 text-white" /> :
|
||||||
|
type === 'text' ?
|
||||||
|
<FileText className="h-6 w-6 text-white" /> :
|
||||||
<Bookmark className="h-6 w-6 text-white" />
|
<Bookmark className="h-6 w-6 text-white" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [currentSearchIndex, setCurrentSearchIndex] = useState(-1);
|
||||||
|
const [searchResults, setSearchResults] = useState<number[]>([]);
|
||||||
|
const [fontSize, setFontSize] = useState(14);
|
||||||
|
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||||
|
const [wordWrap, setWordWrap] = useState(true);
|
||||||
|
const [selectedEncoding, setSelectedEncoding] = useState<string>('utf8');
|
||||||
|
const [availableEncodings] = useState<string[]>(['utf8', 'gbk', 'gb2312', 'big5', 'latin1']);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(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(
|
||||||
|
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="relative w-full max-w-7xl h-[95vh] bg-zinc-900 rounded-xl border border-zinc-800 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-zinc-800 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileText className="h-5 w-5 text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">{getTextTitle()}</h2>
|
||||||
|
<p className="text-sm text-zinc-400">{getTextSize()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
id="text-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search in text..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<span className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-zinc-400">
|
||||||
|
{currentSearchIndex + 1}/{searchResults.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Navigation */}
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => navigateSearch('prev')}
|
||||||
|
className="p-1 text-zinc-400 hover:text-white transition-colors"
|
||||||
|
title="Previous match (Shift+F3)"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigateSearch('next')}
|
||||||
|
className="p-1 text-zinc-400 hover:text-white transition-colors"
|
||||||
|
title="Next match (F3)"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
title="Copy to clipboard (Ctrl+Shift+C)"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={downloadFile}
|
||||||
|
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
title="Download file (Ctrl+S)"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between p-3 border-b border-zinc-800 bg-zinc-800/50 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-zinc-300">Font Size:</label>
|
||||||
|
<select
|
||||||
|
value={fontSize}
|
||||||
|
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||||
|
className="bg-zinc-700 border border-zinc-600 rounded px-2 py-1 text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value={12}>12px</option>
|
||||||
|
<option value={14}>14px</option>
|
||||||
|
<option value={16}>16px</option>
|
||||||
|
<option value={18}>18px</option>
|
||||||
|
<option value={20}>20px</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-zinc-300">Encoding:</label>
|
||||||
|
<select
|
||||||
|
value={selectedEncoding}
|
||||||
|
onChange={(e) => setSelectedEncoding(e.target.value)}
|
||||||
|
className="bg-zinc-700 border border-zinc-600 rounded px-2 py-1 text-white text-sm"
|
||||||
|
>
|
||||||
|
{availableEncodings.map((encoding) => (
|
||||||
|
<option key={encoding} value={encoding}>
|
||||||
|
{encoding.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-zinc-400 ml-1">
|
||||||
|
{selectedEncoding === 'utf8' ? '(Default)' :
|
||||||
|
selectedEncoding === 'gbk' || selectedEncoding === 'gb2312' ? '(Chinese)' :
|
||||||
|
selectedEncoding === 'big5' ? '(Traditional Chinese)' : '(Other)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showLineNumbers}
|
||||||
|
onChange={(e) => setShowLineNumbers(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Line Numbers
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={wordWrap}
|
||||||
|
onChange={(e) => setWordWrap(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
Word Wrap
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-zinc-400">
|
||||||
|
{formattedLines.length} lines
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-hidden min-h-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-zinc-400">Loading...</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-red-400">{error}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="h-full overflow-auto p-4 custom-scrollbar"
|
||||||
|
style={{ fontSize: `${fontSize}px` }}
|
||||||
|
>
|
||||||
|
<div className={`font-mono text-zinc-300 ${wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'}`}>
|
||||||
|
{formattedLines.map(({ lineNumber, content, isHighlighted }) => (
|
||||||
|
<div
|
||||||
|
key={lineNumber}
|
||||||
|
id={`line-${lineNumber}`}
|
||||||
|
className={`flex ${isHighlighted ? 'bg-yellow-500/20' : ''}`}
|
||||||
|
>
|
||||||
|
{showLineNumbers && (
|
||||||
|
<div className="text-zinc-500 pr-4 select-none min-w-[60px] text-right">
|
||||||
|
{lineNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 break-words">
|
||||||
|
{content || ' '}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<HTMLVideoElement>(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<HTMLInputElement>) => {
|
|
||||||
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<HTMLDivElement>) => {
|
|
||||||
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 (
|
|
||||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
|
||||||
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Video container */}
|
|
||||||
<div className="relative w-full h-full bg-black rounded-lg overflow-hidden">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
onTimeUpdate={handleTimeUpdate}
|
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
|
||||||
onPlay={() => setIsPlaying(true)}
|
|
||||||
onPause={() => setIsPlaying(false)}
|
|
||||||
>
|
|
||||||
<source src={`/api/stream/${video.id}`} type="video/mp4" />
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
|
|
||||||
{/* Title overlay */}
|
|
||||||
<div className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/60 to-transparent p-4">
|
|
||||||
<h2 className="text-white text-lg font-semibold">{video.title}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls overlay */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent">
|
|
||||||
<div className="p-4 space-y-2">
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div
|
|
||||||
className="relative h-1 bg-white/20 rounded-full cursor-pointer"
|
|
||||||
onClick={handleProgressClick}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute h-full bg-white rounded-full"
|
|
||||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex items-center justify-between text-white">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<button
|
|
||||||
onClick={handlePlayPause}
|
|
||||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={handleMute}
|
|
||||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
{isMuted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value={volume}
|
|
||||||
onChange={handleVolumeChange}
|
|
||||||
className="w-20 h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-sm">
|
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleFullscreen}
|
|
||||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
|
||||||
>
|
|
||||||
{isFullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { FixedSizeGrid } from 'react-window';
|
import { FixedSizeGrid } from 'react-window';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { StarRating } from '@/components/star-rating';
|
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 { Button } from '@/components/ui/button';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
@ -30,6 +30,7 @@ interface VirtualizedFolderGridProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
onVideoClick: (item: FileSystemItem) => void;
|
onVideoClick: (item: FileSystemItem) => void;
|
||||||
onPhotoClick: (item: FileSystemItem, index: number) => void;
|
onPhotoClick: (item: FileSystemItem, index: number) => void;
|
||||||
|
onTextClick: (item: FileSystemItem) => void;
|
||||||
onBackClick: () => void;
|
onBackClick: () => void;
|
||||||
onBreadcrumbClick: (path: string) => void;
|
onBreadcrumbClick: (path: string) => void;
|
||||||
breadcrumbs: BreadcrumbItem[];
|
breadcrumbs: BreadcrumbItem[];
|
||||||
|
|
@ -43,6 +44,7 @@ export default function VirtualizedFolderGrid({
|
||||||
currentPath,
|
currentPath,
|
||||||
onVideoClick,
|
onVideoClick,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
|
onTextClick,
|
||||||
onBackClick,
|
onBackClick,
|
||||||
onBreadcrumbClick,
|
onBreadcrumbClick,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
|
|
@ -161,11 +163,12 @@ export default function VirtualizedFolderGrid({
|
||||||
if (item.isDirectory) return <Folder className="text-blue-500" size={48} />;
|
if (item.isDirectory) return <Folder className="text-blue-500" size={48} />;
|
||||||
if (item.type === 'photo') return <ImageIcon className="text-green-500" size={48} />;
|
if (item.type === 'photo') return <ImageIcon className="text-green-500" size={48} />;
|
||||||
if (item.type === 'video') return <Film className="text-red-500" size={48} />;
|
if (item.type === 'video') return <Film className="text-red-500" size={48} />;
|
||||||
|
if (item.type === 'text') return <FileText className="text-purple-500" size={48} />;
|
||||||
return <HardDrive className="text-gray-500" size={48} />;
|
return <HardDrive className="text-gray-500" size={48} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMediaFile = (item: FileSystemItem) => {
|
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
|
// Calculate responsive column count and width
|
||||||
|
|
@ -220,7 +223,7 @@ export default function VirtualizedFolderGrid({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={style} className="p-2">
|
<div style={style} className="p-2">
|
||||||
<div className={`group relative bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden min-h-[240px] ${(item.type === 'video' || item.type === 'photo') ? 'cursor-pointer' : ''}`}>
|
<div className={`group relative bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 overflow-hidden min-h-[240px] ${(item.type === 'video' || item.type === 'photo' || item.type === 'text') ? 'cursor-pointer' : ''}`}>
|
||||||
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
|
<Link href={item.isDirectory ? `/folder-viewer?path=${item.path}` : '#'}
|
||||||
className="block h-full flex flex-col"
|
className="block h-full flex flex-col"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -231,6 +234,9 @@ export default function VirtualizedFolderGrid({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const photoIndex = items.filter(i => i.type === 'photo' && i.id).findIndex(i => i.id === item.id);
|
const photoIndex = items.filter(i => i.type === 'photo' && i.id).findIndex(i => i.id === item.id);
|
||||||
onPhotoClick(item, photoIndex);
|
onPhotoClick(item, photoIndex);
|
||||||
|
} else if (item.type === 'text' && item.id) {
|
||||||
|
e.preventDefault();
|
||||||
|
onTextClick(item);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className="aspect-[4/3] relative overflow-hidden">
|
<div className="aspect-[4/3] relative overflow-hidden">
|
||||||
|
|
@ -243,11 +249,22 @@ export default function VirtualizedFolderGrid({
|
||||||
</div>
|
</div>
|
||||||
) : isMediaFile(item) ? (
|
) : isMediaFile(item) ? (
|
||||||
<div className="relative overflow-hidden aspect-[4/3] bg-black rounded-t-xl">
|
<div className="relative overflow-hidden aspect-[4/3] bg-black rounded-t-xl">
|
||||||
|
{item.type === 'text' ? (
|
||||||
|
// For text files, show a text icon instead of thumbnail
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-purple-50 to-indigo-100 dark:from-purple-900/20 dark:to-indigo-900/20 flex items-center justify-center">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center shadow-lg"
|
||||||
|
style={{ transform: 'perspective(100px) rotateY(-5deg) rotateX(5deg)' }}>
|
||||||
|
<FileText className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// For photos and videos, show thumbnail
|
||||||
<img
|
<img
|
||||||
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
|
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
|
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{item.type === 'video' && (
|
{item.type === 'video' && (
|
||||||
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
||||||
<Play className="h-3 w-3" />
|
<Play className="h-3 w-3" />
|
||||||
|
|
@ -258,6 +275,11 @@ export default function VirtualizedFolderGrid({
|
||||||
<ImageIcon className="h-3 w-3" />
|
<ImageIcon className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{item.type === 'text' && (
|
||||||
|
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center"
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import ffmpeg from "fluent-ffmpeg";
|
||||||
|
|
||||||
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
|
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
|
||||||
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"];
|
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"];
|
||||||
|
|
||||||
const generateVideoThumbnail = (videoPath: string, thumbnailPath: string) => {
|
const generateVideoThumbnail = (videoPath: string, thumbnailPath: string) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -35,34 +36,37 @@ const generatePhotoThumbnail = (photoPath: string, thumbnailPath: string) => {
|
||||||
|
|
||||||
const scanLibrary = async (library: { id: number; path: string }) => {
|
const scanLibrary = async (library: { id: number; path: string }) => {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
// Scan videos - handle all case variations
|
// Scan all files - handle all case variations
|
||||||
const videoFiles = await glob(`${library.path}/**/*.*`, { nodir: true });
|
const allFiles = await glob(`${library.path}/**/*.*`, { nodir: true });
|
||||||
|
|
||||||
// Scan photos - handle all case variations
|
|
||||||
const photoFiles = await glob(`${library.path}/**/*.*`, { nodir: true });
|
|
||||||
|
|
||||||
// Filter files by extension (case-insensitive)
|
// Filter files by extension (case-insensitive)
|
||||||
const filteredVideoFiles = videoFiles.filter(file => {
|
const filteredVideoFiles = allFiles.filter(file => {
|
||||||
const ext = path.extname(file).toLowerCase().replace('.', '');
|
const ext = path.extname(file).toLowerCase().replace('.', '');
|
||||||
return VIDEO_EXTENSIONS.includes(ext);
|
return VIDEO_EXTENSIONS.includes(ext);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredPhotoFiles = photoFiles.filter(file => {
|
const filteredPhotoFiles = allFiles.filter(file => {
|
||||||
const ext = path.extname(file).toLowerCase().replace('.', '');
|
const ext = path.extname(file).toLowerCase().replace('.', '');
|
||||||
return PHOTO_EXTENSIONS.includes(ext);
|
return PHOTO_EXTENSIONS.includes(ext);
|
||||||
});
|
});
|
||||||
|
|
||||||
const allFiles = [...filteredVideoFiles, ...filteredPhotoFiles];
|
const filteredTextFiles = allFiles.filter(file => {
|
||||||
|
const ext = path.extname(file).toLowerCase().replace('.', '');
|
||||||
|
return TEXT_EXTENSIONS.includes(ext);
|
||||||
|
});
|
||||||
|
|
||||||
for (const file of allFiles) {
|
const mediaFiles = [...filteredVideoFiles, ...filteredPhotoFiles, ...filteredTextFiles];
|
||||||
|
|
||||||
|
for (const file of mediaFiles) {
|
||||||
const stats = fs.statSync(file);
|
const stats = fs.statSync(file);
|
||||||
const title = path.basename(file);
|
const title = path.basename(file);
|
||||||
const ext = path.extname(file).toLowerCase();
|
const ext = path.extname(file).toLowerCase();
|
||||||
const cleanExt = ext.replace('.', '').toLowerCase();
|
const cleanExt = ext.replace('.', '').toLowerCase();
|
||||||
const isVideo = VIDEO_EXTENSIONS.some(v => v.toLowerCase() === cleanExt);
|
const isVideo = VIDEO_EXTENSIONS.some(v => v.toLowerCase() === cleanExt);
|
||||||
const isPhoto = PHOTO_EXTENSIONS.some(p => p.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 thumbnailFileName = `${path.parse(title).name}_${Date.now()}.png`;
|
||||||
const thumbnailPath = path.join(process.cwd(), "public", "thumbnails", thumbnailFileName);
|
const thumbnailPath = path.join(process.cwd(), "public", "thumbnails", thumbnailFileName);
|
||||||
const thumbnailUrl = `/thumbnails/${thumbnailFileName}`;
|
const thumbnailUrl = `/thumbnails/${thumbnailFileName}`;
|
||||||
|
|
@ -93,7 +97,7 @@ const scanLibrary = async (library: { id: number; path: string }) => {
|
||||||
} catch (thumbnailError) {
|
} catch (thumbnailError) {
|
||||||
console.warn(`Thumbnail generation failed for ${file}:`, thumbnailError);
|
console.warn(`Thumbnail generation failed for ${file}:`, thumbnailError);
|
||||||
// Use fallback thumbnail based on media type
|
// 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 = {
|
const media = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue