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* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
# Next.js build output # Next.js
.next .next/
out out/
# Production build # Production
build
dist dist
# Environment variables # Environment files
.env .env
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
# IDE and editor files # IDE
.vscode .vscode
.idea .idea
*.swp *.swp
*.swo *.swo
*~
# OS generated files # OS
.DS_Store .DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db Thumbs.db
# Git # Git
@ -40,14 +35,23 @@ Thumbs.db
.gitignore .gitignore
# Docker # Docker
Dockerfile Dockerfile*
docker-compose*
.dockerignore .dockerignore
docker-compose.yml
# Documentation # Documentation
README.md README.md
docs/
*.md *.md
# Tests
coverage/
.nyc_output
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# Logs # Logs
logs logs
*.log *.log
@ -59,10 +63,60 @@ pids
*.pid.lock *.pid.lock
# Coverage directory used by tools like istanbul # 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 # Temporary folders
tmp tmp/
temp 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: 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.

View File

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

Binary file not shown.

Binary file not shown.

View File

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

23
package-lock.json generated
View File

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

View File

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

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

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 { 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} />}
</> </>
); );
}; };

View File

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

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

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 { 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">
<img {item.type === 'text' ? (
src={item.thumbnail || (item.type === 'video' ? '/placeholder-video.svg' : '/placeholder-photo.svg')} // For text files, show a text icon instead of thumbnail
alt={item.name} <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">
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105" <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' && ( {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"

View File

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