mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add editor layout components, welcome screen, and editor page
Create the four-zone editor layout shell (toolbar, options bar, status bar, right panel), welcome screen with drag-and-drop and new document dialog, and the main editor page with clipboard paste and URL loading.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// apps/web/src/components/editor/common/new-document-dialog.tsx
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
const PRESETS = [
|
||||
{ label: "Custom", width: 1920, height: 1080 },
|
||||
{ label: "1920x1080 (HD)", width: 1920, height: 1080 },
|
||||
{ label: "3840x2160 (4K)", width: 3840, height: 2160 },
|
||||
{ label: "1080x1080 (Instagram)", width: 1080, height: 1080 },
|
||||
{ label: "1200x628 (Facebook)", width: 1200, height: 628 },
|
||||
{ label: "800x600", width: 800, height: 600 },
|
||||
{ label: "1280x720", width: 1280, height: 720 },
|
||||
];
|
||||
|
||||
const BACKGROUNDS = ["White", "Black", "Transparent"] as const;
|
||||
|
||||
interface NewDocumentDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NewDocumentDialog({ open, onClose }: NewDocumentDialogProps) {
|
||||
const [width, setWidth] = useState(1920);
|
||||
const [height, setHeight] = useState(1080);
|
||||
const [preset, setPreset] = useState("1920x1080 (HD)");
|
||||
const [background, setBackground] = useState<(typeof BACKGROUNDS)[number]>("White");
|
||||
const loadImage = useEditorStore((s) => s.loadImage);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handlePresetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selected = PRESETS.find((p) => p.label === e.target.value);
|
||||
if (selected) {
|
||||
setPreset(selected.label);
|
||||
if (selected.label !== "Custom") {
|
||||
setWidth(selected.width);
|
||||
setHeight(selected.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
if (background === "White") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
} else if (background === "Black") {
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
}
|
||||
const url = canvas.toDataURL("image/png");
|
||||
loadImage(url, width, height);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-card border border-border rounded-lg shadow-lg p-6 w-96">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">New Document</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="new-doc-preset" className="text-xs text-muted-foreground">
|
||||
Preset
|
||||
</label>
|
||||
<select
|
||||
id="new-doc-preset"
|
||||
value={preset}
|
||||
onChange={handlePresetChange}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground"
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.label} value={p.label}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="new-doc-width" className="text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="new-doc-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => {
|
||||
setWidth(Number(e.target.value));
|
||||
setPreset("Custom");
|
||||
}}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground"
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="new-doc-height" className="text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="new-doc-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => {
|
||||
setHeight(Number(e.target.value));
|
||||
setPreset("Custom");
|
||||
}}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground"
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Background</span>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{BACKGROUNDS.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
type="button"
|
||||
onClick={() => setBackground(bg)}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 text-xs rounded border transition-colors",
|
||||
background === bg
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted text-muted-foreground border-border hover:bg-muted/80",
|
||||
)}
|
||||
>
|
||||
{bg}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="px-4 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// apps/web/src/components/editor/common/welcome-screen.tsx
|
||||
|
||||
import { FilePlus, ImagePlus } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { NewDocumentDialog } from "./new-document-dialog";
|
||||
|
||||
const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg";
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const [showNewDoc, setShowNewDoc] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const loadImage = useEditorStore((s) => s.loadImage);
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
loadImage(url, img.naturalWidth, img.naturalHeight);
|
||||
};
|
||||
img.src = url;
|
||||
},
|
||||
[loadImage],
|
||||
);
|
||||
|
||||
const handleOpenFile = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ACCEPTED_TYPES;
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => setIsDragOver(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
aria-label="Image drop zone"
|
||||
className="absolute inset-0 flex items-center justify-center z-10"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-center gap-6 p-12 bg-card border-2 border-dashed rounded-xl max-w-md transition-colors ${
|
||||
isDragOver ? "border-primary bg-primary/5" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-1">Image Editor</h2>
|
||||
<p className="text-sm text-muted-foreground">Drop an image here to get started</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenFile}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ImagePlus size={20} />
|
||||
<span className="text-sm font-medium">Open Image</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewDoc(true)}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 bg-muted text-foreground rounded-lg hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<FilePlus size={20} />
|
||||
<span className="text-sm font-medium">New Document</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">Or paste from clipboard (Ctrl+V)</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NewDocumentDialog open={showNewDoc} onClose={() => setShowNewDoc(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user