A web app to help you design things, local, offline, on device. In your browser.
TypeScript
53.1%
JSON
45.6%
CSS
0.9%
Markdown
0.3%
JavaScript
0.1%
'use client';
import React, { useState, useRef } from 'react';
import { Download, Check, Copy, Smartphone, Tablet, Monitor, Share2 } from 'lucide-react';
import { toPng, toJpeg } from 'html-to-image';
import { useEditorStore } from '@/lib/store/editor-store';
import { EXPORT_PRESETS, type ExportPreset, type ExportConfig } from '@/types';
const presetGroups = [
{
name: 'App Store',
icon: Smartphone,
presets: ['iphone-6.9', 'iphone-6.5', 'iphone-6.1', 'ipad-13', 'ipad-11', 'mac'] as ExportPreset[],
},
{
name: 'Social Media',
icon: Share2,
presets: [
'instagram-post',
'instagram-story',
'twitter-post',
'linkedin-post',
'facebook-post',
] as ExportPreset[],
},
{
name: 'Marketing',
icon: Monitor,
presets: ['product-hunt', 'youtube-thumbnail'] as ExportPreset[],
},
];
export function ExportPanel() {
const [selectedPresets, setSelectedPresets] = useState<ExportPreset[]>(['iphone-6.5']);
const [format, setFormat] = useState<'png' | 'jpg'>('png');
const [quality, setQuality] = useState(100);
const [scale, setScale] = useState(1);
const [isExporting, setIsExporting] = useState(false);
const [exportComplete, setExportComplete] = useState(false);
const { canvas, layers } = useEditorStore();
const togglePreset = (preset: ExportPreset) => {
setSelectedPresets((prev) =>
prev.includes(preset) ? prev.filter((p) => p !== preset) : [...prev, preset]
);
};
const selectAll = (presets: ExportPreset[]) => {
const allSelected = presets.every((p) => selectedPresets.includes(p));
if (allSelected) {
setSelectedPresets((prev) => prev.filter((p) => !presets.includes(p)));
} else {
setSelectedPresets((prev) => [...new Set([...prev, ...presets])]);
}
};
const handleExport = async () => {
setIsExporting(true);
setExportComplete(false);
// Get the canvas element (we'll need to implement this with Konva)
const stage = document.querySelector('.konva-container canvas') as HTMLCanvasElement;
if (!stage) {
console.error('Canvas not found');
setIsExporting(false);
return;
}
try {
for (const preset of selectedPresets) {
const config = EXPORT_PRESETS[preset];
// Create a temporary canvas for export
const tempCanvas = document.createElement('canvas');
tempCanvas.width = config.width * scale;
tempCanvas.height = config.height * scale;
const ctx = tempCanvas.getContext('2d');
if (!ctx) continue;
// Draw the stage content scaled
ctx.drawImage(stage, 0, 0, tempCanvas.width, tempCanvas.height);
// Convert to image
const dataUrl =
format === 'png'
? tempCanvas.toDataURL('image/png')
: tempCanvas.toDataURL('image/jpeg', quality / 100);
// Download
const link = document.createElement('a');
link.download = `${config.name.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${config.width}x${config.height}.${format}`;
link.href = dataUrl;
link.click();
// Small delay between downloads
await new Promise((r) => setTimeout(r, 500));
}
setExportComplete(true);
setTimeout(() => setExportComplete(false), 3000);
} catch (error) {
console.error('Export failed:', error);
} finally {
setIsExporting(false);
}
};
return (
<div className="p-4">
<h2 className="text-lg font-semibold text-white mb-4">Export</h2>
{/* Preset selection */}
<div className="space-y-4 mb-6">
{presetGroups.map((group) => {
const Icon = group.icon;
const allSelected = group.presets.every((p) => selectedPresets.includes(p));
const someSelected = group.presets.some((p) => selectedPresets.includes(p));
return (
<div key={group.name}>
<button
onClick={() => selectAll(group.presets)}
className="flex items-center gap-2 w-full text-left mb-2 group"
>
<div
className={`
w-5 h-5 rounded border-2 flex items-center justify-center transition-colors
${
allSelected
? 'bg-indigo-500 border-indigo-500'
: someSelected
? 'border-indigo-500 bg-indigo-500/30'
: 'border-neutral-600 group-hover:border-neutral-500'
}
`}
>
{(allSelected || someSelected) && <Check className="w-3 h-3 text-white" />}
</div>
<Icon className="w-4 h-4 text-neutral-400" />
<span className="text-sm text-neutral-300 group-hover:text-white transition-colors">
{group.name}
</span>
</button>
<div className="ml-7 space-y-1">
{group.presets.map((preset) => {
const config = EXPORT_PRESETS[preset];
const isSelected = selectedPresets.includes(preset);
return (
<button
key={preset}
onClick={() => togglePreset(preset)}
className={`
w-full flex items-center justify-between p-2 rounded-lg text-left
transition-colors
${
isSelected
? 'bg-indigo-500/20 border border-indigo-500/50'
: 'hover:bg-neutral-800 border border-transparent'
}
`}
>
<div className="flex items-center gap-2">
<div
className={`
w-4 h-4 rounded border-2 flex items-center justify-center
${
isSelected
? 'bg-indigo-500 border-indigo-500'
: 'border-neutral-600'
}
`}
>
{isSelected && <Check className="w-2.5 h-2.5 text-white" />}
</div>
<span
className={`text-sm ${isSelected ? 'text-white' : 'text-neutral-400'}`}
>
{config.name}
</span>
</div>
<span className="text-xs text-neutral-500">
{config.width}x{config.height}
</span>
</button>
);
})}
</div>
</div>
);
})}
</div>
{/* Export options */}
<div className="space-y-4 mb-6">
{/* Format */}
<div>
<label className="text-sm text-neutral-400 block mb-2">Format</label>
<div className="flex gap-2">
{(['png', 'jpg'] as const).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`
flex-1 py-2 rounded-lg text-sm font-medium uppercase transition-colors
${
format === f
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/50'
: 'bg-neutral-800 text-neutral-400 border border-neutral-700 hover:text-white'
}
`}
>
{f}
</button>
))}
</div>
</div>
{/* Quality (for JPG) */}
{format === 'jpg' && (
<div>
<div className="flex justify-between text-sm mb-2">
<span className="text-neutral-400">Quality</span>
<span className="text-white">{quality}%</span>
</div>
<input
type="range"
min={10}
max={100}
value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
className="w-full"
/>
</div>
)}
{/* Scale */}
<div>
<label className="text-sm text-neutral-400 block mb-2">Scale</label>
<div className="flex gap-2">
{[1, 2, 3].map((s) => (
<button
key={s}
onClick={() => setScale(s)}
className={`
flex-1 py-2 rounded-lg text-sm transition-colors
${
scale === s
? 'bg-indigo-500/20 text-indigo-400 border border-indigo-500/50'
: 'bg-neutral-800 text-neutral-400 border border-neutral-700 hover:text-white'
}
`}
>
{s}x
</button>
))}
</div>
</div>
</div>
{/* Export button */}
<button
onClick={handleExport}
disabled={isExporting || selectedPresets.length === 0}
className={`
w-full py-3 rounded-xl font-medium
flex items-center justify-center gap-2
transition-all
${
exportComplete
? 'bg-green-500 text-white'
: isExporting
? 'bg-indigo-500/50 text-white/50 cursor-wait'
: selectedPresets.length === 0
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
: 'bg-indigo-500 hover:bg-indigo-600 text-white'
}
`}
>
{exportComplete ? (
<>
<Check className="w-5 h-5" />
Exported!
</>
) : isExporting ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-5 h-5" />
Export {selectedPresets.length} {selectedPresets.length === 1 ? 'Size' : 'Sizes'}
</>
)}
</button>
{/* Quick copy dimensions */}
<div className="mt-4 p-3 rounded-lg bg-neutral-800/50 border border-neutral-700/50">
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-400">Current canvas</span>
<button
onClick={() => navigator.clipboard.writeText(`${canvas.width}x${canvas.height}`)}
className="flex items-center gap-1 text-neutral-300 hover:text-white transition-colors"
>
<Copy className="w-3 h-3" />
{canvas.width}x{canvas.height}
</button>
</div>
</div>
</div>
);
}
About
A web app to help you design things, local, offline, on device. In your browser.
0 stars
0 forks