feat: Implement file system API and folder viewer
- Added API endpoint to retrieve files from a specified directory. - Created API for managing media libraries (GET, POST, DELETE). - Implemented scanning functionality for media libraries. - Developed video listing page with API integration. - Introduced folder viewer component to navigate file system. - Enhanced settings page for managing libraries and initiating scans. - Built sidebar component for navigation with dynamic library links. - Established UI components for buttons, cards, and inputs. - Set up SQLite database schema for libraries and media. - Integrated thumbnail generation for video files during scanning. - Configured Tailwind CSS for styling and responsive design.
This commit is contained in:
parent
20a8a74d32
commit
83dea7c651
|
|
@ -0,0 +1,25 @@
|
|||
Project Description:
|
||||
This is a nextjs project, basically a youtube like video sites.
|
||||
|
||||
Feature requirement:
|
||||
1. Has a youtube like UI
|
||||
2. has a concept of media library. each library is a path that mounted in the /mnt
|
||||
3. has the ability to scan the media libaries
|
||||
4. the quntitiy of media files can be vast, tens of thousands videos
|
||||
5. the media can work with photos and videos
|
||||
6. user can manage(add/remove) the media libraries.
|
||||
7. there should be a database(sqlite) to save the media informations
|
||||
8. there should be at least two tables in the database, one to keep the video informations, such as path, size, thumbnails, title, etc(not limit to these); one to keep the media libraries. videos should be linked to the library
|
||||
9. thumbnail generate feature
|
||||
|
||||
|
||||
UI:
|
||||
1. two main areas, left is the left sidebar, right is the main video area
|
||||
2. side bar can be expanded and collapsed
|
||||
3. sidebar should have these sections: Settings, Videos, Photos, Folder Viewer
|
||||
4. Settings section: open manage page in the main area, allow user to add media library path; delete library.
|
||||
5. Videos section: open video cards page in the main area, each card has thumbnail pic displayed. the card lower part display video path, size.
|
||||
6. photo section: TBD
|
||||
7. folder viewer: list libraries in the side bar folder viewer section. once one of the folder is selected, display the folder sturcture in the main area. the vdieo or photo display thunbnail and information as the video card would do. for the folder, display the folder icon, which can be entered in
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
Project Description:
|
||||
This is a nextjs project, basically a youtube like video sites.
|
||||
|
||||
Feature requirement:
|
||||
1. Has a youtube like UI
|
||||
2. has a concept of media library. each library is a path that mounted in the /mnt
|
||||
3. has the ability to scan the media libaries
|
||||
4. the quntitiy of media files can be vast, tens of thousands videos
|
||||
5. the media can work with photos and videos
|
||||
6. user can manage(add/remove) the media libraries.
|
||||
7. there should be a database(sqlite) to save the media informations
|
||||
8. there should be at least two tables in the database, one to keep the video informations, such as path, size, thumbnails, title, etc(not limit to these); one to keep the media libraries. videos should be linked to the library
|
||||
9. thumbnail generate feature
|
||||
|
||||
|
||||
UI:
|
||||
1. two main areas, left is the left sidebar, right is the main video area
|
||||
2. side bar can be expanded and collapsed
|
||||
3. sidebar should have these sections: Settings, Videos, Photos, Folder Viewer
|
||||
4. Settings section: open manage page in the main area, allow user to add media library path; delete library.
|
||||
5. Videos section: open video cards page in the main area, each card has thumbnail pic displayed. the card lower part display video path, size.
|
||||
6. photo section: TBD
|
||||
7. folder viewer: list libraries in the side bar folder viewer section. once one of the folder is selected, display the folder sturcture in the main area. the vdieo or photo display thunbnail and information as the video card would do. for the folder, display the folder icon, which can be entered in
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
|
|
@ -8,16 +8,28 @@
|
|||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@types/fluent-ffmpeg": "^2.1.27",
|
||||
"@types/glob": "^8.1.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"glob": "^11.0.3",
|
||||
"lucide-react": "^0.541.0",
|
||||
"next": "15.5.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"next": "15.5.0"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
|
|
@ -0,0 +1,30 @@
|
|||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const dirPath = searchParams.get("path");
|
||||
|
||||
if (!dirPath) {
|
||||
return NextResponse.json({ error: "Path is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
const result = files.map((file) => {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
name: file,
|
||||
path: filePath,
|
||||
isDirectory: stats.isDirectory(),
|
||||
size: stats.size,
|
||||
};
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
const id = parseInt(params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const info = db.prepare('DELETE FROM libraries WHERE id = ?').run(id);
|
||||
if (info.changes === 0) {
|
||||
return NextResponse.json({ error: 'Library not found' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ message: 'Library deleted' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
import { NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function GET() {
|
||||
const libraries = db.prepare('SELECT * FROM libraries').all();
|
||||
return NextResponse.json(libraries);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { path } = await request.json();
|
||||
if (!path) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const info = db.prepare('INSERT INTO libraries (path) VALUES (?)').run(path);
|
||||
return NextResponse.json({ id: info.lastInsertRowid, path });
|
||||
} catch (error: any) {
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||
return NextResponse.json({ error: 'Path already exists' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
import { NextResponse } from "next/server";
|
||||
import { scanAllLibraries } from "@/lib/scanner";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await scanAllLibraries();
|
||||
return NextResponse.json({ message: "Scan complete" });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import { NextResponse } from "next/server";
|
||||
import db from "@/db";
|
||||
|
||||
export async function GET() {
|
||||
const videos = db.prepare("SELECT * FROM media WHERE type = 'video'").all();
|
||||
return NextResponse.json(videos);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Folder, File } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface FileSystemItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const FolderViewerPage = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const path = searchParams.get("path");
|
||||
const [items, setItems] = useState<FileSystemItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (path) {
|
||||
fetchItems(path);
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
const fetchItems = async (currentPath: string) => {
|
||||
const res = await fetch(`/api/files?path=${currentPath}`);
|
||||
const data = await res.json();
|
||||
setItems(data);
|
||||
};
|
||||
|
||||
if (!path) {
|
||||
return <div>Select a library from the sidebar</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-4 truncate">{path}</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{items.map((item) => (
|
||||
<Card key={item.name} className="overflow-hidden rounded-lg shadow-lg hover:shadow-xl transition-shadow duration-300 ease-in-out">
|
||||
<CardContent className="p-4 flex items-center">
|
||||
{item.isDirectory ? (
|
||||
<Link href={`/folder-viewer?path=${item.path}`} className="flex items-center w-full">
|
||||
<Folder className="mr-4 text-blue-500" size={48} />
|
||||
<span className="truncate font-semibold">{item.name}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center w-full">
|
||||
<File className="mr-4 text-gray-500" size={48} />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderViewerPageWrapper = () => (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<FolderViewerPage />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
export default FolderViewerPageWrapper;
|
||||
|
|
@ -1,26 +1,76 @@
|
|||
@import "tailwindcss";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@layer base {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
|
@ -25,9 +26,12 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-100 dark:bg-gray-900`}
|
||||
>
|
||||
{children}
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 p-4 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface Library {
|
||||
id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const SettingsPage = () => {
|
||||
const [libraries, setLibraries] = useState<Library[]>([]);
|
||||
const [newLibraryPath, setNewLibraryPath] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, []);
|
||||
|
||||
const fetchLibraries = async () => {
|
||||
const res = await fetch("/api/libraries");
|
||||
const data = await res.json();
|
||||
setLibraries(data);
|
||||
};
|
||||
|
||||
const addLibrary = async () => {
|
||||
if (!newLibraryPath) return;
|
||||
const res = await fetch("/api/libraries", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ path: newLibraryPath }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setNewLibraryPath("");
|
||||
fetchLibraries();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLibrary = async (id: number) => {
|
||||
const res = await fetch(`/api/libraries/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchLibraries();
|
||||
}
|
||||
};
|
||||
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
const scanLibraries = async () => {
|
||||
setIsScanning(true);
|
||||
const res = await fetch("/api/scan", {
|
||||
method: "POST",
|
||||
});
|
||||
if (res.ok) {
|
||||
alert("Scan complete");
|
||||
}
|
||||
setIsScanning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Manage Media Libraries</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2 mb-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/mnt/media"
|
||||
value={newLibraryPath}
|
||||
onChange={(e) => setNewLibraryPath(e.target.value)}
|
||||
/>
|
||||
<Button onClick={addLibrary}>Add Library</Button>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Existing Libraries</h2>
|
||||
<ul className="space-y-2">
|
||||
{libraries.map((lib) => (
|
||||
<li key={lib.id} className="flex items-center justify-between p-2 border rounded-lg">
|
||||
<span className="truncate">{lib.path}</span>
|
||||
<Button variant="destructive" size="sm" onClick={() => deleteLibrary(lib.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Media Scanner</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={scanLibraries} disabled={isScanning}>
|
||||
{isScanning ? "Scanning..." : "Scan Libraries"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface Video {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
const VideosPage = () => {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
const res = await fetch("/api/videos");
|
||||
const data = await res.json();
|
||||
setVideos(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Videos</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{videos.map((video) => (
|
||||
<Card key={video.id} className="overflow-hidden rounded-lg shadow-lg hover:shadow-xl transition-shadow duration-300 ease-in-out">
|
||||
<CardContent className="p-0">
|
||||
<img src={video.thumbnail || "/placeholder.svg"} alt={video.title} className="w-full h-48 object-cover"/>
|
||||
</CardContent>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold truncate">{video.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<p className="text-sm text-gray-500 truncate">{video.path}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideosPage;
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Home,
|
||||
Settings,
|
||||
Video,
|
||||
Image,
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
const Sidebar = () => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, []);
|
||||
|
||||
const fetchLibraries = async () => {
|
||||
const res = await fetch("/api/libraries");
|
||||
const data = await res.json();
|
||||
setLibraries(data);
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200 border-r border-gray-200 dark:border-gray-800 transition-all duration-300 ${isCollapsed ? "w-20" : "w-64"}`}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
{!isCollapsed && <h1 className="text-xl font-bold">NextAV</h1>}
|
||||
<Button onClick={toggleSidebar} variant="ghost" size="icon">
|
||||
{isCollapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="flex flex-col mt-4 space-y-2 px-4">
|
||||
<Link href="/" passHref>
|
||||
<Button variant="ghost" className="w-full justify-start">
|
||||
<Home className="mr-4" />
|
||||
{!isCollapsed && "Home"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/settings" passHref>
|
||||
<Button variant="ghost" className="w-full justify-start">
|
||||
<Settings className="mr-4" />
|
||||
{!isCollapsed && "Settings"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/videos" passHref>
|
||||
<Button variant="ghost" className="w-full justify-start">
|
||||
<Video className="mr-4" />
|
||||
{!isCollapsed && "Videos"}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/photos" passHref>
|
||||
<Button variant="ghost" className="w-full justify-start">
|
||||
<Image className="mr-4" />
|
||||
{!isCollapsed && "Photos"}
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="pt-4">
|
||||
<h2 className={`text-lg font-semibold p-2 ${isCollapsed ? 'text-center' : ''}`}>
|
||||
{!isCollapsed ? "Folder Viewer" : "Folders"}
|
||||
</h2>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{libraries.map((lib) => (
|
||||
<Link href={`/folder-viewer?path=${lib.path}`} key={lib.id} passHref>
|
||||
<Button variant="ghost" className="w-full justify-start">
|
||||
<Folder className="mr-4" />
|
||||
{!isCollapsed && <span className="truncate">{lib.path}</span>}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
const dbPath = path.join(process.cwd(), 'media.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS libraries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
library_id INTEGER,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
size INTEGER,
|
||||
thumbnail TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (library_id) REFERENCES libraries (id)
|
||||
);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import db from "@/db";
|
||||
import { glob } from "glob";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
|
||||
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv"];
|
||||
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif"];
|
||||
|
||||
const generateThumbnail = (videoPath: string, thumbnailPath: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(videoPath)
|
||||
.on("end", () => resolve(thumbnailPath))
|
||||
.on("error", (err) => reject(err))
|
||||
.screenshots({
|
||||
count: 1,
|
||||
folder: path.dirname(thumbnailPath),
|
||||
filename: path.basename(thumbnailPath),
|
||||
size: "320x240",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const scanLibrary = async (library: { id: number; path: string }) => {
|
||||
const mediaFiles = await glob(`${library.path}/**/*.{${VIDEO_EXTENSIONS.join(",")}}`, {
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
for (const file of mediaFiles) {
|
||||
const stats = fs.statSync(file);
|
||||
const title = path.basename(file);
|
||||
const thumbnailFileName = `${path.parse(title).name}.png`;
|
||||
const thumbnailPath = path.join(process.cwd(), "public", "thumbnails", thumbnailFileName);
|
||||
const thumbnailUrl = `/thumbnails/${thumbnailFileName}`;
|
||||
|
||||
try {
|
||||
const existingMedia = db.prepare("SELECT * FROM media WHERE path = ?").get(file);
|
||||
if (existingMedia) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await generateThumbnail(file, thumbnailPath);
|
||||
|
||||
const media = {
|
||||
library_id: library.id,
|
||||
path: file,
|
||||
type: "video",
|
||||
title: title,
|
||||
size: stats.size,
|
||||
thumbnail: thumbnailUrl,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO media (library_id, path, type, title, size, thumbnail) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
).run(media.library_id, media.path, media.type, media.title, media.size, media.thumbnail);
|
||||
} catch (error: any) {
|
||||
if (error.code !== "SQLITE_CONSTRAINT_UNIQUE") {
|
||||
console.error(`Error inserting media: ${file}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const scanAllLibraries = async () => {
|
||||
const libraries = db.prepare("SELECT * FROM libraries").all() as { id: number; path: string }[];
|
||||
for (const library of libraries) {
|
||||
await scanLibrary(library);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
|
||||
import type { Config } from "tailwindcss"
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config
|
||||
|
||||
export default config
|
||||
Loading…
Reference in New Issue