Compare commits

..

2 Commits

Author SHA1 Message Date
tigeren 407c702e88 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.
2025-08-31 08:42:30 +00:00
tigeren 0c8cb78ad2 [!!]refactor: optimize Docker configuration and update .dockerignore
- Changed base image in Dockerfile to a smaller Alpine version for reduced size.
- Updated package installation commands to use Alpine's package manager.
- Enhanced .dockerignore to exclude additional files and directories, improving build efficiency.
- Added new entries for media, database, and test files to .dockerignore.
- Adjusted docker-compose.yml to use a simplified image name for clarity.
2025-08-30 20:12:15 +00:00
20 changed files with 1232 additions and 296 deletions

View File

@ -5,34 +5,29 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Next.js build output
.next
out
# Next.js
.next/
out/
# Production build
# Production
build
dist
# Environment variables
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
@ -40,14 +35,23 @@ Thumbs.db
.gitignore
# Docker
Dockerfile
Dockerfile*
docker-compose*
.dockerignore
docker-compose.yml
# Documentation
README.md
docs/
*.md
# Tests
coverage/
.nyc_output
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# Logs
logs
*.log
@ -59,10 +63,60 @@ pids
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
coverage/
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp
temp
tmp/
temp/
public/thumbnails
# Media files (these will be mounted as volumes)
data/
media/
public/thumbnails/
# Database files
*.db
*.sqlite
*.sqlite3
# Backup files
*.bak
*.backup

View File

@ -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.

View File

@ -1,75 +1,67 @@
# Use official Node.js runtime as the base image
FROM node:22.18.0 AS base
# Use a smaller base image for the final runtime
FROM node:22.18.0-alpine AS base
# Rebuild the source code only when needed
# Build stage
FROM base AS builder
WORKDIR /app
# Install build dependencies for native modules
RUN apt-get update && apt-get install -y \
RUN apk add --no-cache \
python3 \
make \
g++ \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
sqlite-dev \
ffmpeg-dev
# Install pnpm globally
RUN npm install -g pnpm
# Copy package files and install all dependencies (including dev dependencies)
# Copy package files and install all dependencies
COPY package.json package-lock.json ./
RUN pnpm install
RUN pnpm install
# Copy source code
COPY . .
# Rebuild better-sqlite3 to ensure native bindings are compiled correctly
RUN pnpm rebuild better-sqlite3 || npm rebuild better-sqlite3
# Verify native bindings are compiled
RUN find /app/node_modules -name "better_sqlite3.node" -type f
# Database file will be created at runtime via docker-compose
# Create directories for media storage
RUN mkdir -p /app/data /app/media
RUN pnpm rebuild better-sqlite3
# Build the application
RUN pnpm buildprod
# Production image, copy all the files and run next
# Production stage
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Install FFmpeg for media file analysis
RUN apt-get update && apt-get install -y \
# Install only runtime dependencies
RUN apk add --no-cache \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
sqlite \
&& rm -rf /var/cache/apk/*
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 --gid nodejs nextjs
# Create user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 --ingroup nodejs nextjs
# Create media directories
RUN mkdir -p /app/data /app/media
# Create necessary directories
RUN mkdir -p /app/data /app/media /app/public/thumbnails
# Ensure directories have correct permissions
RUN chown -R nextjs:nodejs /app/data /app/media
# Copy built application
# Copy only the necessary files from builder
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy node_modules to ensure native bindings are available
# Copy the entire node_modules to ensure all native bindings are available
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
# Rebuild native bindings for the production environment
RUN npm rebuild better-sqlite3
# Set up volume for persistent data
VOLUME ["/app/data", "/app/media"]
# Set correct permissions
RUN chown -R nextjs:nodejs /app/data /app/media /app/public
# Switch to non-root user
USER nextjs

Binary file not shown.

Binary file not shown.

View File

@ -2,7 +2,7 @@ version: '3.8'
services:
nextav:
image: ${REGISTRY_URL:-192.168.2.212:3000}/${IMAGE_NAME:-tigeren/nextav}:${IMAGE_TAG:-latest}
image: nextav:optimized
container_name: nextav-app
restart: unless-stopped
ports:

23
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 });
}
}

View File

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

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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<FileSystemItem | null>(null);
const [isPhotoViewerOpen, setIsPhotoViewerOpen] = useState(false);
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}[]>([]);
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<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 = () => {
// 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 */}
<TextViewer
text={selectedText!}
isOpen={isTextViewerOpen}
onClose={handleCloseTextViewer}
formatFileSize={formatFileSize}
/>
{/* Custom Text Viewer for files without IDs */}
{selectedText && <FolderTextViewer text={selectedText} isOpen={isTextViewerOpen} onClose={handleCloseTextViewer} />}
</>
);
};

View File

@ -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;

140
src/app/texts/page.tsx Normal file
View File

@ -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;

View File

@ -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<void>;
onUnbookmark: (id: number) => Promise<void>;
@ -357,7 +357,7 @@ export default function InfiniteVirtualGrid({
return (
<div style={style} className="p-2">
<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">
<div className="h-4 bg-muted rounded mb-2" />
<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">
<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}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
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" />
@ -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">
{type === 'video' ?
<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" />
}
</div>
@ -397,6 +399,8 @@ export default function InfiniteVirtualGrid({
<div className="bg-black/70 backdrop-blur-sm rounded-full px-2 py-1">
{type === 'video' ?
<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" />
}
</div>
@ -423,7 +427,7 @@ export default function InfiniteVirtualGrid({
</div>
<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">
<Bookmark className="h-2.5 w-2.5 fill-yellow-500" />
</div>
@ -437,7 +441,7 @@ export default function InfiniteVirtualGrid({
<HardDrive className="h-2.5 w-2.5" />
<span>{formatFileSize(item.size)}</span>
</div>
{type === 'video' && item.bookmark_count > 0 && (
{(type === 'video' || type === 'text') && item.bookmark_count > 0 && (
<span className="text-xs text-muted-foreground">
{item.bookmark_count}
</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">
{type === 'video' ?
<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" />
}
</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 ${
type === 'video' ? 'from-red-500 to-red-600' :
type === 'photo' ? 'from-green-500 to-green-600' :
type === 'text' ? 'from-purple-500 to-purple-600' :
'from-blue-500 to-blue-600'
}`}>
{type === 'video' ?
<Film className="h-6 w-6 text-white" /> :
type === 'photo' ?
<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" />
}
</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 ${
type === 'video' ? 'from-red-500 to-red-600' :
type === 'photo' ? 'from-green-500 to-green-600' :
type === 'text' ? 'from-purple-500 to-purple-600' :
'from-blue-500 to-blue-600'
}`}>
{type === 'video' ?
<Film className="h-6 w-6 text-white" /> :
type === 'photo' ?
<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" />
}
</div>

View File

@ -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
);
}

View File

@ -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>
);
}

View File

@ -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 <Folder className="text-blue-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 === 'text') return <FileText className="text-purple-500" size={48} />;
return <HardDrive className="text-gray-500" size={48} />;
};
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 (
<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}` : '#'}
className="block h-full flex flex-col"
onClick={(e) => {
@ -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);
}
}}>
<div className="aspect-[4/3] relative overflow-hidden">
@ -243,11 +249,22 @@ export default function VirtualizedFolderGrid({
</div>
) : isMediaFile(item) ? (
<div className="relative overflow-hidden aspect-[4/3] bg-black rounded-t-xl">
<img
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
alt={item.name}
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
/>
{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
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')}
alt={item.name}
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
/>
)}
{item.type === 'video' && (
<div className="absolute top-2 right-2 bg-black/60 text-white rounded-full p-1">
<Play className="h-3 w-3" />
@ -258,6 +275,11 @@ export default function VirtualizedFolderGrid({
<ImageIcon className="h-3 w-3" />
</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 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"

View File

@ -6,6 +6,7 @@ import ffmpeg from "fluent-ffmpeg";
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"];
const generateVideoThumbnail = (videoPath: string, thumbnailPath: string) => {
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 = {