Compare commits

..

4 Commits

Author SHA1 Message Date
tigeren e248613abb feat: add media library management features, including scanning and displaying photos and videos; implement database integration for media files 2025-08-25 08:11:03 +00:00
tigeren 31e27d4214 feat: add Header component for consistent page titles and layout 2025-08-25 07:28:21 +00:00
tigeren 8676a7d05a refactor: Update DELETE function to use NextRequest and Promise for params; enhance CSS with HSL colors; adjust Tailwind config for dark mode 2025-08-25 06:47:10 +00:00
tigeren 83dea7c651 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.
2025-08-25 06:15:49 +00:00
30 changed files with 2458 additions and 144 deletions

25
CLAUDE.md Normal file
View File

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

25
GEMINI.md Normal file
View File

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

25
PRD.md Normal file
View File

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

17
components.json Normal file
View File

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

BIN
media.db Normal file

Binary file not shown.

1053
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,16 +8,28 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "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": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"next": "15.5.0" "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@tailwindcss/postcss": "^4", "tailwindcss": "^4",
"tailwindcss": "^4" "typescript": "^5"
} }
} }

View File

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

View File

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

View File

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

View File

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

12
src/app/api/scan/route.ts Normal file
View File

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

View File

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

View File

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

View File

@ -1,26 +1,60 @@
@import "tailwindcss"; @tailwind base;
@tailwind components;
@tailwind utilities;
:root { @layer base {
--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) {
:root { :root {
--background: #0a0a0a; --background: 0 0% 98%;
--foreground: #ededed; --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 { @layer base {
background: var(--background); * {
color: var(--foreground); border-color: hsl(var(--border));
font-family: Arial, Helvetica, sans-serif; }
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
} }

View File

@ -1,14 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Sidebar from "@/components/sidebar";
const geistSans = Geist({ const inter = Inter({
variable: "--font-geist-sans", variable: "--font-inter",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
}); });
@ -25,9 +21,12 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body <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> </body>
</html> </html>
); );

View File

@ -1,103 +1,13 @@
import Image from "next/image"; import { Header } from "@/components/ui/header";
export default function Home() { export default function Home() {
return ( 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"> <div className="container mx-auto px-4 py-8">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> <Header title="Home" />
<Image <div className="flex flex-col items-center justify-center mt-8">
className="dark:invert" <h2 className="text-xl font-semibold">Welcome to NextAV</h2>
src="/next.svg" <p className="text-gray-500 dark:text-gray-400">Select a library from the sidebar to get started.</p>
alt="Next.js logo" </div>
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> </div>
); );
} }

85
src/app/photos/page.tsx Normal file
View File

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

233
src/app/settings/page.tsx Normal file
View File

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

93
src/app/videos/page.tsx Normal file
View File

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

112
src/components/sidebar.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

29
src/db/index.ts Normal file
View File

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

104
src/lib/scanner.ts Normal file
View File

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

7
src/lib/utils.ts Normal file
View File

@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

81
tailwind.config.ts Normal file
View File

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