feat: 实现 Mini Game AI 工具平台 #1
35
.env.example
Normal file
35
.env.example
Normal 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
9
.prettierrc
Normal 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
30
eslint.config.mjs
Normal 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
6
next-env.d.ts
vendored
Normal 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
48
next.config.ts
Normal 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
7440
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
package.json
Normal file
48
package.json
Normal 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
9
postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
16
src/app/(dashboard)/layout.tsx
Normal file
16
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
src/app/(dashboard)/tools/audio-compress/page.tsx
Normal file
261
src/app/(dashboard)/tools/audio-compress/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/app/(dashboard)/tools/image-compress/page.tsx
Normal file
230
src/app/(dashboard)/tools/image-compress/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
src/app/(dashboard)/tools/video-frames/page.tsx
Normal file
249
src/app/(dashboard)/tools/video-frames/page.tsx
Normal 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 "Process Video" to start extraction</li>
|
||||||
|
<li>4. Download the ZIP file with all frames</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/app/api/process/audio-compress/route.ts
Normal file
60
src/app/api/process/audio-compress/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/api/process/image-compress/route.ts
Normal file
58
src/app/api/process/image-compress/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/process/video-frames/route.ts
Normal file
59
src/app/api/process/video-frames/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/app/api/upload/route.ts
Normal file
51
src/app/api/upload/route.ts
Normal 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
180
src/app/globals.css
Normal 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
32
src/app/layout.tsx
Normal 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
391
src/app/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/layout/Footer.tsx
Normal file
146
src/components/layout/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/layout/Header.tsx
Normal file
111
src/components/layout/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/layout/Sidebar.tsx
Normal file
105
src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
src/components/tools/ConfigPanel.tsx
Normal file
135
src/components/tools/ConfigPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/components/tools/FileUploader.tsx
Normal file
152
src/components/tools/FileUploader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/components/tools/ProgressBar.tsx
Normal file
96
src/components/tools/ProgressBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/tools/ResultPreview.tsx
Normal file
141
src/components/tools/ResultPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/ui/badge.tsx
Normal file
32
src/components/ui/badge.tsx
Normal 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 };
|
||||||
48
src/components/ui/button.tsx
Normal file
48
src/components/ui/button.tsx
Normal 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 };
|
||||||
55
src/components/ui/card.tsx
Normal file
55
src/components/ui/card.tsx
Normal 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 };
|
||||||
23
src/components/ui/input.tsx
Normal file
23
src/components/ui/input.tsx
Normal 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 };
|
||||||
23
src/components/ui/label.tsx
Normal file
23
src/components/ui/label.tsx
Normal 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 };
|
||||||
24
src/components/ui/progress.tsx
Normal file
24
src/components/ui/progress.tsx
Normal 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 };
|
||||||
22
src/components/ui/slider.tsx
Normal file
22
src/components/ui/slider.tsx
Normal 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
122
src/lib/api.ts
Normal 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
138
src/lib/utils.ts
Normal 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
45
src/store/authStore.ts
Normal 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
62
src/store/uploadStore.ts
Normal 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
142
src/types/index.ts
Normal 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
98
tailwind.config.ts
Normal 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
31
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user