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
|