Compare commits
4 Commits
20a8a74d32
...
e248613abb
| Author | SHA1 | Date |
|---|---|---|
|
|
e248613abb | |
|
|
31e27d4214 | |
|
|
8676a7d05a | |
|
|
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,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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import db from '@/db';
|
||||
|
||||
const VIDEO_EXTENSIONS = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v"];
|
||||
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"];
|
||||
|
||||
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);
|
||||
|
||||
// Get media files from database for this path
|
||||
const mediaFiles = db.prepare(`
|
||||
SELECT path, type, thumbnail
|
||||
FROM media
|
||||
WHERE path LIKE ?
|
||||
`).all(`${dirPath}%`) as Array<{path: string, type: string, thumbnail: string | null}>;
|
||||
|
||||
const result = files.map((file) => {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
|
||||
let type = 'file';
|
||||
if (VIDEO_EXTENSIONS.some(v => ext.includes(v))) {
|
||||
type = 'video';
|
||||
} else if (PHOTO_EXTENSIONS.some(p => ext.includes(p))) {
|
||||
type = 'photo';
|
||||
}
|
||||
|
||||
// Find matching media file in database
|
||||
const mediaFile = mediaFiles.find((m: any) => m.path === filePath);
|
||||
|
||||
return {
|
||||
name: file,
|
||||
path: filePath,
|
||||
isDirectory: stats.isDirectory(),
|
||||
size: stats.size,
|
||||
type: mediaFile?.type || type,
|
||||
thumbnail: mediaFile?.thumbnail,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function DELETE(request: NextRequest, { params: paramsPromise }: { params: Promise<{ id: string }> }) {
|
||||
const params = await paramsPromise;
|
||||
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,7 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import db from '@/db';
|
||||
|
||||
export async function GET() {
|
||||
const photos = db.prepare('SELECT * FROM media WHERE type = ?').all('photo');
|
||||
return NextResponse.json(photos);
|
||||
}
|
||||
|
|
@ -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,128 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Folder, File, Image, Film } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface FileSystemItem {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
size: number;
|
||||
thumbnail?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
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 getFileIcon = (item: FileSystemItem) => {
|
||||
if (item.isDirectory) return <Folder className="text-blue-500" size={48} />;
|
||||
if (item.type === 'photo') return <Image className="text-green-500" size={48} />;
|
||||
if (item.type === 'video') return <Film className="text-red-500" size={48} />;
|
||||
return <File className="text-gray-500" size={48} />;
|
||||
};
|
||||
|
||||
const isMediaFile = (item: FileSystemItem) => {
|
||||
return item.type === 'video' || item.type === 'photo';
|
||||
};
|
||||
|
||||
if (!path) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 text-lg mb-4">Select a library from the sidebar</p>
|
||||
<p className="text-gray-400">Choose a media library above to browse its contents</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-6 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-6">
|
||||
{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-0">
|
||||
{item.isDirectory ? (
|
||||
<Link href={`/folder-viewer?path=${item.path}`} className="block">
|
||||
<div className="flex items-center justify-center h-48 bg-gray-100">
|
||||
<Folder className="text-blue-500" size={64} />
|
||||
</div>
|
||||
</Link>
|
||||
) : isMediaFile(item) ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={item.thumbnail || '/placeholder.svg'}
|
||||
alt={item.name}
|
||||
className="w-full h-48 object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder.svg';
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-2 right-2">
|
||||
{item.type === 'video' ? (
|
||||
<Film className="text-white bg-black bg-opacity-50 rounded-full p-1" size={20} />
|
||||
) : (
|
||||
<Image className="text-white bg-black bg-opacity-50 rounded-full p-1" size={20} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 bg-gray-100">
|
||||
{getFileIcon(item)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-sm font-semibold truncate">{item.name}</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<div>Size: {formatFileSize(item.size)}</div>
|
||||
{!item.isDirectory && <div className="truncate">Type: {item.type || 'File'}</div>}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No items found in this directory.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderViewerPageWrapper = () => (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<FolderViewerPage />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
export default FolderViewerPageWrapper;
|
||||
|
|
@ -1,26 +1,60 @@
|
|||
@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% 98%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 215 20.2% 65.1%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
|
|
@ -25,9 +21,12 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${inter.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>
|
||||
);
|
||||
|
|
|
|||
106
src/app/page.tsx
106
src/app/page.tsx
|
|
@ -1,103 +1,13 @@
|
|||
import Image from "next/image";
|
||||
import { Header } from "@/components/ui/header";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Header title="Home" />
|
||||
<div className="flex flex-col items-center justify-center mt-8">
|
||||
<h2 className="text-xl font-semibold">Welcome to NextAV</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">Select a library from the sidebar to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Photo {
|
||||
id: number;
|
||||
path: string;
|
||||
title: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function PhotosPage() {
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPhotos();
|
||||
}, []);
|
||||
|
||||
const fetchPhotos = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/photos');
|
||||
const data = await response.json();
|
||||
setPhotos(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
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];
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg">Loading photos...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Photos</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{photos.map((photo) => (
|
||||
<Card key={photo.id} className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<img
|
||||
src={photo.thumbnail}
|
||||
alt={photo.title}
|
||||
className="w-full h-48 object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder-image.jpg';
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-sm truncate">{photo.title}</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<div>Size: {formatFileSize(photo.size)}</div>
|
||||
<div className="truncate">Path: {photo.path}</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{photos.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No photos found. Add media libraries to scan for photos.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Header } from "@/components/ui/header";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Trash2, Plus, Folder, HardDrive } from "lucide-react";
|
||||
|
||||
interface Library {
|
||||
id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const SettingsPage = () => {
|
||||
const [libraries, setLibraries] = useState<Library[]>([]);
|
||||
const [newLibraryPath, setNewLibraryPath] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scanStatus, setScanStatus] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, []);
|
||||
|
||||
const fetchLibraries = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/libraries");
|
||||
const data = await res.json();
|
||||
setLibraries(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching libraries:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const addLibrary = async () => {
|
||||
if (!newLibraryPath.trim()) {
|
||||
setError("Please enter a valid path");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/libraries", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ path: newLibraryPath.trim() }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setNewLibraryPath("");
|
||||
setError(null);
|
||||
fetchLibraries();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Failed to add library");
|
||||
}
|
||||
} catch (error) {
|
||||
setError("Network error occurred");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLibrary = async (id: number) => {
|
||||
if (!confirm("Are you sure you want to delete this library? This will not delete the actual files.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/libraries/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchLibraries();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting library:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
const scanLibraries = async () => {
|
||||
setIsScanning(true);
|
||||
setScanStatus("Scanning libraries...");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/scan", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setScanStatus("Scan completed successfully!");
|
||||
setTimeout(() => setScanStatus(""), 3000);
|
||||
} else {
|
||||
setScanStatus("Scan failed. Please try again.");
|
||||
}
|
||||
} catch (error) {
|
||||
setScanStatus("Network error during scan");
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalStorage = () => {
|
||||
return libraries.reduce((total, lib) => {
|
||||
// Rough estimation based on path length
|
||||
return total + (lib.path.length * 100); // Placeholder calculation
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<Header title="Settings" />
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{scanStatus && (
|
||||
<Alert className="mb-4">
|
||||
<AlertDescription>{scanStatus}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl flex items-center gap-2">
|
||||
<Folder className="h-6 w-6" />
|
||||
Manage Media Libraries
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add or remove media library paths to scan for videos and photos
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/mnt/media or /path/to/media"
|
||||
value={newLibraryPath}
|
||||
onChange={(e) => {
|
||||
setNewLibraryPath(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addLibrary()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={addLibrary} className="gap-2">
|
||||
<Plus size={16} />
|
||||
Add Library
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{libraries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold mb-3">Existing Libraries ({libraries.length})</h3>
|
||||
{libraries.map((lib) => (
|
||||
<div key={lib.id} className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<HardDrive className="h-5 w-5 text-gray-500 flex-shrink-0" />
|
||||
<span className="font-mono text-sm truncate">{lib.path}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteLibrary(lib.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>No media libraries configured yet.</p>
|
||||
<p className="text-sm">Add your first library above to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Media Scanner</CardTitle>
|
||||
<CardDescription>
|
||||
Scan your libraries for new videos and photos
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
onClick={scanLibraries}
|
||||
disabled={isScanning || libraries.length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{isScanning ? "Scanning..." : "Scan Libraries"}
|
||||
</Button>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{libraries.length === 0
|
||||
? "Add at least one library to enable scanning"
|
||||
: "This will scan all configured libraries for new media files"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">System Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Libraries:</span>
|
||||
<span className="font-semibold">{libraries.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Database:</span>
|
||||
<span className="font-semibold">SQLite</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Header } from "@/components/ui/header";
|
||||
|
||||
interface Video {
|
||||
id: number;
|
||||
title: string;
|
||||
path: string;
|
||||
size: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
const VideosPage = () => {
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/videos");
|
||||
const data = await res.json();
|
||||
setVideos(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching videos:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
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];
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg">Loading videos...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Header title="Videos" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{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"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder.svg';
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-sm font-semibold truncate">{video.title}</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<div>Size: {formatFileSize(video.size)}</div>
|
||||
<div className="truncate">Path: {video.path}</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{videos.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No videos found. Add media libraries to scan for videos.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideosPage;
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
|
||||
"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";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Sidebar = () => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, []);
|
||||
|
||||
const fetchLibraries = async () => {
|
||||
const res = await fetch("/api/libraries");
|
||||
const data = await res.json();
|
||||
setLibraries(data);
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Home", icon: Home },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
{ href: "/videos", label: "Videos", icon: Video },
|
||||
{ href: "/photos", label: "Photos", icon: Image },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-gray-100 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-2xl font-bold text-primary">NextAV</h1>
|
||||
)}
|
||||
<Button onClick={toggleSidebar} variant="ghost" size="icon">
|
||||
{isCollapsed ? <ChevronRight /> : <ChevronLeft />}
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="flex-1 mt-4 space-y-2 px-4">
|
||||
{navItems.map((item) => (
|
||||
<Link href={item.href} key={item.href} passHref>
|
||||
<Button
|
||||
variant={pathname === item.href ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<item.icon className="mr-4 h-5 w-5" />
|
||||
{!isCollapsed && item.label}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-4">
|
||||
<h2
|
||||
className={cn(
|
||||
"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={
|
||||
pathname === "/folder-viewer" &&
|
||||
new URLSearchParams(window.location.search).get("path") ===
|
||||
lib.path
|
||||
? "secondary"
|
||||
: "ghost"
|
||||
}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Folder className="mr-4 h-5 w-5" />
|
||||
{!isCollapsed && (
|
||||
<span className="truncate">{lib.path}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
|
|
@ -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,79 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((
|
||||
{ className, ...props },
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((
|
||||
{ className, ...props },
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>((
|
||||
{ className, ...props },
|
||||
ref
|
||||
) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((
|
||||
{ className, ...props },
|
||||
ref
|
||||
) => (
|
||||
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>((
|
||||
{ className, ...props },
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-4 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function Header({ title, className, ...props }: HeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-800",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-primary">{title}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,104 @@
|
|||
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", "flv", "webm", "m4v"];
|
||||
const PHOTO_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "svg"];
|
||||
|
||||
const generateVideoThumbnail = (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",
|
||||
timemarks: ["10%"]
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const generatePhotoThumbnail = (photoPath: string, thumbnailPath: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(photoPath)
|
||||
.on("end", () => resolve(thumbnailPath))
|
||||
.on("error", (err) => reject(err))
|
||||
.outputOptions(["-vf", "scale=320:240:force_original_aspect_ratio=decrease", "-quality", "85"])
|
||||
.output(thumbnailPath)
|
||||
.run();
|
||||
});
|
||||
};
|
||||
|
||||
const scanLibrary = async (library: { id: number; path: string }) => {
|
||||
// Scan videos
|
||||
const videoFiles = await glob(`${library.path}/**/*.{${VIDEO_EXTENSIONS.join(",")}}`, {
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
// Scan photos
|
||||
const photoFiles = await glob(`${library.path}/**/*.{${PHOTO_EXTENSIONS.join(",")}}`, {
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
const allFiles = [...videoFiles, ...photoFiles];
|
||||
|
||||
for (const file of allFiles) {
|
||||
const stats = fs.statSync(file);
|
||||
const title = path.basename(file);
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const isVideo = VIDEO_EXTENSIONS.some(v => ext.includes(v));
|
||||
const isPhoto = PHOTO_EXTENSIONS.some(p => ext.includes(p));
|
||||
|
||||
const mediaType = isVideo ? "video" : "photo";
|
||||
const thumbnailFileName = `${path.parse(title).name}_${Date.now()}.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;
|
||||
}
|
||||
|
||||
// Ensure thumbnails directory exists
|
||||
const thumbnailsDir = path.join(process.cwd(), "public", "thumbnails");
|
||||
if (!fs.existsSync(thumbnailsDir)) {
|
||||
fs.mkdirSync(thumbnailsDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
await generateVideoThumbnail(file, thumbnailPath);
|
||||
} else if (isPhoto) {
|
||||
await generatePhotoThumbnail(file, thumbnailPath);
|
||||
}
|
||||
|
||||
const media = {
|
||||
library_id: library.id,
|
||||
path: file,
|
||||
type: mediaType,
|
||||
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