Compare commits
2 Commits
9ca11a8c6d
...
407c702e88
| Author | SHA1 | Date |
|---|---|---|
|
|
407c702e88 | |
|
|
0c8cb78ad2 |
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
54
Dockerfile
54
Dockerfile
|
|
@ -1,22 +1,22 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -24,52 +24,44 @@ RUN pnpm install
|
|||
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
|
||||
|
|
|
|||
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
Binary file not shown.
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const TEXT_EXTENSIONS = ["txt", "md", "json", "xml", "csv", "log", "conf", "ini", "yaml", "yml", "html", "css", "js", "ts", "py", "sh", "bat", "php", "sql"];
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const filePath = searchParams.get("path");
|
||||
const encoding = searchParams.get("encoding") || "utf8";
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: "Path is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if it's a file (not directory)
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isFile()) {
|
||||
return NextResponse.json({ error: "Path is not a file" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if it's a text file
|
||||
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
||||
if (!TEXT_EXTENSIONS.includes(ext)) {
|
||||
return NextResponse.json({ error: "File type not supported" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check file size (limit to 10MB for text files)
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (stats.size > maxSize) {
|
||||
return NextResponse.json({ error: "File too large (max 10MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
// Read file with specified encoding
|
||||
let content = '';
|
||||
try {
|
||||
if (encoding === 'utf8') {
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
} else {
|
||||
// For non-UTF-8 encodings, read as buffer and convert
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const iconv = require('iconv-lite');
|
||||
content = iconv.decode(buffer, encoding);
|
||||
}
|
||||
} catch (err) {
|
||||
// If specified encoding fails, try fallback encodings
|
||||
const fallbackEncodings = ['utf8', 'gbk', 'gb2312', 'big5', 'latin1'];
|
||||
|
||||
for (const fallbackEncoding of fallbackEncodings) {
|
||||
try {
|
||||
if (fallbackEncoding === 'utf8') {
|
||||
content = fs.readFileSync(filePath, 'utf-8');
|
||||
} else {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const iconv = require('iconv-lite');
|
||||
content = iconv.decode(buffer, fallbackEncoding);
|
||||
}
|
||||
|
||||
if (content && content.length > 0) {
|
||||
break;
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no encoding worked, try reading as buffer and return as base64
|
||||
if (!content || content.length === 0) {
|
||||
try {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
content = buffer.toString('base64');
|
||||
return NextResponse.json({
|
||||
content,
|
||||
size: stats.size,
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
encoding: 'base64'
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to read file content' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
content,
|
||||
size: stats.size,
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
encoding: encoding
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error reading file:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { getDatabase } from '@/db';
|
|||
|
||||
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
|
||||
const 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '@/db';
|
||||
import fs from 'fs';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const encoding = searchParams.get("encoding") || "utf8";
|
||||
|
||||
try {
|
||||
const textId = parseInt(id);
|
||||
if (isNaN(textId)) {
|
||||
return NextResponse.json({ error: 'Invalid text ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const text = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(textId, 'text') as { id: number; path: string; title: string; size: number };
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'Text file not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(text.path)) {
|
||||
return NextResponse.json({ error: 'File not found on filesystem' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Read file with specified encoding
|
||||
let content = '';
|
||||
try {
|
||||
if (encoding === 'utf8') {
|
||||
content = fs.readFileSync(text.path, 'utf-8');
|
||||
} else {
|
||||
// For non-UTF-8 encodings, read as buffer and convert
|
||||
const buffer = fs.readFileSync(text.path);
|
||||
const iconv = require('iconv-lite');
|
||||
content = iconv.decode(buffer, encoding);
|
||||
}
|
||||
} catch (err) {
|
||||
// If specified encoding fails, try fallback encodings
|
||||
const fallbackEncodings = ['utf8', 'gbk', 'gb2312', 'big5', 'latin1'];
|
||||
|
||||
for (const fallbackEncoding of fallbackEncodings) {
|
||||
try {
|
||||
if (fallbackEncoding === 'utf8') {
|
||||
content = fs.readFileSync(text.path, 'utf-8');
|
||||
} else {
|
||||
const buffer = fs.readFileSync(text.path);
|
||||
const iconv = require('iconv-lite');
|
||||
content = iconv.decode(buffer, fallbackEncoding);
|
||||
}
|
||||
|
||||
if (content && content.length > 0) {
|
||||
break;
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no encoding worked, try reading as buffer and return as base64
|
||||
if (!content || content.length === 0) {
|
||||
try {
|
||||
const buffer = fs.readFileSync(text.path);
|
||||
content = buffer.toString('base64');
|
||||
return NextResponse.json({
|
||||
id: text.id,
|
||||
title: text.title,
|
||||
path: text.path,
|
||||
content,
|
||||
size: text.size,
|
||||
encoding: 'base64'
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to read file content' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: text.id,
|
||||
title: text.title,
|
||||
path: text.path,
|
||||
content,
|
||||
size: text.size,
|
||||
encoding: encoding
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error reading text file:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getDatabase } from '@/db';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const db = getDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = parseInt(searchParams.get('limit') || '50');
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const search = searchParams.get('search');
|
||||
|
||||
try {
|
||||
let query = `
|
||||
SELECT m.*, l.path as library_path
|
||||
FROM media m
|
||||
JOIN libraries l ON m.library_id = l.id
|
||||
WHERE m.type = 'text'
|
||||
`;
|
||||
let params: any[] = [];
|
||||
|
||||
if (search) {
|
||||
query += ' AND (m.title LIKE ? OR m.path LIKE ?)';
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
query += ' ORDER BY m.created_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
|
||||
const texts = db.prepare(query).all(...params) as { id: number; title: string; path: string; size: number; thumbnail: string; type: string; bookmark_count: number; avg_rating: number; star_count: number; library_path: string; created_at: string }[];
|
||||
|
||||
const totalQuery = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM media m
|
||||
WHERE m.type = 'text'
|
||||
${search ? 'AND (m.title LIKE ? OR m.path LIKE ?)' : ''}
|
||||
`;
|
||||
const totalParams = search ? [`%${search}%`, `%${search}%`] : [];
|
||||
const total = (db.prepare(totalQuery).get(...totalParams) as { count: number }).count;
|
||||
|
||||
return NextResponse.json({
|
||||
texts,
|
||||
pagination: {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { mediaId } = await request.json();
|
||||
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const text = db.prepare('SELECT * FROM media WHERE id = ? AND type = "text"').get(mediaId) as { id: number; path: string; title: string; size: number };
|
||||
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'Text file not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(text.path, 'utf-8');
|
||||
|
||||
return NextResponse.json({
|
||||
id: text.id,
|
||||
title: text.title,
|
||||
path: text.path,
|
||||
content,
|
||||
size: text.size
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,11 @@ import { useState, useEffect, Suspense } from "react";
|
|||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import 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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import InfiniteVirtualGrid from "@/components/infinite-virtual-grid";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
interface TextFile {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
type: string;
|
||||
bookmark_count: number;
|
||||
avg_rating: number;
|
||||
star_count: number;
|
||||
}
|
||||
|
||||
const TextsPage = () => {
|
||||
const [selectedText, setSelectedText] = useState<TextFile | null>(null);
|
||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||
const [textContent, setTextContent] = useState<string>("");
|
||||
|
||||
const handleTextClick = async (text: TextFile) => {
|
||||
try {
|
||||
const response = await fetch(`/api/texts/${text.id}`);
|
||||
const data = await response.json();
|
||||
setTextContent(data.content);
|
||||
setSelectedText(text);
|
||||
setIsViewerOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Error loading text file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseViewer = () => {
|
||||
setIsViewerOpen(false);
|
||||
setSelectedText(null);
|
||||
setTextContent("");
|
||||
};
|
||||
|
||||
const handleBookmark = async (textId: number) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${textId}`, { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.error('Error bookmarking text:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbookmark = async (textId: number) => {
|
||||
try {
|
||||
await fetch(`/api/bookmarks/${textId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error('Error unbookmarking text:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRate = async (textId: number, rating: number) => {
|
||||
try {
|
||||
await fetch(`/api/stars/${textId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rating })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error rating text:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfiniteVirtualGrid
|
||||
type="text"
|
||||
onItemClick={handleTextClick}
|
||||
onBookmark={handleBookmark}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
onRate={handleRate}
|
||||
/>
|
||||
|
||||
{/* Text Viewer Modal */}
|
||||
{isViewerOpen && selectedText && (
|
||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-6xl max-h-[90vh] bg-zinc-900 rounded-xl border border-zinc-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{selectedText.title}</h2>
|
||||
<p className="text-sm text-zinc-400">{selectedText.path}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseViewer}
|
||||
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 max-h-[calc(90vh-120px)] overflow-auto">
|
||||
<pre className="text-sm text-zinc-300 font-mono whitespace-pre-wrap break-words leading-relaxed">
|
||||
{textContent}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-zinc-800">
|
||||
<div className="text-sm text-zinc-400">
|
||||
Size: {(selectedText.size / 1024).toFixed(2)} KB
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(textContent)}
|
||||
className="px-3 py-1 text-sm bg-zinc-800 hover:bg-zinc-700 text-white rounded transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const blob = new Blob([textContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = selectedText.title;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextsPage;
|
||||
|
|
@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|||
import { FixedSizeGrid } from 'react-window';
|
||||
import { 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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,419 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, FileText, Download, Copy, Search, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface TextFile {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface FileSystemItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
thumbnail?: string;
|
||||
type?: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
interface TextViewerProps {
|
||||
text: TextFile | FileSystemItem;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
formatFileSize?: (bytes: number) => string;
|
||||
}
|
||||
|
||||
export default function TextViewer({
|
||||
text,
|
||||
isOpen,
|
||||
onClose,
|
||||
formatFileSize
|
||||
}: TextViewerProps) {
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentSearchIndex, setCurrentSearchIndex] = useState(-1);
|
||||
const [searchResults, setSearchResults] = useState<number[]>([]);
|
||||
const [fontSize, setFontSize] = useState(14);
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||
const [wordWrap, setWordWrap] = useState(true);
|
||||
const [selectedEncoding, setSelectedEncoding] = useState<string>('utf8');
|
||||
const [availableEncodings] = useState<string[]>(['utf8', 'gbk', 'gb2312', 'big5', 'latin1']);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && text) {
|
||||
loadTextContent();
|
||||
}
|
||||
}, [isOpen, text, selectedEncoding]);
|
||||
|
||||
const loadTextContent = async () => {
|
||||
if (!text || !('id' in text) || !text.id) {
|
||||
setError('Invalid text file');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setContent('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/texts/${text.id}?encoding=${selectedEncoding}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load text file');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Handle base64 encoded content
|
||||
if (data.encoding === 'base64') {
|
||||
try {
|
||||
const decodedContent = atob(data.content);
|
||||
setContent(decodedContent);
|
||||
} catch (err) {
|
||||
setError('Failed to decode file content');
|
||||
}
|
||||
} else {
|
||||
setContent(data.content || '');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load text file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
if (!term.trim()) {
|
||||
setSearchResults([]);
|
||||
setCurrentSearchIndex(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const results: number[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (line.toLowerCase().includes(term.toLowerCase())) {
|
||||
results.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
setSearchResults(results);
|
||||
setCurrentSearchIndex(results.length > 0 ? 0 : -1);
|
||||
};
|
||||
|
||||
const navigateSearch = (direction: 'next' | 'prev') => {
|
||||
if (searchResults.length === 0) return;
|
||||
|
||||
if (direction === 'next') {
|
||||
setCurrentSearchIndex((prev) => (prev + 1) % searchResults.length);
|
||||
} else {
|
||||
setCurrentSearchIndex((prev) => (prev - 1 + searchResults.length) % searchResults.length);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = () => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = ('name' in text ? text.name : text.title) || 'text.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const getTextTitle = () => {
|
||||
if ('name' in text) return text.name;
|
||||
if ('title' in text) return text.title;
|
||||
return 'Text File';
|
||||
};
|
||||
|
||||
const getTextSize = () => {
|
||||
if (!text) return '0 Bytes';
|
||||
if (formatFileSize) {
|
||||
return formatFileSize(text.size);
|
||||
}
|
||||
const bytes = text.size;
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatContent = () => {
|
||||
if (!content) return [];
|
||||
|
||||
const lines = content.split('\n');
|
||||
return lines.map((line, index) => ({
|
||||
lineNumber: index + 1,
|
||||
content: line,
|
||||
isHighlighted: searchResults.includes(index)
|
||||
}));
|
||||
};
|
||||
|
||||
const scrollToLine = (lineNumber: number) => {
|
||||
const element = document.getElementById(`line-${lineNumber}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSearchIndex >= 0 && searchResults.length > 0) {
|
||||
const lineNumber = searchResults[currentSearchIndex] + 1;
|
||||
scrollToLine(lineNumber);
|
||||
}
|
||||
}, [currentSearchIndex, searchResults]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
const searchInput = document.getElementById('text-search') as HTMLInputElement;
|
||||
searchInput?.focus();
|
||||
break;
|
||||
case 'c':
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
copyToClipboard();
|
||||
}
|
||||
break;
|
||||
case 's':
|
||||
e.preventDefault();
|
||||
downloadFile();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
case 'F3':
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
navigateSearch('prev');
|
||||
} else {
|
||||
navigateSearch('next');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, searchResults, currentSearchIndex]);
|
||||
|
||||
if (!isOpen || typeof window === 'undefined') return null;
|
||||
|
||||
const formattedLines = formatContent();
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-7xl h-[95vh] bg-zinc-900 rounded-xl border border-zinc-800 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-zinc-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{getTextTitle()}</h2>
|
||||
<p className="text-sm text-zinc-400">{getTextSize()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-zinc-400" />
|
||||
<input
|
||||
id="text-search"
|
||||
type="text"
|
||||
placeholder="Search in text..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-10 pr-8 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<span className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-zinc-400">
|
||||
{currentSearchIndex + 1}/{searchResults.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Navigation */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => navigateSearch('prev')}
|
||||
className="p-1 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Previous match (Shift+F3)"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateSearch('next')}
|
||||
className="p-1 text-zinc-400 hover:text-white transition-colors"
|
||||
title="Next match (F3)"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
title="Copy to clipboard (Ctrl+Shift+C)"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={downloadFile}
|
||||
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
title="Download file (Ctrl+S)"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-zinc-800 bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-zinc-300">Font Size:</label>
|
||||
<select
|
||||
value={fontSize}
|
||||
onChange={(e) => setFontSize(Number(e.target.value))}
|
||||
className="bg-zinc-700 border border-zinc-600 rounded px-2 py-1 text-white text-sm"
|
||||
>
|
||||
<option value={12}>12px</option>
|
||||
<option value={14}>14px</option>
|
||||
<option value={16}>16px</option>
|
||||
<option value={18}>18px</option>
|
||||
<option value={20}>20px</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-zinc-300">Encoding:</label>
|
||||
<select
|
||||
value={selectedEncoding}
|
||||
onChange={(e) => setSelectedEncoding(e.target.value)}
|
||||
className="bg-zinc-700 border border-zinc-600 rounded px-2 py-1 text-white text-sm"
|
||||
>
|
||||
{availableEncodings.map((encoding) => (
|
||||
<option key={encoding} value={encoding}>
|
||||
{encoding.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-zinc-400 ml-1">
|
||||
{selectedEncoding === 'utf8' ? '(Default)' :
|
||||
selectedEncoding === 'gbk' || selectedEncoding === 'gb2312' ? '(Chinese)' :
|
||||
selectedEncoding === 'big5' ? '(Traditional Chinese)' : '(Other)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLineNumbers}
|
||||
onChange={(e) => setShowLineNumbers(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Line Numbers
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wordWrap}
|
||||
onChange={(e) => setWordWrap(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Word Wrap
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-zinc-400">
|
||||
{formattedLines.length} lines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-zinc-400">Loading...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-red-400">{error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full overflow-auto p-4 custom-scrollbar"
|
||||
style={{ fontSize: `${fontSize}px` }}
|
||||
>
|
||||
<div className={`font-mono text-zinc-300 ${wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'}`}>
|
||||
{formattedLines.map(({ lineNumber, content, isHighlighted }) => (
|
||||
<div
|
||||
key={lineNumber}
|
||||
id={`line-${lineNumber}`}
|
||||
className={`flex ${isHighlighted ? 'bg-yellow-500/20' : ''}`}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<div className="text-zinc-500 pr-4 select-none min-w-[60px] text-right">
|
||||
{lineNumber}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 break-words">
|
||||
{content || ' '}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { X, Play, Pause, Maximize, Minimize, Volume2, VolumeX } from 'lucide-react';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
video: {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
};
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function VideoPlayer({ video, isOpen, onClose }: VideoPlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && videoRef.current) {
|
||||
videoRef.current.src = `/api/stream/${video.id}`;
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, [isOpen, video.id]);
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
if (videoRef.current) {
|
||||
if (!isFullscreen) {
|
||||
videoRef.current.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMute = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (videoRef.current) {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
videoRef.current.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (videoRef.current) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const newTime = (clickX / rect.width) * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handlePlayPause();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
||||
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Video container */}
|
||||
<div className="relative w-full h-full bg-black rounded-lg overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
>
|
||||
<source src={`/api/stream/${video.id}`} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
{/* Title overlay */}
|
||||
<div className="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/60 to-transparent p-4">
|
||||
<h2 className="text-white text-lg font-semibold">{video.title}</h2>
|
||||
</div>
|
||||
|
||||
{/* Controls overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent">
|
||||
<div className="p-4 space-y-2">
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="relative h-1 bg-white/20 rounded-full cursor-pointer"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div
|
||||
className="absolute h-full bg-white rounded-full"
|
||||
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between text-white">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleMute}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-20 h-1 bg-white/20 rounded-full appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{isFullscreen ? <Minimize className="h-5 w-5" /> : <Maximize className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||
import { FixedSizeGrid } from 'react-window';
|
||||
import { 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"
|
||||
|
|
|
|||
|
|
@ -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 allFiles = [...filteredVideoFiles, ...filteredPhotoFiles];
|
||||
const filteredTextFiles = allFiles.filter(file => {
|
||||
const ext = path.extname(file).toLowerCase().replace('.', '');
|
||||
return TEXT_EXTENSIONS.includes(ext);
|
||||
});
|
||||
|
||||
for (const file of allFiles) {
|
||||
const mediaFiles = [...filteredVideoFiles, ...filteredPhotoFiles, ...filteredTextFiles];
|
||||
|
||||
for (const file of mediaFiles) {
|
||||
const stats = fs.statSync(file);
|
||||
const 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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue