31d0464a60
- Scaffold: package.json, tsconfig.json, vite.config.ts, index.html - src/lib/imageProcessor.ts: full pipeline (normalize, offset, seam repair, export, validation) - src/components/UploadPanel.tsx: drag-and-drop, file picker, clipboard paste - src/components/SettingsPanel.tsx: all controls per spec - src/components/PreviewPanel.tsx: Original / Tileable / Repeated tabs - src/components/ErrorBanner.tsx: dismissible error/warning banners - src/App.tsx: root component wiring everything together - src/index.css: dark premium glassmorphism theme w/ Inter font
140 lines
4.5 KiB
TypeScript
140 lines
4.5 KiB
TypeScript
import { useState, useCallback, useRef } from 'react';
|
|
import UploadPanel from './components/UploadPanel';
|
|
import SettingsPanel from './components/SettingsPanel';
|
|
import PreviewPanel from './components/PreviewPanel';
|
|
import ErrorBanner, { Banner } from './components/ErrorBanner';
|
|
import {
|
|
defaultSettings,
|
|
TilesetSettings,
|
|
generateTileableTexture,
|
|
exportAsWebP,
|
|
} from './lib/imageProcessor';
|
|
|
|
let bannerCounter = 0;
|
|
|
|
export default function App() {
|
|
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
|
const [settings, setSettings] = useState<TilesetSettings>(defaultSettings);
|
|
const [resultCanvas, setResultCanvas] = useState<HTMLCanvasElement | null>(null);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [processingStep, setProcessingStep] = useState('');
|
|
const [banners, setBanners] = useState<Banner[]>([]);
|
|
const processingRef = useRef(false);
|
|
|
|
const addBanner = (type: Banner['type'], message: string) => {
|
|
const id = String(++bannerCounter);
|
|
setBanners((prev) => [...prev, { id, type, message }]);
|
|
};
|
|
|
|
const dismissBanner = (id: string) =>
|
|
setBanners((prev) => prev.filter((b) => b.id !== id));
|
|
|
|
const handleFileSelect = useCallback((file: File) => {
|
|
setSourceFile(file);
|
|
setResultCanvas(null); // Clear old result when new image loaded
|
|
}, []);
|
|
|
|
const handleGenerate = async () => {
|
|
if (!sourceFile || processingRef.current) return;
|
|
processingRef.current = true;
|
|
setIsProcessing(true);
|
|
setResultCanvas(null);
|
|
|
|
try {
|
|
const canvas = await generateTileableTexture(
|
|
sourceFile,
|
|
settings,
|
|
(step) => setProcessingStep(step),
|
|
);
|
|
setResultCanvas(canvas);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
|
|
addBanner('error', `Processing failed: ${msg}`);
|
|
} finally {
|
|
setIsProcessing(false);
|
|
processingRef.current = false;
|
|
setProcessingStep('');
|
|
}
|
|
};
|
|
|
|
const handleExport = async () => {
|
|
if (!resultCanvas) return;
|
|
try {
|
|
await exportAsWebP(
|
|
resultCanvas,
|
|
settings.webpQuality,
|
|
settings.outputWidth,
|
|
settings.outputHeight,
|
|
);
|
|
} catch (err) {
|
|
addBanner('error', 'Export failed. Your browser may not support WebP export.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="app">
|
|
{/* Header */}
|
|
<header className="app-header">
|
|
<div className="app-header-logo">
|
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
|
<rect width="28" height="28" rx="7" fill="url(#grad)" />
|
|
<rect x="2" y="2" width="11" height="11" rx="2" fill="rgba(255,255,255,0.85)" />
|
|
<rect x="15" y="2" width="11" height="11" rx="2" fill="rgba(255,255,255,0.45)" />
|
|
<rect x="2" y="15" width="11" height="11" rx="2" fill="rgba(255,255,255,0.45)" />
|
|
<rect x="15" y="15" width="11" height="11" rx="2" fill="rgba(255,255,255,0.85)" />
|
|
<defs>
|
|
<linearGradient id="grad" x1="0" y1="0" x2="28" y2="28">
|
|
<stop stopColor="#3b82f6" />
|
|
<stop offset="1" stopColor="#8b5cf6" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
<div>
|
|
<h1>Tileset Generator</h1>
|
|
<div className="app-header-subtitle">Upload image → Generate tileable WebP texture</div>
|
|
</div>
|
|
</div>
|
|
<div className="app-badge">V1 · Client-Side</div>
|
|
</header>
|
|
|
|
{/* Body */}
|
|
<div className="app-body">
|
|
{/* Sidebar */}
|
|
<aside className="sidebar">
|
|
{banners.length > 0 && (
|
|
<ErrorBanner banners={banners} onDismiss={dismissBanner} />
|
|
)}
|
|
|
|
<UploadPanel
|
|
onFileSelect={handleFileSelect}
|
|
onError={(msg) => addBanner('error', msg)}
|
|
onWarning={(msg) => addBanner('warning', msg)}
|
|
currentFile={sourceFile}
|
|
/>
|
|
|
|
<SettingsPanel
|
|
settings={settings}
|
|
onChange={setSettings}
|
|
onGenerate={handleGenerate}
|
|
onExport={handleExport}
|
|
canGenerate={!!sourceFile}
|
|
canExport={!!resultCanvas && !isProcessing}
|
|
isProcessing={isProcessing}
|
|
/>
|
|
</aside>
|
|
|
|
{/* Preview */}
|
|
<main className="preview-area">
|
|
<PreviewPanel
|
|
sourceFile={sourceFile}
|
|
resultCanvas={resultCanvas}
|
|
settings={settings}
|
|
isProcessing={isProcessing}
|
|
processingStep={processingStep}
|
|
/>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|