Merge pull request 'feat: 实现 Mini Game AI 工具平台' (#1) from feature/mini-game-ai-platform into main

Reviewed-on: #1
This commit is contained in:
2026-01-20 14:27:31 +00:00
40 changed files with 10963 additions and 0 deletions

35
.env.example Normal file
View File

@@ -0,0 +1,35 @@
# Application Configuration
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_APP_NAME="Mini Game AI Tools"
# Database (Phase 6)
# DATABASE_URL="postgresql://..."
# NextAuth (Phase 6)
# NEXTAUTH_URL=http://localhost:3000
# NEXTAUTH_SECRET="your-secret-here"
# OAuth (Phase 6)
# GOOGLE_CLIENT_ID="your-google-client-id"
# GOOGLE_CLIENT_SECRET="your-google-client-secret"
# GITHUB_CLIENT_ID="your-github-client-id"
# GITHUB_CLIENT_SECRET="your-github-client-secret"
# AI Services (Phase 5)
# REPLICATE_API_TOKEN="your-replicate-token"
# OPENAI_API_KEY="your-openai-api-key"
# Storage Service (Production)
# CLOUDFLARE_R2_ACCOUNT_ID="your-account-id"
# CLOUDFLARE_R2_ACCESS_KEY_ID="your-access-key"
# CLOUDFLARE_R2_SECRET_ACCESS_KEY="your-secret-key"
# CLOUDFLARE_R2_BUCKET_NAME="mini-game-ai"
# Payment (Phase 6)
# STRIPE_PUBLIC_KEY="your-stripe-public-key"
# STRIPE_SECRET_KEY="your-stripe-secret-key"
# STRIPE_WEBHOOK_SECRET="your-webhook-secret"
# File Upload Limits
MAX_FILE_SIZE=52428800
MAX_FILE_SIZE_PREMIUM=524288000

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"plugins": ["prettier-plugin-tailwindcss"]
}

30
eslint.config.mjs Normal file
View File

@@ -0,0 +1,30 @@
import { FlatCompat } from "@eslint/eslintrc";
import { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-explicit-any": "warn",
"react-hooks/exhaustive-deps": "warn",
"prefer-const": "error",
},
},
];
export default eslintConfig;

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

48
next.config.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
formats: ["image/avif", "image/webp"],
},
// Optimize for production
poweredByHeader: false,
compress: true,
// Configure headers for security
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin",
},
],
},
];
},
};
export default nextConfig;

7440
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "mini-game-ai",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"format": "prettier --write ."
},
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.62.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ffmpeg-static": "^5.2.0",
"framer-motion": "^11.15.0",
"lucide-react": "^0.468.0",
"next": "^15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"sharp": "^0.33.5",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.20",
"eslint": "^9",
"eslint-config-next": "^15.1.6",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.10",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}

9
postcss.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,16 @@
import { Sidebar } from "@/components/layout/Sidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1 lg:ml-64">
<div className="min-h-[calc(100vh-4rem)]">{children}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import { useState, useCallback } from "react";
import { motion } from "framer-motion";
import { Music, Volume2 } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader";
import { ProgressBar } from "@/components/tools/ProgressBar";
import { ResultPreview } from "@/components/tools/ResultPreview";
import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import type { UploadedFile, ProcessedFile, AudioCompressConfig } from "@/types";
const audioAccept = {
"audio/*": [".mp3", ".wav", ".ogg", ".aac", ".flac", ".m4a"],
};
const defaultConfig: AudioCompressConfig = {
bitrate: 128,
format: "mp3",
sampleRate: 44100,
channels: 2,
};
const configOptions: ConfigOption[] = [
{
id: "bitrate",
type: "select",
label: "Bitrate",
description: "Higher bitrate = better quality, larger file",
value: defaultConfig.bitrate,
options: [
{ label: "64 kbps", value: 64 },
{ label: "128 kbps", value: 128 },
{ label: "192 kbps", value: 192 },
{ label: "256 kbps", value: 256 },
{ label: "320 kbps", value: 320 },
],
},
{
id: "format",
type: "select",
label: "Output Format",
description: "Target audio format",
value: defaultConfig.format,
options: [
{ label: "MP3", value: "mp3" },
{ label: "AAC", value: "aac" },
{ label: "OGG", value: "ogg" },
{ label: "FLAC", value: "flac" },
],
},
{
id: "sampleRate",
type: "select",
label: "Sample Rate",
description: "Audio sample rate in Hz",
value: defaultConfig.sampleRate,
options: [
{ label: "44.1 kHz", value: 44100 },
{ label: "48 kHz", value: 48000 },
],
},
{
id: "channels",
type: "radio",
label: "Channels",
description: "Audio channels",
value: defaultConfig.channels,
options: [
{ label: "Stereo (2 channels)", value: 2 },
{ label: "Mono (1 channel)", value: 1 },
],
},
];
export default function AudioCompressPage() {
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
const [config, setConfig] = useState<AudioCompressConfig>(defaultConfig);
const [processedFiles, setProcessedFiles] = useState<ProcessedFile[]>([]);
const handleFilesDrop = useCallback(
(acceptedFiles: File[]) => {
const newFiles: UploadedFile[] = acceptedFiles.map((file) => ({
id: generateId(),
file,
name: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date(),
}));
newFiles.forEach((file) => addFile(file));
},
[addFile]
);
const handleConfigChange = (id: string, value: any) => {
setConfig((prev) => ({ ...prev, [id]: value }));
};
const handleResetConfig = () => {
setConfig(defaultConfig);
};
const handleProcess = async () => {
if (files.length === 0) return;
setProcessingStatus({
status: "uploading",
progress: 0,
message: "Uploading audio...",
});
try {
// Simulate upload
for (let i = 0; i <= 100; i += 10) {
await new Promise((resolve) => setTimeout(resolve, 50));
setProcessingStatus({
status: "uploading",
progress: i,
message: `Uploading... ${i}%`,
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: "Compressing audio...",
});
// Simulate processing
for (let i = 0; i <= 100; i += 5) {
await new Promise((resolve) => setTimeout(resolve, 150));
setProcessingStatus({
status: "processing",
progress: i,
message: `Compressing... ${i}%`,
});
}
// Simulate completion
const results: ProcessedFile[] = files.map((file) => ({
id: generateId(),
originalFile: file,
processedUrl: "#",
metadata: {
format: config.format,
bitrate: config.bitrate,
sampleRate: config.sampleRate,
compressionRatio: Math.floor(Math.random() * 50) + 50, // Simulated 50-100%
},
createdAt: new Date(),
}));
setProcessedFiles(results);
clearFiles();
setProcessingStatus({
status: "completed",
progress: 100,
message: "Compression complete!",
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: "Compression failed",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
const handleDownload = (fileId: string) => {
console.log("Downloading file:", fileId);
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
return (
<div className="p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="mb-8">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Music className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">Audio Compression</h1>
<p className="text-muted-foreground">
Compress and convert audio files with quality control
</p>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<FileUploader
files={files}
onFilesDrop={handleFilesDrop}
onRemoveFile={removeFile}
accept={audioAccept}
maxSize={100 * 1024 * 1024} // 100MB
maxFiles={10}
disabled={processingStatus.status === "processing"}
/>
<ConfigPanel
title="Audio Settings"
description="Configure compression parameters"
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof AudioCompressConfig],
}))}
onChange={handleConfigChange}
onReset={handleResetConfig}
/>
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Volume2 className="mr-2 h-4 w-4" />
Compress Audio
</Button>
)}
</div>
<div className="space-y-6">
{processingStatus.status !== "idle" && (
<ProgressBar progress={processingStatus} />
)}
{processedFiles.length > 0 && (
<ResultPreview results={processedFiles} onDownload={handleDownload} />
)}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">Supported Formats</h3>
<div className="grid grid-cols-2 gap-3 text-sm text-muted-foreground">
<div>
<p className="font-medium text-foreground">Input</p>
<p>MP3, WAV, OGG, AAC, FLAC, M4A</p>
</div>
<div>
<p className="font-medium text-foreground">Output</p>
<p>MP3, AAC, OGG, FLAC</p>
</div>
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import { useState, useCallback } from "react";
import { motion } from "framer-motion";
import { Image as ImageIcon, Zap } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader";
import { ProgressBar } from "@/components/tools/ProgressBar";
import { ResultPreview } from "@/components/tools/ResultPreview";
import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types";
const imageAccept = {
"image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"],
};
const defaultConfig: ImageCompressConfig = {
quality: 80,
format: "original",
};
const configOptions: ConfigOption[] = [
{
id: "quality",
type: "slider",
label: "Compression Quality",
description: "Lower quality = smaller file size",
value: defaultConfig.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
icon: <Zap className="h-4 w-4" />,
},
{
id: "format",
type: "select",
label: "Output Format",
description: "Convert to a different format (optional)",
value: defaultConfig.format,
options: [
{ label: "Original", value: "original" },
{ label: "JPEG", value: "jpeg" },
{ label: "PNG", value: "png" },
{ label: "WebP", value: "webp" },
],
},
];
export default function ImageCompressPage() {
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
const [config, setConfig] = useState<ImageCompressConfig>(defaultConfig);
const [processedFiles, setProcessedFiles] = useState<ProcessedFile[]>([]);
const handleFilesDrop = useCallback(
(acceptedFiles: File[]) => {
const newFiles: UploadedFile[] = acceptedFiles.map((file) => ({
id: generateId(),
file,
name: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date(),
}));
newFiles.forEach((file) => addFile(file));
},
[addFile]
);
const handleConfigChange = (id: string, value: any) => {
setConfig((prev) => ({ ...prev, [id]: value }));
};
const handleResetConfig = () => {
setConfig(defaultConfig);
};
const handleProcess = async () => {
if (files.length === 0) return;
setProcessingStatus({
status: "uploading",
progress: 0,
message: "Uploading images...",
});
try {
// Simulate upload
for (let i = 0; i <= 100; i += 10) {
await new Promise((resolve) => setTimeout(resolve, 50));
setProcessingStatus({
status: "uploading",
progress: i,
message: `Uploading... ${i}%`,
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: "Compressing images...",
});
// Simulate processing
for (let i = 0; i <= 100; i += 5) {
await new Promise((resolve) => setTimeout(resolve, 100));
setProcessingStatus({
status: "processing",
progress: i,
message: `Compressing... ${i}%`,
});
}
// Simulate completion
const results: ProcessedFile[] = files.map((file) => ({
id: generateId(),
originalFile: file,
processedUrl: "#",
metadata: {
format: config.format === "original" ? file.file.type.split("/")[1] : config.format,
quality: config.quality,
compressionRatio: Math.floor(Math.random() * 30) + 40, // Simulated 40-70%
},
createdAt: new Date(),
}));
setProcessedFiles(results);
clearFiles();
setProcessingStatus({
status: "completed",
progress: 100,
message: "Compression complete!",
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: "Compression failed",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
const handleDownload = (fileId: string) => {
console.log("Downloading file:", fileId);
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
return (
<div className="p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="mb-8">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<ImageIcon className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">Image Compression</h1>
<p className="text-muted-foreground">
Optimize images for web and mobile without quality loss
</p>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<FileUploader
files={files}
onFilesDrop={handleFilesDrop}
onRemoveFile={removeFile}
accept={imageAccept}
maxSize={50 * 1024 * 1024} // 50MB
maxFiles={20}
disabled={processingStatus.status === "processing"}
/>
<ConfigPanel
title="Compression Settings"
description="Configure compression options"
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof ImageCompressConfig],
}))}
onChange={handleConfigChange}
onReset={handleResetConfig}
/>
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Zap className="mr-2 h-4 w-4" />
Compress Images
</Button>
)}
</div>
<div className="space-y-6">
{processingStatus.status !== "idle" && (
<ProgressBar progress={processingStatus} />
)}
{processedFiles.length > 0 && (
<ResultPreview results={processedFiles} onDownload={handleDownload} />
)}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">Features</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li> Batch processing - compress multiple images at once</li>
<li> Smart compression - maintains visual quality</li>
<li> Format conversion - PNG to JPEG, WebP, and more</li>
<li> Up to 80% size reduction without quality loss</li>
</ul>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useState, useCallback } from "react";
import { motion } from "framer-motion";
import { Video, Settings } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader";
import { ProgressBar } from "@/components/tools/ProgressBar";
import { ResultPreview } from "@/components/tools/ResultPreview";
import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import type { UploadedFile, ProcessedFile, VideoFramesConfig } from "@/types";
const videoAccept = {
"video/*": [".mp4", ".mov", ".avi", ".webm", ".mkv"],
};
const defaultConfig: VideoFramesConfig = {
fps: 30,
format: "png",
quality: 90,
width: undefined,
height: undefined,
};
const configOptions: ConfigOption[] = [
{
id: "fps",
type: "slider",
label: "Frame Rate",
description: "Number of frames to extract per second",
value: defaultConfig.fps,
min: 1,
max: 60,
step: 1,
suffix: " fps",
icon: <Video className="h-4 w-4" />,
},
{
id: "format",
type: "select",
label: "Output Format",
description: "Image format for the extracted frames",
value: defaultConfig.format,
options: [
{ label: "PNG", value: "png" },
{ label: "JPEG", value: "jpeg" },
{ label: "WebP", value: "webp" },
],
},
{
id: "quality",
type: "slider",
label: "Quality",
description: "Image quality (for JPEG and WebP)",
value: defaultConfig.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
},
];
export default function VideoFramesPage() {
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
const [config, setConfig] = useState<VideoFramesConfig>(defaultConfig);
const [processedFiles, setProcessedFiles] = useState<ProcessedFile[]>([]);
const handleFilesDrop = useCallback(
(acceptedFiles: File[]) => {
const newFiles: UploadedFile[] = acceptedFiles.map((file) => ({
id: generateId(),
file,
name: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date(),
}));
newFiles.forEach((file) => addFile(file));
},
[addFile]
);
const handleConfigChange = (id: string, value: any) => {
setConfig((prev) => ({ ...prev, [id]: value }));
};
const handleResetConfig = () => {
setConfig(defaultConfig);
};
const handleProcess = async () => {
if (files.length === 0) return;
setProcessingStatus({
status: "uploading",
progress: 0,
message: "Uploading video...",
});
try {
// Simulate upload
for (let i = 0; i <= 100; i += 10) {
await new Promise((resolve) => setTimeout(resolve, 100));
setProcessingStatus({
status: "uploading",
progress: i,
message: `Uploading... ${i}%`,
});
}
setProcessingStatus({
status: "processing",
progress: 0,
message: "Extracting frames...",
});
// Simulate processing
for (let i = 0; i <= 100; i += 5) {
await new Promise((resolve) => setTimeout(resolve, 150));
setProcessingStatus({
status: "processing",
progress: i,
message: `Processing... ${i}%`,
});
}
// Simulate completion
const results: ProcessedFile[] = files.map((file) => ({
id: generateId(),
originalFile: file,
processedUrl: "#",
metadata: {
format: config.format,
quality: config.quality,
fps: config.fps,
frames: Math.floor(10 * config.fps), // Simulated
},
createdAt: new Date(),
}));
setProcessedFiles(results);
clearFiles();
setProcessingStatus({
status: "completed",
progress: 100,
message: "Processing complete!",
});
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: "Processing failed",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
const handleDownload = (fileId: string) => {
console.log("Downloading file:", fileId);
// Implement download logic
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
return (
<div className="p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<Video className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">Video to Frames</h1>
<p className="text-muted-foreground">
Extract frames from videos with customizable settings
</p>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Left Column - Upload and Config */}
<div className="space-y-6">
<FileUploader
files={files}
onFilesDrop={handleFilesDrop}
onRemoveFile={removeFile}
accept={videoAccept}
maxSize={500 * 1024 * 1024} // 500MB
maxFiles={1}
disabled={processingStatus.status === "processing"}
/>
<ConfigPanel
title="Export Settings"
description="Configure how frames are extracted"
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof VideoFramesConfig],
}))}
onChange={handleConfigChange}
onReset={handleResetConfig}
/>
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<Settings className="mr-2 h-4 w-4" />
Process Video
</Button>
)}
</div>
{/* Right Column - Progress and Results */}
<div className="space-y-6">
{processingStatus.status !== "idle" && (
<ProgressBar progress={processingStatus} />
)}
{processedFiles.length > 0 && (
<ResultPreview results={processedFiles} onDownload={handleDownload} />
)}
{/* Info Card */}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">How it works</h3>
<ol className="space-y-2 text-sm text-muted-foreground">
<li>1. Upload your video file (MP4, MOV, AVI, etc.)</li>
<li>2. Configure frame rate, format, and quality</li>
<li>3. Click &quot;Process Video&quot; to start extraction</li>
<li>4. Download the ZIP file with all frames</li>
</ol>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import type { AudioCompressConfig } from "@/types";
export const runtime = "nodejs";
interface ProcessRequest {
fileId: string;
config: AudioCompressConfig;
}
export async function POST(request: NextRequest) {
try {
const body: ProcessRequest = await request.json();
const { fileId, config } = body;
if (!fileId) {
return NextResponse.json(
{ success: false, error: "No file ID provided" },
{ status: 400 }
);
}
// Validate config
if (!config.bitrate || config.bitrate < 64 || config.bitrate > 320) {
return NextResponse.json(
{ success: false, error: "Invalid bitrate value" },
{ status: 400 }
);
}
// In production, you would:
// 1. Retrieve the file from storage
// 2. Use FFmpeg to compress the audio
// 3. Apply format conversion if needed
// 4. Upload to R2/S3
// 5. Return download URL
// Mock processing for now
const resultFileId = `processed-${Date.now()}`;
return NextResponse.json({
success: true,
fileUrl: `/api/download/${resultFileId}`,
filename: `compressed-${resultFileId}.${config.format}`,
metadata: {
format: config.format,
bitrate: config.bitrate,
sampleRate: config.sampleRate,
channels: config.channels,
compressionRatio: Math.floor(Math.random() * 50) + 50, // Mock 50-100%
},
});
} catch (error) {
console.error("Processing error:", error);
return NextResponse.json(
{ success: false, error: "Processing failed" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import type { ImageCompressConfig } from "@/types";
export const runtime = "nodejs";
interface ProcessRequest {
fileId: string;
config: ImageCompressConfig;
}
export async function POST(request: NextRequest) {
try {
const body: ProcessRequest = await request.json();
const { fileId, config } = body;
if (!fileId) {
return NextResponse.json(
{ success: false, error: "No file ID provided" },
{ status: 400 }
);
}
// Validate config
if (!config.quality || config.quality < 1 || config.quality > 100) {
return NextResponse.json(
{ success: false, error: "Invalid quality value" },
{ status: 400 }
);
}
// In production, you would:
// 1. Retrieve the file from storage
// 2. Use Sharp to compress the image
// 3. Apply format conversion if needed
// 4. Upload to R2/S3
// 5. Return download URL
// Mock processing for now
const resultFileId = `processed-${Date.now()}`;
return NextResponse.json({
success: true,
fileUrl: `/api/download/${resultFileId}`,
filename: `compressed-${resultFileId}`,
metadata: {
format: config.format,
quality: config.quality,
compressionRatio: Math.floor(Math.random() * 30) + 40, // Mock 40-70%
},
});
} catch (error) {
console.error("Processing error:", error);
return NextResponse.json(
{ success: false, error: "Processing failed" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import type { VideoFramesConfig } from "@/types";
export const runtime = "nodejs";
interface ProcessRequest {
fileId: string;
config: VideoFramesConfig;
}
export async function POST(request: NextRequest) {
try {
const body: ProcessRequest = await request.json();
const { fileId, config } = body;
if (!fileId) {
return NextResponse.json(
{ success: false, error: "No file ID provided" },
{ status: 400 }
);
}
// Validate config
if (!config.fps || config.fps < 1 || config.fps > 60) {
return NextResponse.json(
{ success: false, error: "Invalid FPS value" },
{ status: 400 }
);
}
// In production, you would:
// 1. Retrieve the file from storage
// 2. Use FFmpeg to extract frames
// 3. Create a ZIP file with all frames
// 4. Upload to R2/S3
// 5. Return download URL
// Mock processing for now
const resultFileId = `processed-${Date.now()}`;
return NextResponse.json({
success: true,
fileUrl: `/api/download/${resultFileId}`,
filename: `frames-${resultFileId}.zip`,
metadata: {
fps: config.fps,
format: config.format,
quality: config.quality,
frames: Math.floor(10 * config.fps), // Mock calculation
},
});
} catch (error) {
console.error("Processing error:", error);
return NextResponse.json(
{ success: false, error: "Processing failed" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ success: false, error: "No file provided" },
{ status: 400 }
);
}
// Check file size
const maxSize = parseInt(process.env.MAX_FILE_SIZE || "52428800"); // 50MB default
if (file.size > maxSize) {
return NextResponse.json(
{ success: false, error: `File size exceeds ${maxSize / 1024 / 1024}MB limit` },
{ status: 400 }
);
}
// Generate file ID
const fileId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
// In production, you would:
// 1. Save to Cloudflare R2 or S3
// 2. Return the actual URL
// For now, we'll return a mock response
return NextResponse.json({
success: true,
fileId,
fileUrl: `/uploads/${fileId}`,
metadata: {
name: file.name,
size: file.size,
type: file.type,
},
});
} catch (error) {
console.error("Upload error:", error);
return NextResponse.json(
{ success: false, error: "Upload failed" },
{ status: 500 }
);
}
}

180
src/app/globals.css Normal file
View File

@@ -0,0 +1,180 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 262.1 83.3% 57.8%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer components {
/* Glassmorphism effect */
.glass {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-card {
@apply glass rounded-lg p-6;
}
/* Gradient text */
.gradient-text {
@apply bg-gradient-to-r from-purple-400 via-pink-500 to-blue-500 bg-clip-text text-transparent;
}
/* Custom scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-secondary;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded-md;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}
/* Button variants */
.btn-primary {
@apply bg-primary text-primary-foreground hover:bg-primary/90;
}
.btn-secondary {
@apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
}
/* Animations */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.shimmer {
animation: shimmer 2s infinite linear;
background: linear-gradient(
to right,
transparent 0%,
rgba(255, 255, 255, 0.05) 50%,
transparent 100%
);
background-size: 1000px 100%;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
/* Container class for responsive layouts */
.container {
width: 100%;
margin-right: auto;
margin-left: auto;
padding-right: 1rem;
padding-left: 1rem;
}
@media (min-width: 640px) {
.container {
max-width: 640px;
padding-right: 1.5rem;
padding-left: 1.5rem;
}
}
@media (min-width: 768px) {
.container {
max-width: 768px;
padding-right: 2rem;
padding-left: 2rem;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
}
@media (min-width: 1280px) {
.container {
max-width: 1280px;
}
}
@media (min-width: 1536px) {
.container {
max-width: 1536px;
}
}
@media (min-width: 1920px) {
.container {
max-width: 1920px;
}
}
@media (min-width: 2560px) {
.container {
max-width: 2560px;
padding-right: 3rem;
padding-left: 3rem;
}
}
@media (min-width: 3840px) {
.container {
max-width: 3200px;
padding-right: 4rem;
padding-left: 4rem;
}
}

32
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { cn } from "@/lib/utils";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "Mini Game AI - AI-Powered Tools for Game Developers",
description: "Transform your game development workflow with AI-powered tools. Video to frames, image compression, audio processing, and more.",
keywords: ["game development", "AI tools", "video processing", "image compression", "audio processing"],
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className={cn("min-h-screen bg-background font-sans antialiased", inter.variable)}>
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</body>
</html>
);
}

391
src/app/page.tsx Normal file
View File

@@ -0,0 +1,391 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import {
ArrowRight,
Video,
Image,
Music,
Sparkles,
Zap,
Shield,
Users,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const features = [
{
icon: Video,
title: "Video to Frames",
description: "Extract frames from videos with customizable frame rates and formats. Perfect for sprite animations.",
href: "/tools/video-frames",
},
{
icon: Image,
title: "Image Compression",
description: "Optimize images for web and mobile without quality loss. Support for batch processing.",
href: "/tools/image-compress",
},
{
icon: Music,
title: "Audio Compression",
description: "Compress and convert audio files to various formats. Adjust bitrate and sample rate.",
href: "/tools/audio-compress",
},
{
icon: Sparkles,
title: "AI-Powered Tools",
description: "Enhance your assets with AI. Upscale images, remove backgrounds, and more.",
href: "/tools/ai-tools",
},
];
const benefits = [
{
icon: Zap,
title: "Lightning Fast",
description: "Process files in seconds with our optimized infrastructure.",
},
{
icon: Shield,
title: "Secure & Private",
description: "Your files are encrypted and automatically deleted after processing.",
},
{
icon: Users,
title: "Built for Developers",
description: "API access, batch processing, and tools designed for game development workflows.",
},
];
const pricingPlans = [
{
name: "Free",
price: "$0",
description: "Perfect for trying out",
features: [
"10 processes per day",
"50MB max file size",
"Basic tools",
"Community support",
],
cta: "Get Started",
href: "/register",
},
{
name: "Pro",
price: "$19",
period: "/month",
description: "For serious developers",
features: [
"Unlimited processes",
"500MB max file size",
"All tools including AI",
"Priority support",
"API access",
],
cta: "Start Free Trial",
href: "/pricing",
popular: true,
},
{
name: "Enterprise",
price: "Custom",
description: "For teams and businesses",
features: [
"Everything in Pro",
"Unlimited file size",
"Custom integrations",
"Dedicated support",
"SLA guarantee",
],
cta: "Contact Sales",
href: "/contact",
},
];
function HeroSection() {
return (
<section className="relative overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-0 -z-10">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-background to-background" />
<div className="absolute inset-0 bg-[url(/grid.svg)] bg-cover opacity-10" />
</div>
<div className="container py-24 md:py-32 xl:py-40 2xl:py-48">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mx-auto max-w-5xl text-center 2xl:max-w-6xl 3xl:max-w-7xl"
>
<Badge className="mb-4" variant="secondary">
<Sparkles className="mr-1 h-3 w-3" />
AI-Powered Tools
</Badge>
<h1 className="mb-6 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl 2xl:text-9xl">
Build Games{" "}
<span className="bg-gradient-to-r from-purple-400 via-pink-500 to-blue-500 bg-clip-text text-transparent">
Faster
</span>
</h1>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
Transform your game development workflow with powerful AI tools. Video to frames,
image compression, audio processing, and more.
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6">
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/tools">
Start Building <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button size="lg" variant="outline" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/pricing">View Pricing</Link>
</Button>
</div>
{/* Stats */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="mt-16 grid grid-cols-3 gap-8 md:gap-16 xl:gap-24 2xl:gap-32"
>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">10K+</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Developers</div>
</div>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">1M+</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Files Processed</div>
</div>
<div>
<div className="text-3xl font-bold md:text-4xl xl:text-5xl 2xl:text-6xl">99.9%</div>
<div className="text-sm text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">Uptime</div>
</div>
</motion.div>
</motion.div>
</div>
</section>
);
}
function FeaturesSection() {
return (
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
<div className="container">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-16 text-center xl:mb-20 2xl:mb-24"
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
Everything You Need
</h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
Powerful tools designed specifically for game developers
</p>
</motion.div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4 xl:gap-8 2xl:gap-10">
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link href={feature.href}>
<Card className="h-full transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 xl:p-8 2xl:p-10">
<CardHeader>
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 xl:h-14 xl:w-14 2xl:h-16 2xl:w-16">
<feature.icon className="h-6 w-6 text-primary xl:h-7 xl:w-7 2xl:h-8 2xl:w-8" />
</div>
<CardTitle className="text-xl xl:text-2xl 2xl:text-3xl">{feature.title}</CardTitle>
<CardDescription className="text-base xl:text-lg 2xl:text-xl">{feature.description}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="ghost" size="sm" className="w-full xl:text-base 2xl:text-lg">
Try it now <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
</Link>
</motion.div>
))}
</div>
</div>
</section>
);
}
function BenefitsSection() {
return (
<section className="py-24 xl:py-32 2xl:py-40">
<div className="container">
<div className="grid gap-12 lg:grid-cols-2 xl:gap-16 2xl:gap-20">
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
Why Choose Mini Game AI?
</h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
We understand the unique challenges of game development. Our tools are built to help
you work faster and smarter.
</p>
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/about">Learn More About Us</Link>
</Button>
</motion.div>
<div className="space-y-6 xl:space-y-8">
{benefits.map((benefit, index) => (
<motion.div
key={benefit.title}
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card className="xl:p-8 2xl:p-10">
<CardContent className="flex gap-4 p-6 xl:gap-6 2xl:gap-8">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10 xl:h-14 xl:w-14 2xl:h-16 2xl:w-16">
<benefit.icon className="h-6 w-6 text-primary xl:h-7 xl:w-7 2xl:h-8 2xl:w-8" />
</div>
<div>
<h3 className="mb-2 text-lg font-semibold xl:text-xl 2xl:text-2xl">{benefit.title}</h3>
<p className="text-muted-foreground text-sm md:text-base xl:text-lg 2xl:text-xl">{benefit.description}</p>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
</div>
</section>
);
}
function PricingSection() {
return (
<section className="border-t border-border/40 bg-background/50 py-24 xl:py-32 2xl:py-40">
<div className="container">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="mb-16 text-center xl:mb-20 2xl:mb-24"
>
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
Simple, Transparent Pricing
</h2>
<p className="text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
Start free, scale as you grow. No hidden fees.
</p>
</motion.div>
<div className="grid gap-8 md:grid-cols-3 xl:gap-10 2xl:gap-12">
{pricingPlans.map((plan, index) => (
<motion.div
key={plan.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Card className={`relative h-full ${plan.popular ? "border-primary" : ""} xl:p-8 2xl:p-10`}>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 xl:text-base 2xl:text-lg">
Most Popular
</Badge>
)}
<CardHeader>
<CardTitle className="text-xl xl:text-2xl 2xl:text-3xl">{plan.name}</CardTitle>
<CardDescription className="text-base xl:text-lg 2xl:text-xl">{plan.description}</CardDescription>
<div className="mt-4">
<span className="text-4xl font-bold xl:text-5xl 2xl:text-6xl">{plan.price}</span>
{plan.period && (
<span className="text-muted-foreground md:text-base xl:text-lg 2xl:text-xl">{plan.period}</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="h-5 w-5 shrink-0 text-primary xl:h-6 xl:w-6 2xl:h-7 2xl:w-7" />
<span className="text-sm md:text-base xl:text-lg 2xl:text-xl">{feature}</span>
</li>
))}
</ul>
<Button className="w-full xl:text-lg xl:py-6 2xl:text-xl 2xl:py-7" variant={plan.popular ? "default" : "outline"} asChild>
<Link href={plan.href}>{plan.cta}</Link>
</Button>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
</section>
);
}
function CTASection() {
return (
<section className="py-24 xl:py-32 2xl:py-40">
<div className="container">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="relative overflow-hidden rounded-2xl bg-gradient-to-r from-primary/20 via-primary/10 to-background p-12 text-center md:p-20 xl:p-24 2xl:p-32"
>
<div className="relative z-10">
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl xl:text-5xl 2xl:text-6xl">
Ready to Level Up?
</h2>
<p className="mb-8 text-lg text-muted-foreground md:text-xl xl:text-2xl 2xl:text-3xl">
Join thousands of game developers building amazing games with our tools.
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row xl:gap-6">
<Button size="lg" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/register">Get Started for Free</Link>
</Button>
<Button size="lg" variant="outline" asChild className="xl:text-lg xl:px-8 xl:py-6 2xl:text-xl 2xl:px-10 2xl:py-7">
<Link href="/contact">Contact Sales</Link>
</Button>
</div>
</div>
</motion.div>
</div>
</section>
);
}
export default function HomePage() {
return (
<>
<HeroSection />
<FeaturesSection />
<BenefitsSection />
<PricingSection />
<CTASection />
</>
);
}

View File

@@ -0,0 +1,146 @@
import Link from "next/link";
import { Sparkles, Github, Twitter } from "lucide-react";
const footerLinks = {
product: [
{ name: "Features", href: "/features" },
{ name: "Pricing", href: "/pricing" },
{ name: "API", href: "/api" },
{ name: "Documentation", href: "/docs" },
],
tools: [
{ name: "Video to Frames", href: "/tools/video-frames" },
{ name: "Image Compression", href: "/tools/image-compress" },
{ name: "Audio Compression", href: "/tools/audio-compress" },
{ name: "AI Tools", href: "/tools/ai-tools" },
],
company: [
{ name: "About", href: "/about" },
{ name: "Blog", href: "/blog" },
{ name: "Careers", href: "/careers" },
{ name: "Contact", href: "/contact" },
],
legal: [
{ name: "Privacy", href: "/privacy" },
{ name: "Terms", href: "/terms" },
{ name: "Cookie Policy", href: "/cookies" },
],
};
const socialLinks = [
{ name: "Twitter", icon: Twitter, href: "https://twitter.com" },
{ name: "GitHub", icon: Github, href: "https://github.com" },
];
export function Footer() {
return (
<footer className="border-t border-border/40 bg-background/50">
<div className="container py-12 md:py-16">
<div className="grid grid-cols-2 gap-8 md:grid-cols-6">
{/* Brand */}
<div className="col-span-2">
<Link href="/" className="flex items-center space-x-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">Mini Game AI</span>
</Link>
<p className="mt-4 text-sm text-muted-foreground">
AI-powered tools for mini game developers. Process media files with ease.
</p>
</div>
{/* Product */}
<div>
<h3 className="mb-4 text-sm font-semibold">Product</h3>
<ul className="space-y-3 text-sm">
{footerLinks.product.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Tools */}
<div>
<h3 className="mb-4 text-sm font-semibold">Tools</h3>
<ul className="space-y-3 text-sm">
{footerLinks.tools.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Company */}
<div>
<h3 className="mb-4 text-sm font-semibold">Company</h3>
<ul className="space-y-3 text-sm">
{footerLinks.company.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Legal */}
<div>
<h3 className="mb-4 text-sm font-semibold">Legal</h3>
<ul className="space-y-3 text-sm">
{footerLinks.legal.map((link) => (
<li key={link.name}>
<Link
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* Bottom section */}
<div className="mt-12 flex flex-col items-center justify-between border-t border-border/40 pt-8 md:flex-row">
<p className="text-sm text-muted-foreground">
© {new Date().getFullYear()} Mini Game AI. All rights reserved.
</p>
<div className="mt-4 flex space-x-6 md:mt-0">
{socialLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className="text-muted-foreground transition-colors hover:text-foreground"
aria-label={link.name}
>
<Icon className="h-5 w-5" />
</Link>
);
})}
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const navItems = [
{ name: "Tools", href: "/tools" },
{ name: "Pricing", href: "/pricing" },
{ name: "Docs", href: "/docs" },
{ name: "About", href: "/about" },
];
export function Header() {
const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<nav className="container flex h-16 items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Sparkles className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-xl font-bold">Mini Game AI</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex md:items-center md:space-x-6">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
className={cn(
"text-sm font-medium transition-colors hover:text-primary",
pathname === item.href ? "text-primary" : "text-muted-foreground"
)}
>
{item.name}
</Link>
))}
</div>
{/* CTA Buttons */}
<div className="hidden md:flex md:items-center md:space-x-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/login">Sign In</Link>
</Button>
<Button size="sm" asChild>
<Link href="/register">Get Started</Link>
</Button>
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu"
>
{isMobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</button>
</nav>
{/* Mobile Menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="md:hidden border-t border-border/40"
>
<div className="container space-y-4 py-6">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
"block text-sm font-medium transition-colors hover:text-primary",
pathname === item.href ? "text-primary" : "text-muted-foreground"
)}
>
{item.name}
</Link>
))}
<div className="flex flex-col space-y-2 pt-4">
<Button variant="ghost" size="sm" asChild className="w-full">
<Link href="/login">Sign In</Link>
</Button>
<Button size="sm" asChild className="w-full">
<Link href="/register">Get Started</Link>
</Button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</header>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { motion } from "framer-motion";
import {
Video,
Image,
Music,
Sparkles,
LayoutDashboard,
CreditCard,
Settings,
} from "lucide-react";
import { cn } from "@/lib/utils";
const sidebarNavItems = [
{
title: "Dashboard",
items: [
{ name: "Overview", href: "/dashboard", icon: LayoutDashboard },
],
},
{
title: "Tools",
items: [
{ name: "Video to Frames", href: "/tools/video-frames", icon: Video },
{ name: "Image Compression", href: "/tools/image-compress", icon: Image },
{ name: "Audio Compression", href: "/tools/audio-compress", icon: Music },
],
},
{
title: "AI Tools",
items: [
{ name: "AI Image", href: "/tools/ai-image", icon: Sparkles },
{ name: "AI Audio", href: "/tools/ai-audio", icon: Sparkles },
],
},
{
title: "Account",
items: [
{ name: "Pricing", href: "/pricing", icon: CreditCard },
{ name: "Settings", href: "/settings", icon: Settings },
],
},
];
interface SidebarProps {
className?: string;
}
export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname();
return (
<aside
className={cn(
"fixed left-0 top-16 z-40 h-[calc(100vh-4rem)] w-64 border-r border-border/40 bg-background/95 backdrop-blur",
className
)}
>
<div className="h-full overflow-y-auto py-6 pr-4">
<nav className="space-y-8 px-4">
{sidebarNavItems.map((section) => (
<div key={section.title}>
<h3 className="mb-4 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.title}
</h3>
<ul className="space-y-1">
{section.items.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname?.startsWith(item.href + "/");
return (
<li key={item.name}>
<Link
href={item.href}
className={cn(
"group flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-all",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className={cn("mr-3 h-4 w-4", isActive ? "text-primary" : "")} />
<span className="flex-1">{item.name}</span>
{isActive && (
<motion.div
layoutId="activeIndicator"
className="h-4 w-1 rounded-full bg-primary"
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
</div>
</aside>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export interface ConfigOption {
id: string;
type: "slider" | "select" | "toggle" | "radio";
label: string;
description?: string;
value: any;
options?: { label: string; value: any }[];
min?: number;
max?: number;
step?: number;
suffix?: string;
icon?: React.ReactNode;
}
interface ConfigPanelProps {
title: string;
description?: string;
options: ConfigOption[];
onChange: (id: string, value: any) => void;
onReset?: () => void;
className?: string;
}
export function ConfigPanel({
title,
description,
options,
onChange,
onReset,
className,
}: ConfigPanelProps) {
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{title}</CardTitle>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{onReset && (
<Button variant="ghost" size="sm" onClick={onReset}>
Reset
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
{options.map((option) => (
<div key={option.id} className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{option.icon && <div className="text-muted-foreground">{option.icon}</div>}
<Label htmlFor={option.id} className="font-medium">
{option.label}
</Label>
</div>
{option.type === "slider" && (
<Badge variant="secondary">
{option.value}
{option.suffix}
</Badge>
)}
</div>
{option.description && (
<p className="text-xs text-muted-foreground">{option.description}</p>
)}
{option.type === "slider" && (
<Slider
id={option.id}
min={option.min ?? 0}
max={option.max ?? 100}
step={option.step ?? 1}
value={[option.value]}
onValueChange={(values: number[]) => onChange(option.id, values[0])}
className="mt-2"
/>
)}
{option.type === "select" && option.options && (
<div className="flex flex-wrap gap-2">
{option.options.map((opt) => (
<Button
key={opt.value}
variant={option.value === opt.value ? "default" : "outline"}
size="sm"
onClick={() => onChange(option.id, opt.value)}
>
{opt.label}
</Button>
))}
</div>
)}
{option.type === "radio" && option.options && (
<div className="space-y-2">
{option.options.map((opt) => (
<label
key={opt.value}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-accent",
option.value === opt.value && "border-primary bg-primary/10"
)}
>
<input
type="radio"
name={option.id}
value={opt.value}
checked={option.value === opt.value}
onChange={() => onChange(option.id, opt.value)}
className="h-4 w-4"
/>
<span className="text-sm">{opt.label}</span>
</label>
))}
</div>
)}
</div>
))}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useCallback } from "react";
import { useDropzone } from "react-dropzone";
import { motion, AnimatePresence } from "framer-motion";
import { Upload, File, X, FileVideo, FileImage, Music } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatFileSize, getFileExtension } from "@/lib/utils";
import type { UploadedFile } from "@/types";
interface FileUploaderProps {
onFilesDrop: (files: File[]) => void;
files: UploadedFile[];
onRemoveFile: (id: string) => void;
accept?: Record<string, string[]>;
maxSize?: number;
maxFiles?: number;
disabled?: boolean;
}
const defaultAccept = {
"image/*": [".png", ".jpg", ".jpeg", ".webp"],
"video/*": [".mp4", ".mov", ".avi", ".webm"],
"audio/*": [".mp3", ".wav", ".ogg", ".aac"],
};
export function FileUploader({
onFilesDrop,
files,
onRemoveFile,
accept = defaultAccept,
maxSize = 50 * 1024 * 1024, // 50MB
maxFiles = 10,
disabled = false,
}: FileUploaderProps) {
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (disabled) return;
onFilesDrop(acceptedFiles);
},
[onFilesDrop, disabled]
);
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop,
accept,
maxSize,
maxFiles,
disabled,
multiple: maxFiles > 1,
});
const getFileIcon = (file: File) => {
if (file.type.startsWith("image/")) return FileImage;
if (file.type.startsWith("video/")) return FileVideo;
if (file.type.startsWith("audio/")) return Music;
return File;
};
return (
<div className="space-y-4">
{/* Dropzone */}
<div {...getRootProps()}>
<motion.div
whileHover={disabled ? {} : { scale: 1.01 }}
whileTap={disabled ? {} : { scale: 0.99 }}
className={`
relative cursor-pointer rounded-lg border-2 border-dashed p-12 text-center transition-all
${
isDragActive
? "border-primary bg-primary/5"
: isDragReject
? "border-destructive bg-destructive/5"
: "border-border hover:border-primary/50 hover:bg-accent/5"
}
${disabled ? "cursor-not-allowed opacity-50" : ""}
`}
>
<input {...getInputProps()} />
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Upload className="h-8 w-8 text-primary" />
</div>
<div className="mt-4">
<p className="text-lg font-medium">
{isDragActive
? "Drop your files here"
: isDragReject
? "File type not accepted"
: "Drag & drop files here"}
</p>
<p className="mt-2 text-sm text-muted-foreground">
or click to browse Max {formatFileSize(maxSize)} Up to {maxFiles} file
{maxFiles > 1 ? "s" : ""}
</p>
</div>
</motion.div>
</div>
{/* File List */}
<AnimatePresence>
{files.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="space-y-2"
>
{files.map((file, index) => {
const Icon = getFileIcon(file.file);
return (
<motion.div
key={file.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.05 }}
>
<Card>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} {getFileExtension(file.name).toUpperCase()}
</p>
</div>
<Badge variant="secondary" className="shrink-0">
{file.file.type.split("/")[1].toUpperCase()}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => onRemoveFile(file.id)}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</CardContent>
</Card>
</motion.div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { motion } from "framer-motion";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ProcessingProgress } from "@/types";
interface ProgressBarProps {
progress: ProcessingProgress;
className?: string;
}
const statusIcons = {
idle: null,
uploading: <Loader2 className="h-5 w-5 animate-spin text-primary" />,
processing: <Loader2 className="h-5 w-5 animate-spin text-primary" />,
completed: <CheckCircle2 className="h-5 w-5 text-green-500" />,
failed: <XCircle className="h-5 w-5 text-destructive" />,
};
const statusColors = {
idle: "text-muted-foreground",
uploading: "text-primary",
processing: "text-primary",
completed: "text-green-500",
failed: "text-destructive",
};
export function ProgressBar({ progress, className }: ProgressBarProps) {
const { status, progress: value, message, error } = progress;
const showProgress = status === "uploading" || status === "processing";
const Icon = statusIcons[status];
return (
<Card className={className}>
<CardContent className="p-6">
<div className="flex items-center gap-4">
{Icon && <div className="shrink-0">{Icon}</div>}
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center justify-between">
<p
className={cn(
"text-sm font-medium capitalize",
statusColors[status]
)}
>
{status === "idle" && "Ready to process"}
{status === "uploading" && "Uploading..."}
{status === "processing" && "Processing..."}
{status === "completed" && "Completed!"}
{status === "failed" && "Failed"}
</p>
{showProgress && (
<span className="text-sm font-medium text-muted-foreground">
{value}%
</span>
)}
</div>
{showProgress && (
<motion.div
initial={{ opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
className="mb-2"
>
<Progress value={value} className="h-2" />
</motion.div>
)}
{message && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm text-muted-foreground"
>
{message}
</motion.p>
)}
{error && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-2 text-sm text-destructive"
>
{error}
</motion.p>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { motion } from "framer-motion";
import { Download, Share2, File, Image as ImageIcon, Video, Music } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatFileSize } from "@/lib/utils";
import type { ProcessedFile } from "@/types";
interface ResultPreviewProps {
results: ProcessedFile[];
onDownload: (fileId: string) => void;
onShare?: (fileId: string) => void;
className?: string;
}
export function ResultPreview({
results,
onDownload,
onShare,
className,
}: ResultPreviewProps) {
if (results.length === 0) return null;
const getFileIcon = (type: string) => {
if (type.startsWith("image/")) return ImageIcon;
if (type.startsWith("video/")) return Video;
if (type.startsWith("audio/")) return Music;
return File;
};
const getMetadataBadge = (file: ProcessedFile) => {
const metadata = file.metadata;
const badges = [];
if (metadata.compressionRatio) {
badges.push({
label: `Saved ${metadata.compressionRatio}%`,
variant: "default" as const,
});
}
if (metadata.format) {
badges.push({
label: metadata.format.toUpperCase(),
variant: "secondary" as const,
});
}
return badges;
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={className}
>
<div className="mb-4">
<h3 className="text-lg font-semibold">Processing Complete</h3>
<p className="text-sm text-muted-foreground">
{results.length} file{results.length > 1 ? "s" : ""} ready for download
</p>
</div>
<div className="space-y-3">
{results.map((result, index) => {
const Icon = getFileIcon(result.originalFile.file.type);
const badges = getMetadataBadge(result);
return (
<motion.div
key={result.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{result.originalFile.name}
</p>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatFileSize(result.originalFile.size)}
</span>
{result.metadata.resolution && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">
{result.metadata.resolution}
</span>
</>
)}
</div>
{badges.length > 0 && (
<div className="mt-2 flex gap-2">
{badges.map((badge, idx) => (
<Badge key={idx} variant={badge.variant} className="text-xs">
{badge.label}
</Badge>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => onDownload(result.id)}
title="Download"
>
<Download className="h-4 w-4" />
</Button>
{onShare && (
<Button
variant="outline"
size="icon"
onClick={() => onShare(result.id)}
title="Share"
>
<Share2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,48 @@
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,55 @@
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-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl 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-6 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-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value?: number }
>(({ className, value = 0, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<div
className="h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out"
style={{ transform: `translateX(-${100 - Math.min(100, Math.max(0, value))}%)` }}
/>
</div>
));
Progress.displayName = "Progress";
export { Progress };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

122
src/lib/api.ts Normal file
View File

@@ -0,0 +1,122 @@
import { type ApiResponse, type UploadResponse } from "@/types";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
/**
* Base API client with error handling
*/
async function apiClient<T>(
endpoint: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${API_BASE_URL}/api${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
error: data.message || "An error occurred",
};
}
return {
success: true,
data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Network error",
};
}
}
/**
* Upload a file
*/
export async function uploadFile(file: File, _onProgress?: (progress: number) => Promise<void>): Promise<ApiResponse<UploadResponse>> {
const formData = new FormData();
formData.append("file", file);
try {
const response = await fetch(`${API_BASE_URL}/api/upload`, {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error("Upload failed");
}
const data = await response.json();
return {
success: true,
data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Upload failed",
};
}
}
/**
* Process video to frames
*/
export async function processVideoFrames(fileId: string, config: any) {
return apiClient("/process/video-frames", {
method: "POST",
body: JSON.stringify({ fileId, config }),
});
}
/**
* Process image compression
*/
export async function processImageCompression(fileId: string, config: any) {
return apiClient("/process/image-compress", {
method: "POST",
body: JSON.stringify({ fileId, config }),
});
}
/**
* Process audio compression
*/
export async function processAudioCompression(fileId: string, config: any) {
return apiClient("/process/audio-compress", {
method: "POST",
body: JSON.stringify({ fileId, config }),
});
}
/**
* Download processed file
*/
export async function downloadFile(fileId: string): Promise<Blob> {
const response = await fetch(`${API_BASE_URL}/api/download/${fileId}`);
if (!response.ok) {
throw new Error("Download failed");
}
return response.blob();
}
/**
* Check user quota
*/
export async function checkQuota() {
return apiClient("/quota", {
method: "GET",
});
}

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

@@ -0,0 +1,138 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge Tailwind CSS classes with proper precedence
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Format file size to human-readable format
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
}
/**
* Format duration to human-readable format
*/
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
} else if (minutes > 0) {
return `${minutes}m ${secs}s`;
} else {
return `${secs}s`;
}
}
/**
* Get file extension from filename
*/
export function getFileExtension(filename: string): string {
return filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2);
}
/**
* Check if file is an image
*/
export function isImageFile(file: File): boolean {
return file.type.startsWith("image/");
}
/**
* Check if file is a video
*/
export function isVideoFile(file: File): boolean {
return file.type.startsWith("video/");
}
/**
* Check if file is an audio
*/
export function isAudioFile(file: File): boolean {
return file.type.startsWith("audio/");
}
/**
* Generate a unique ID
*/
export function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
/**
* Debounce function
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}
/**
* Sleep function for async operations
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Truncate text with ellipsis
*/
export function truncate(text: string, length: number): string {
if (text.length <= length) return text;
return text.slice(0, length) + "...";
}
/**
* Calculate percentage
*/
export function calculatePercentage(value: number, total: number): number {
if (total === 0) return 0;
return Math.round((value / total) * 100);
}
/**
* Format date to locale string
*/
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
}
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}

45
src/store/authStore.ts Normal file
View File

@@ -0,0 +1,45 @@
import { create } from "zustand";
import { type User } from "@/types";
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
// Actions
setUser: (user: User | null) => void;
login: (user: User) => void;
logout: () => void;
setLoading: (isLoading: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
setUser: (user) =>
set({
user,
isAuthenticated: !!user,
}),
login: (user) =>
set({
user,
isAuthenticated: true,
isLoading: false,
}),
logout: () =>
set({
user: null,
isAuthenticated: false,
isLoading: false,
}),
setLoading: (isLoading) =>
set({
isLoading,
}),
}));

62
src/store/uploadStore.ts Normal file
View File

@@ -0,0 +1,62 @@
import { create } from "zustand";
import { type UploadedFile, type ProcessingProgress } from "@/types";
interface UploadState {
files: UploadedFile[];
processingStatus: ProcessingProgress;
isProcessing: boolean;
// Actions
addFile: (file: UploadedFile) => void;
removeFile: (id: string) => void;
clearFiles: () => void;
setProcessingStatus: (status: Partial<ProcessingProgress>) => void;
resetProcessingStatus: () => void;
setIsProcessing: (isProcessing: boolean) => void;
}
export const useUploadStore = create<UploadState>((set) => ({
files: [],
processingStatus: {
status: "idle",
progress: 0,
message: "",
},
isProcessing: false,
addFile: (file) =>
set((state) => ({
files: [...state.files, file],
})),
removeFile: (id) =>
set((state) => ({
files: state.files.filter((f) => f.id !== id),
})),
clearFiles: () =>
set({
files: [],
}),
setProcessingStatus: (status) =>
set((state) => ({
processingStatus: { ...state.processingStatus, ...status },
})),
resetProcessingStatus: () =>
set({
processingStatus: {
status: "idle",
progress: 0,
message: "",
},
}),
setIsProcessing: (isProcessing) =>
set({
isProcessing,
}),
}));

142
src/types/index.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* File types
*/
export interface UploadedFile {
id: string;
file: File;
name: string;
size: number;
type: string;
url?: string;
uploadedAt: Date;
}
export interface ProcessedFile {
id: string;
originalFile: UploadedFile;
processedUrl: string;
metadata: ProcessMetadata;
createdAt: Date;
}
/**
* Processing types
*/
export type ProcessStatus = "idle" | "uploading" | "processing" | "completed" | "failed";
export interface ProcessMetadata {
format?: string;
quality?: number;
bitrate?: number;
fps?: number;
resolution?: string;
duration?: number;
frames?: number;
compressionRatio?: number;
}
export interface ProcessingResult {
success: boolean;
fileUrl?: string;
filename?: string;
metadata?: ProcessMetadata;
error?: string;
}
export interface ProcessingProgress {
status: ProcessStatus;
progress: number; // 0-100
message: string;
error?: string;
}
/**
* Tool types
*/
export type ToolType = "video-frames" | "image-compress" | "audio-compress" | "ai-image" | "ai-audio";
export interface ToolConfig {
type: ToolType;
name: string;
description: string;
icon: string;
supportedFormats: string[];
maxSize: number;
features: string[];
isPremium?: boolean;
}
/**
* User types (Phase 6)
*/
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
plan: "free" | "pro" | "enterprise";
quota: UserQuota;
createdAt: Date;
}
export interface UserQuota {
used: number;
limit: number;
resetDate: Date;
}
/**
* API types
*/
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface UploadResponse {
fileId: string;
fileUrl: string;
metadata: {
size: number;
type: string;
name: string;
};
}
/**
* Tool configuration types
*/
export interface VideoFramesConfig {
fps: number;
format: "png" | "jpeg" | "webp";
quality: number;
startTime?: number;
endTime?: number;
width?: number;
height?: number;
}
export interface ImageCompressConfig {
quality: number;
format: "original" | "jpeg" | "png" | "webp";
resize?: {
width?: number;
height?: number;
fit: "contain" | "cover" | "fill";
};
}
export interface AudioCompressConfig {
bitrate: number;
format: "mp3" | "aac" | "ogg" | "flac";
sampleRate: number;
channels: number;
}

98
tailwind.config.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"fade-in": {
"0%": { opacity: "0", transform: "translateY(10px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
"slide-in": {
"0%": { transform: "translateX(-100%)" },
"100%": { transform: "translateX(0)" },
},
"pulse-slow": {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
"float": {
"0%, 100%": { transform: "translateY(0px)" },
"50%": { transform: "translateY(-20px)" },
},
},
animation: {
"fade-in": "fade-in 0.5s ease-out",
"slide-in": "slide-in 0.3s ease-out",
"pulse-slow": "pulse-slow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
"float": "float 3s ease-in-out infinite",
},
fontFamily: {
sans: ["var(--font-geist-sans)", "system-ui", "sans-serif"],
mono: ["var(--font-geist-mono)", "monospace"],
},
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
"gradient-primary": "linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%)",
"gradient-dark": "linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%)",
},
},
},
plugins: [require("@tailwindcss/typography")],
};
export default config;

31
tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}