mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
870 lines
28 KiB
Markdown
870 lines
28 KiB
Markdown
# Resize & Rotate/Flip UX Redesign Implementation Plan
|
||||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** Replace the before/after slider with intuitive side-by-side comparison for resize and live CSS preview for rotate/flip.
|
|||
|
|
|
|||
|
|
**Architecture:** Frontend-only changes. Resize gets a tab-based settings panel (Presets/Custom Size/Scale) and a new SideBySideComparison result view. Rotate/flip gets live CSS transform preview on the ImageViewer with an "Apply" button. The tool-page.tsx conditionally renders the appropriate result view per tool. No backend changes.
|
|||
|
|
|
|||
|
|
**Tech Stack:** React, TypeScript, Tailwind CSS, Zustand, lucide-react
|
|||
|
|
|
|||
|
|
**Spec:** `docs/superpowers/specs/2026-03-23-resize-rotate-redesign.md`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 1: Create SideBySideComparison Component
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `apps/web/src/components/common/side-by-side-comparison.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create the component file**
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { useState } from "react";
|
|||
|
|
|
|||
|
|
interface SideBySideComparisonProps {
|
|||
|
|
beforeSrc: string;
|
|||
|
|
afterSrc: string;
|
|||
|
|
beforeSize?: number;
|
|||
|
|
afterSize?: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatSize(bytes: number): string {
|
|||
|
|
if (bytes < 1024) return `${bytes} B`;
|
|||
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|||
|
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function SideBySideComparison({
|
|||
|
|
beforeSrc,
|
|||
|
|
afterSrc,
|
|||
|
|
beforeSize,
|
|||
|
|
afterSize,
|
|||
|
|
}: SideBySideComparisonProps) {
|
|||
|
|
const [beforeDims, setBeforeDims] = useState<{ w: number; h: number } | null>(null);
|
|||
|
|
const [afterDims, setAfterDims] = useState<{ w: number; h: number } | null>(null);
|
|||
|
|
|
|||
|
|
const savingsPercent =
|
|||
|
|
beforeSize && afterSize && beforeSize > 0
|
|||
|
|
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
const checkerboard = {
|
|||
|
|
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
|
|||
|
|
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
|||
|
|
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
|||
|
|
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
|
|||
|
|
backgroundSize: "16px 16px",
|
|||
|
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
|||
|
|
{/* Side-by-side images */}
|
|||
|
|
<div className="flex flex-col sm:flex-row gap-4 w-full">
|
|||
|
|
{/* Original */}
|
|||
|
|
<div className="flex-1 flex flex-col items-center gap-2">
|
|||
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|||
|
|
Original
|
|||
|
|
</span>
|
|||
|
|
<div
|
|||
|
|
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
|||
|
|
style={checkerboard}
|
|||
|
|
>
|
|||
|
|
<img
|
|||
|
|
src={beforeSrc}
|
|||
|
|
alt="Original"
|
|||
|
|
className="max-w-full max-h-full object-contain"
|
|||
|
|
draggable={false}
|
|||
|
|
onLoad={(e) => {
|
|||
|
|
const img = e.currentTarget;
|
|||
|
|
setBeforeDims({ w: img.naturalWidth, h: img.naturalHeight });
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
|||
|
|
{beforeDims && (
|
|||
|
|
<p>
|
|||
|
|
{beforeDims.w} × {beforeDims.h}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
{beforeSize != null && <p>{formatSize(beforeSize)}</p>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Resized */}
|
|||
|
|
<div className="flex-1 flex flex-col items-center gap-2">
|
|||
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|||
|
|
Resized
|
|||
|
|
</span>
|
|||
|
|
<div
|
|||
|
|
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
|||
|
|
style={checkerboard}
|
|||
|
|
>
|
|||
|
|
<img
|
|||
|
|
src={afterSrc}
|
|||
|
|
alt="Resized"
|
|||
|
|
className="max-w-full max-h-full object-contain"
|
|||
|
|
draggable={false}
|
|||
|
|
onLoad={(e) => {
|
|||
|
|
const img = e.currentTarget;
|
|||
|
|
setAfterDims({ w: img.naturalWidth, h: img.naturalHeight });
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-xs text-muted-foreground text-center space-y-0.5">
|
|||
|
|
{afterDims && (
|
|||
|
|
<p>
|
|||
|
|
{afterDims.w} × {afterDims.h}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
{afterSize != null && <p>{formatSize(afterSize)}</p>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Size savings */}
|
|||
|
|
{savingsPercent !== null && (
|
|||
|
|
<p
|
|||
|
|
className={`text-sm font-medium ${Number(savingsPercent) > 0 ? "text-green-600 dark:text-green-400" : "text-red-500"}`}
|
|||
|
|
>
|
|||
|
|
{Number(savingsPercent) > 0
|
|||
|
|
? `${savingsPercent}% smaller`
|
|||
|
|
: `${Math.abs(Number(savingsPercent))}% larger`}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify it compiles**
|
|||
|
|
|
|||
|
|
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
|||
|
|
Expected: Build succeeds
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/web/src/components/common/side-by-side-comparison.tsx
|
|||
|
|
git commit -m "feat: add SideBySideComparison component for resize results"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 2: Rewrite ResizeSettings with Tab-Based UI
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/web/src/components/tools/resize-settings.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Rewrite resize-settings.tsx with three tabs**
|
|||
|
|
|
|||
|
|
Replace the entire file content with:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { useState } from "react";
|
|||
|
|
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
|||
|
|
import { useFileStore } from "@/stores/file-store";
|
|||
|
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
|||
|
|
import { Download, Link, Unlink } from "lucide-react";
|
|||
|
|
import { ProgressCard } from "@/components/common/progress-card";
|
|||
|
|
|
|||
|
|
type ResizeTab = "presets" | "custom" | "scale";
|
|||
|
|
type FitMode = "cover" | "contain" | "fill";
|
|||
|
|
|
|||
|
|
const FIT_LABELS: Record<FitMode, string> = {
|
|||
|
|
cover: "Crop to fit",
|
|||
|
|
contain: "Fit inside",
|
|||
|
|
fill: "Stretch",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Group presets by platform
|
|||
|
|
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
|
|||
|
|
|
|||
|
|
export function ResizeSettings() {
|
|||
|
|
const { files } = useFileStore();
|
|||
|
|
const { processFiles, processing, error, downloadUrl, progress } =
|
|||
|
|
useToolProcessor("resize");
|
|||
|
|
|
|||
|
|
const [tab, setTab] = useState<ResizeTab>("presets");
|
|||
|
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
|||
|
|
const [width, setWidth] = useState<string>("");
|
|||
|
|
const [height, setHeight] = useState<string>("");
|
|||
|
|
const [percentage, setPercentage] = useState<string>("50");
|
|||
|
|
const [fit, setFit] = useState<FitMode>("cover");
|
|||
|
|
const [lockAspect, setLockAspect] = useState(true);
|
|||
|
|
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
|||
|
|
|
|||
|
|
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
|||
|
|
const key = `${preset.platform}-${preset.name}`;
|
|||
|
|
if (selectedPreset === key) {
|
|||
|
|
setSelectedPreset(null);
|
|||
|
|
setWidth("");
|
|||
|
|
setHeight("");
|
|||
|
|
} else {
|
|||
|
|
setSelectedPreset(key);
|
|||
|
|
setWidth(String(preset.width));
|
|||
|
|
setHeight(String(preset.height));
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleProcess = () => {
|
|||
|
|
const settings: Record<string, unknown> = {};
|
|||
|
|
|
|||
|
|
if (tab === "scale") {
|
|||
|
|
settings.percentage = Number(percentage);
|
|||
|
|
} else {
|
|||
|
|
if (width) settings.width = Number(width);
|
|||
|
|
if (height) settings.height = Number(height);
|
|||
|
|
settings.fit = tab === "presets" ? "cover" : fit;
|
|||
|
|
settings.withoutEnlargement = withoutEnlargement;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
processFiles(files, settings);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const hasFile = files.length > 0;
|
|||
|
|
const canProcess =
|
|||
|
|
hasFile &&
|
|||
|
|
!processing &&
|
|||
|
|
(tab === "scale"
|
|||
|
|
? Number(percentage) > 0
|
|||
|
|
: Boolean(width) || Boolean(height));
|
|||
|
|
|
|||
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (canProcess) handleProcess();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const tabClass = (t: ResizeTab) =>
|
|||
|
|
`flex-1 text-xs py-1.5 rounded ${tab === t ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|||
|
|
{/* Tab selector */}
|
|||
|
|
<div>
|
|||
|
|
<div className="flex gap-1">
|
|||
|
|
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
|||
|
|
Presets
|
|||
|
|
</button>
|
|||
|
|
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
|||
|
|
Custom Size
|
|||
|
|
</button>
|
|||
|
|
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
|||
|
|
Scale
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Presets tab */}
|
|||
|
|
{tab === "presets" && (
|
|||
|
|
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
|
|||
|
|
{platforms.map((platform) => (
|
|||
|
|
<div key={platform}>
|
|||
|
|
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
|
|||
|
|
const key = `${preset.platform}-${preset.name}`;
|
|||
|
|
const isSelected = selectedPreset === key;
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={key}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => handlePreset(preset)}
|
|||
|
|
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
|
|||
|
|
isSelected
|
|||
|
|
? "border-primary bg-primary/10 text-foreground"
|
|||
|
|
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<span>{preset.name}</span>
|
|||
|
|
<span className="text-xs tabular-nums">
|
|||
|
|
{preset.width} × {preset.height}
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
|
|||
|
|
{/* Don't enlarge */}
|
|||
|
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={withoutEnlargement}
|
|||
|
|
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
|||
|
|
className="rounded"
|
|||
|
|
/>
|
|||
|
|
Don't enlarge
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Custom Size tab */}
|
|||
|
|
{tab === "custom" && (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="flex items-end gap-2">
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<label className="text-xs text-muted-foreground">Width (px)</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={width}
|
|||
|
|
onChange={(e) => setWidth(e.target.value)}
|
|||
|
|
placeholder="Auto"
|
|||
|
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setLockAspect(!lockAspect)}
|
|||
|
|
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
|
|||
|
|
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
|||
|
|
>
|
|||
|
|
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
|
|||
|
|
</button>
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<label className="text-xs text-muted-foreground">Height (px)</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={height}
|
|||
|
|
onChange={(e) => setHeight(e.target.value)}
|
|||
|
|
placeholder="Auto"
|
|||
|
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Fit mode */}
|
|||
|
|
<div>
|
|||
|
|
<label className="text-xs text-muted-foreground">Fit Mode</label>
|
|||
|
|
<div className="flex gap-1 mt-1">
|
|||
|
|
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
|
|||
|
|
<button
|
|||
|
|
key={f}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setFit(f)}
|
|||
|
|
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
|||
|
|
>
|
|||
|
|
{FIT_LABELS[f]}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Don't enlarge */}
|
|||
|
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={withoutEnlargement}
|
|||
|
|
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
|||
|
|
className="rounded"
|
|||
|
|
/>
|
|||
|
|
Don't enlarge
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Scale tab */}
|
|||
|
|
{tab === "scale" && (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="text-xs text-muted-foreground">Scale (%)</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={percentage}
|
|||
|
|
onChange={(e) => setPercentage(e.target.value)}
|
|||
|
|
min={1}
|
|||
|
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-1">
|
|||
|
|
{[25, 50, 75].map((pct) => (
|
|||
|
|
<button
|
|||
|
|
key={pct}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setPercentage(String(pct))}
|
|||
|
|
className={`flex-1 text-xs py-1.5 rounded ${
|
|||
|
|
percentage === String(pct)
|
|||
|
|
? "bg-primary text-primary-foreground"
|
|||
|
|
: "bg-muted text-muted-foreground"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{pct}%
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Error */}
|
|||
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|||
|
|
|
|||
|
|
{/* Process button */}
|
|||
|
|
{processing ? (
|
|||
|
|
<ProgressCard
|
|||
|
|
active={processing}
|
|||
|
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
|||
|
|
label="Resizing"
|
|||
|
|
stage={progress.stage}
|
|||
|
|
percent={progress.percent}
|
|||
|
|
elapsed={progress.elapsed}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<button
|
|||
|
|
type="submit"
|
|||
|
|
disabled={!canProcess}
|
|||
|
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
Resize
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Download */}
|
|||
|
|
{downloadUrl && (
|
|||
|
|
<a
|
|||
|
|
href={downloadUrl}
|
|||
|
|
download
|
|||
|
|
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
|||
|
|
>
|
|||
|
|
<Download className="h-4 w-4" />
|
|||
|
|
Download
|
|||
|
|
</a>
|
|||
|
|
)}
|
|||
|
|
</form>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify it compiles**
|
|||
|
|
|
|||
|
|
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
|||
|
|
Expected: Build succeeds
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/web/src/components/tools/resize-settings.tsx
|
|||
|
|
git commit -m "feat: rewrite resize settings with tab-based UI (presets, custom, scale)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 3: Add CSS Transform Props to ImageViewer
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/web/src/components/common/image-viewer.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add optional CSS transform props to the ImageViewer interface and apply them**
|
|||
|
|
|
|||
|
|
Add `cssRotate`, `cssFlipH`, `cssFlipV` optional props to the `ImageViewerProps` interface. In the `imageStyle` computation, compose CSS transforms when these props are provided.
|
|||
|
|
|
|||
|
|
Changes to make:
|
|||
|
|
|
|||
|
|
1. Update the interface (line 5-8):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
interface ImageViewerProps {
|
|||
|
|
src: string;
|
|||
|
|
filename: string;
|
|||
|
|
fileSize: number;
|
|||
|
|
cssRotate?: number;
|
|||
|
|
cssFlipH?: boolean;
|
|||
|
|
cssFlipV?: boolean;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
2. Update the component destructuring (line 14):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
export function ImageViewer({ src, filename, fileSize, cssRotate, cssFlipH, cssFlipV }: ImageViewerProps) {
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
3. Update the `imageStyle` computation (lines 65-68) to compose CSS transforms:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
const previewTransform = [
|
|||
|
|
cssRotate ? `rotate(${cssRotate}deg)` : "",
|
|||
|
|
cssFlipH ? "scaleX(-1)" : "",
|
|||
|
|
cssFlipV ? "scaleY(-1)" : "",
|
|||
|
|
]
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.join(" ");
|
|||
|
|
|
|||
|
|
const imageStyle =
|
|||
|
|
fitMode === "fit"
|
|||
|
|
? {
|
|||
|
|
maxWidth: "100%",
|
|||
|
|
maxHeight: "100%",
|
|||
|
|
objectFit: "contain" as const,
|
|||
|
|
...(previewTransform && { transform: previewTransform }),
|
|||
|
|
}
|
|||
|
|
: {
|
|||
|
|
transform: `scale(${zoom / 100})${previewTransform ? ` ${previewTransform}` : ""}`,
|
|||
|
|
transformOrigin: "center center",
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify it compiles**
|
|||
|
|
|
|||
|
|
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
|||
|
|
Expected: Build succeeds
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/web/src/components/common/image-viewer.tsx
|
|||
|
|
git commit -m "feat: add CSS transform props to ImageViewer for live rotate/flip preview"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 4: Update RotateSettings with Live Preview Callback
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/web/src/components/tools/rotate-settings.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add onPreviewTransform callback prop and change button label**
|
|||
|
|
|
|||
|
|
The component needs to:
|
|||
|
|
1. Accept an optional `onPreviewTransform` callback
|
|||
|
|
2. Call it on every state change (angle, flipH, flipV) via useEffect
|
|||
|
|
3. Change the submit button label from "Rotate" to "Apply"
|
|||
|
|
|
|||
|
|
Replace the entire file:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { useState, useEffect } from "react";
|
|||
|
|
import { useFileStore } from "@/stores/file-store";
|
|||
|
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
|||
|
|
import {
|
|||
|
|
Download,
|
|||
|
|
RotateCcw,
|
|||
|
|
RotateCw,
|
|||
|
|
FlipHorizontal,
|
|||
|
|
FlipVertical,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import { ProgressCard } from "@/components/common/progress-card";
|
|||
|
|
|
|||
|
|
export interface PreviewTransform {
|
|||
|
|
rotate: number;
|
|||
|
|
flipH: boolean;
|
|||
|
|
flipV: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface RotateSettingsProps {
|
|||
|
|
onPreviewTransform?: (transform: PreviewTransform) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||
|
|
const { files } = useFileStore();
|
|||
|
|
const { processFiles, processing, error, downloadUrl, progress } =
|
|||
|
|
useToolProcessor("rotate");
|
|||
|
|
|
|||
|
|
const [angle, setAngle] = useState(0);
|
|||
|
|
const [flipH, setFlipH] = useState(false);
|
|||
|
|
const [flipV, setFlipV] = useState(false);
|
|||
|
|
|
|||
|
|
// Emit preview transform on every change
|
|||
|
|
useEffect(() => {
|
|||
|
|
onPreviewTransform?.({ rotate: angle, flipH, flipV });
|
|||
|
|
}, [angle, flipH, flipV, onPreviewTransform]);
|
|||
|
|
|
|||
|
|
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
|
|||
|
|
const rotateRight = () => setAngle((a) => (a + 90) % 360);
|
|||
|
|
|
|||
|
|
const handleProcess = () => {
|
|||
|
|
processFiles(files, {
|
|||
|
|
angle,
|
|||
|
|
horizontal: flipH,
|
|||
|
|
vertical: flipV,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const hasFile = files.length > 0;
|
|||
|
|
const hasChanges = angle !== 0 || flipH || flipV;
|
|||
|
|
|
|||
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (hasFile && hasChanges && !processing) handleProcess();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|||
|
|
{/* Quick rotate buttons */}
|
|||
|
|
<div>
|
|||
|
|
<label className="text-xs text-muted-foreground">Quick Rotate</label>
|
|||
|
|
<div className="flex gap-2 mt-1">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={rotateLeft}
|
|||
|
|
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
|||
|
|
>
|
|||
|
|
<RotateCcw className="h-4 w-4" />
|
|||
|
|
90 Left
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={rotateRight}
|
|||
|
|
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
|||
|
|
>
|
|||
|
|
<RotateCw className="h-4 w-4" />
|
|||
|
|
90 Right
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Angle slider */}
|
|||
|
|
<div>
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<label className="text-xs text-muted-foreground">Angle</label>
|
|||
|
|
<span className="text-xs font-mono text-foreground">{angle} deg</span>
|
|||
|
|
</div>
|
|||
|
|
<input
|
|||
|
|
type="range"
|
|||
|
|
min={0}
|
|||
|
|
max={360}
|
|||
|
|
value={angle}
|
|||
|
|
onChange={(e) => setAngle(Number(e.target.value))}
|
|||
|
|
className="w-full mt-1"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Flip buttons */}
|
|||
|
|
<div>
|
|||
|
|
<label className="text-xs text-muted-foreground">Flip</label>
|
|||
|
|
<div className="flex gap-2 mt-1">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setFlipH(!flipH)}
|
|||
|
|
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
|
|||
|
|
flipH
|
|||
|
|
? "bg-primary text-primary-foreground"
|
|||
|
|
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<FlipHorizontal className="h-4 w-4" />
|
|||
|
|
Horizontal
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setFlipV(!flipV)}
|
|||
|
|
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
|
|||
|
|
flipV
|
|||
|
|
? "bg-primary text-primary-foreground"
|
|||
|
|
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<FlipVertical className="h-4 w-4" />
|
|||
|
|
Vertical
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Error */}
|
|||
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|||
|
|
|
|||
|
|
{/* Process */}
|
|||
|
|
{processing ? (
|
|||
|
|
<ProgressCard
|
|||
|
|
active={processing}
|
|||
|
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
|||
|
|
label="Applying"
|
|||
|
|
stage={progress.stage}
|
|||
|
|
percent={progress.percent}
|
|||
|
|
elapsed={progress.elapsed}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<button
|
|||
|
|
type="submit"
|
|||
|
|
disabled={!hasFile || !hasChanges || processing}
|
|||
|
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
Apply
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Download */}
|
|||
|
|
{downloadUrl && (
|
|||
|
|
<a
|
|||
|
|
href={downloadUrl}
|
|||
|
|
download
|
|||
|
|
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
|||
|
|
>
|
|||
|
|
<Download className="h-4 w-4" />
|
|||
|
|
Download
|
|||
|
|
</a>
|
|||
|
|
)}
|
|||
|
|
</form>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify it compiles**
|
|||
|
|
|
|||
|
|
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
|||
|
|
Expected: Build succeeds
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/web/src/components/tools/rotate-settings.tsx
|
|||
|
|
git commit -m "feat: add live preview callback to RotateSettings, rename button to Apply"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 5: Update tool-page.tsx for Conditional Rendering
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/web/src/pages/tool-page.tsx`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Add imports, preview transform state, and conditional result rendering**
|
|||
|
|
|
|||
|
|
Changes to make in `tool-page.tsx`:
|
|||
|
|
|
|||
|
|
1. Add imports at the top (after existing imports, around line 8):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
|
|||
|
|
import type { PreviewTransform } from "@/components/tools/rotate-settings";
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
2. Add a set for tools that use alternate result views (after `NO_DROPZONE_TOOLS` on line 63):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
const SIDE_BY_SIDE_TOOLS = new Set(["resize"]);
|
|||
|
|
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
3. Update `ToolSettingsPanel` to accept and pass through the preview transform callback (replace the function starting at line 65):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
function ToolSettingsPanel({
|
|||
|
|
toolId,
|
|||
|
|
onPreviewTransform,
|
|||
|
|
}: {
|
|||
|
|
toolId: string;
|
|||
|
|
onPreviewTransform?: (t: PreviewTransform) => void;
|
|||
|
|
}) {
|
|||
|
|
// Phase 2: Core tools
|
|||
|
|
if (toolId === "resize") return <ResizeSettings />;
|
|||
|
|
if (toolId === "crop") return <CropSettings />;
|
|||
|
|
if (toolId === "rotate") return <RotateSettings onPreviewTransform={onPreviewTransform} />;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
(Rest of ToolSettingsPanel stays identical)
|
|||
|
|
|
|||
|
|
4. In the `ToolPage` component, add preview transform state (after `const [mobileSettingsOpen, setMobileSettingsOpen]` on line 177):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
5. Update ToolSettingsPanel usage in both mobile and desktop layouts — pass the callback:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
<ToolSettingsPanel
|
|||
|
|
toolId={tool.id}
|
|||
|
|
onPreviewTransform={LIVE_PREVIEW_TOOLS.has(tool.id) ? setPreviewTransform : undefined}
|
|||
|
|
/>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
6. Replace the result rendering logic in the main area for **both mobile and desktop layouts**. Replace the entire conditional block (from `{isNoDropzone ?` through the closing `}`). Apply this **identically** in both the mobile layout (around line 286) and the desktop layout (around line 372):
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
{isNoDropzone ? (
|
|||
|
|
<div className="text-center text-muted-foreground">
|
|||
|
|
<p className="text-sm">Configure settings and generate.</p>
|
|||
|
|
</div>
|
|||
|
|
) : hasProcessed && originalBlobUrl && SIDE_BY_SIDE_TOOLS.has(tool.id) ? (
|
|||
|
|
<SideBySideComparison
|
|||
|
|
beforeSrc={originalBlobUrl}
|
|||
|
|
afterSrc={processedUrl}
|
|||
|
|
beforeSize={originalSize ?? undefined}
|
|||
|
|
afterSize={processedSize ?? undefined}
|
|||
|
|
/>
|
|||
|
|
) : hasProcessed && originalBlobUrl && LIVE_PREVIEW_TOOLS.has(tool.id) ? (
|
|||
|
|
<ImageViewer
|
|||
|
|
src={processedUrl}
|
|||
|
|
filename={processedFileName}
|
|||
|
|
fileSize={processedSize ?? 0}
|
|||
|
|
/>
|
|||
|
|
) : hasProcessed && originalBlobUrl ? (
|
|||
|
|
<BeforeAfterSlider
|
|||
|
|
beforeSrc={originalBlobUrl}
|
|||
|
|
afterSrc={processedUrl}
|
|||
|
|
beforeSize={originalSize ?? undefined}
|
|||
|
|
afterSize={processedSize ?? undefined}
|
|||
|
|
/>
|
|||
|
|
) : hasFile && originalBlobUrl ? (
|
|||
|
|
<ImageViewer
|
|||
|
|
src={originalBlobUrl}
|
|||
|
|
filename={selectedFileName ?? files[0].name}
|
|||
|
|
fileSize={selectedFileSize ?? files[0].size}
|
|||
|
|
{...(LIVE_PREVIEW_TOOLS.has(tool.id) && previewTransform
|
|||
|
|
? {
|
|||
|
|
cssRotate: previewTransform.rotate,
|
|||
|
|
cssFlipH: previewTransform.flipH,
|
|||
|
|
cssFlipV: previewTransform.flipV,
|
|||
|
|
}
|
|||
|
|
: {})}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<Dropzone
|
|||
|
|
onFiles={handleFiles}
|
|||
|
|
accept="image/*"
|
|||
|
|
multiple
|
|||
|
|
currentFiles={files}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Important:** The `BeforeAfterSlider` is kept for all tools except resize (SideBySideComparison) and rotate (ImageViewer). The `BeforeAfterSlider` import must NOT be removed.
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Verify it compiles**
|
|||
|
|
|
|||
|
|
Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter @stirling-image/web build 2>&1 | tail -5`
|
|||
|
|
Expected: Build succeeds
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add apps/web/src/pages/tool-page.tsx
|
|||
|
|
git commit -m "feat: conditional result views — side-by-side for resize, live preview for rotate"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 6: Docker Rebuild and Test
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- No file changes — build and run existing Docker setup
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Build Docker image**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image
|
|||
|
|
docker compose -f docker/docker-compose.yml build
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Start the container**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
docker compose -f docker/docker-compose.yml up -d
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Verify the app is running**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
curl -f http://localhost:1349/api/v1/health
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: Health check passes
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Report to user for UI testing**
|
|||
|
|
|
|||
|
|
App is running at `http://localhost:1349`. User can test:
|
|||
|
|
- Resize tool: tab-based settings (Presets, Custom Size, Scale), side-by-side result view
|
|||
|
|
- Rotate tool: live CSS preview as controls change, "Apply" button, processed result in standard viewer
|