phase 2 complete

This commit is contained in:
Oliver Bryan
2026-01-08 10:47:48 +00:00
parent 77a9b32841
commit e5a7030d9b
10 changed files with 232 additions and 132 deletions

View File

@@ -104,12 +104,12 @@ src/
### Tasks
- [ ] Create `useShapeState.ts` hook with default values
- [ ] Add `beforeunload` warning when state has changed
- [ ] Create `ShapeCanvas.tsx` - Konva Stage that fills container
- [ ] Create `MorphableShape.tsx` - renders a simple circle for now
- [ ] Make shape draggable, update state on drag end
- [ ] Display current x/y in sidebar (debug, remove later)
- [x] Create `useShapeState.ts` hook with default values
- [x] Add `beforeunload` warning when state has changed
- [x] Create `ShapeCanvas.tsx` - Konva Stage that fills container
- [x] Create `MorphableShape.tsx` - renders a simple circle for now
- [x] Make shape draggable, update state on drag end
- [x] Display current x/y in sidebar (debug, remove later)
### Deliverables

View File

@@ -1,28 +1,13 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Index from "@/Index";
import Settings from "./Settings";
import Layout from "./Layout";
function App() {
return (
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<Layout>
<Index />
</Layout>
}
/>
<Route
path="/settings"
element={
<Layout>
<Settings />
</Layout>
}
/>
<Route path="/" element={<Index />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</BrowserRouter>
);

View File

@@ -1,16 +1,18 @@
import { Stage, Layer, Circle } from "react-konva";
import { useEffect, useState } from "react";
import ShapeCanvas from "@/components/canvas/ShapeCanvas";
import { useShapeState } from "@/hooks/useShapeState";
import Layout from "./Layout";
function Index() {
const [dimensions, setDimensions] = useState({
width: window.innerWidth,
width: window.innerWidth - 320,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setDimensions({
width: window.innerWidth,
width: window.innerWidth - 320,
height: window.innerHeight,
});
};
@@ -19,18 +21,15 @@ function Index() {
return () => window.removeEventListener("resize", handleResize);
}, []);
// canvas fills the available space (accounting for sidebar width of 320px)
const canvasWidth = dimensions.width - 320;
const canvasHeight = dimensions.height;
const centerX = dimensions.width / 2;
const centerY = dimensions.height / 2;
const [state, setState] = useShapeState(centerX, centerY);
return (
<>
<Stage width={canvasWidth} height={canvasHeight} className="">
<Layer>
<Circle x={canvasWidth / 2} y={canvasHeight / 2} radius={100} fill="#68d436" draggable />
</Layer>
</Stage>
</>
<Layout sidebarContent={<>controls here</>}>
<ShapeCanvas state={state} onStateChange={setState} />
</Layout>
);
}

View File

@@ -3,7 +3,13 @@ import { Link, useLocation } from "react-router-dom";
import { Home, Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function Layout({ children }: { children: React.ReactNode }) {
export default function Layout({
children,
sidebarContent,
}: {
children: React.ReactNode;
sidebarContent?: React.ReactNode;
}) {
const location = useLocation();
return (
@@ -13,7 +19,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">The Shape of Sound</h1>
</div>
<div className="flex-1">{/* controls will go here in later phases */}</div>
<div className="flex-1">{sidebarContent || null}</div>
<div className="flex items-center gap-4">
<ThemeToggle className="rounded-lg" />

View File

@@ -1,7 +1,9 @@
import Layout from "@/Layout";
export default function Settings() {
return (
<div className="w-full h-full flex flex-col items-center justify-start">
<Layout>
<p>Settings Page</p>
</div>
</Layout>
);
}

View File

@@ -0,0 +1,23 @@
import { Circle } from "react-konva";
import type { KonvaEventObject } from "konva/lib/Node";
import type { ShapeState } from "@/types/shape";
export default function MorphableShape({
state,
onStateChange,
}: {
state: ShapeState;
onStateChange: (state: ShapeState) => void;
}) {
const handleDrag = (e: KonvaEventObject<DragEvent>) => {
onStateChange({
...state,
x: e.target.x(),
y: e.target.y(),
});
};
return (
<Circle x={state.x} y={state.y} radius={100} fill={state.color} draggable onDragMove={handleDrag} />
);
}

View File

@@ -0,0 +1,37 @@
import { Stage, Layer } from "react-konva";
import { useEffect, useState } from "react";
import MorphableShape from "./MorphableShape";
import type { ShapeState } from "@/types/shape";
export default function ShapeCanvas({
state,
onStateChange,
}: {
state: ShapeState;
onStateChange: (state: ShapeState) => void;
}) {
const [dimensions, setDimensions] = useState({
width: window.innerWidth - 320, // account for sidebar
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setDimensions({
width: window.innerWidth - 320,
height: window.innerHeight,
});
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<Stage width={dimensions.width} height={dimensions.height}>
<Layer>
<MorphableShape state={state} onStateChange={onStateChange} />
</Layer>
</Stage>
);
}

View File

@@ -1,61 +1,57 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
const _values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
// biome-ignore lint/suspicious/noArrayIndexKey: <>
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider }
export { Slider };

View File

@@ -1,47 +1,46 @@
"use client"
"use client";
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants }
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,53 @@
import { useState, useEffect, useRef } from "react";
import type { ShapeState } from "@/types/shape";
const DEFAULT_STATE: ShapeState = {
x: 0,
y: 0,
preset: "circle",
roundness: 100, // full circle
size: 50, // medium
wobble: 20, // subtle
wobbleSpeed: 50, // medium
grain: 0, // none
color: "#FF0000", // red (C)
octave: 4, // middle octave
};
export function useShapeState(centerX: number, centerY: number) {
const [state, setState] = useState<ShapeState>({
...DEFAULT_STATE,
x: centerX,
y: centerY,
});
const initialStateRef = useRef<ShapeState>({
...DEFAULT_STATE,
x: centerX,
y: centerY,
});
// update center position when canvas resizes
useEffect(() => {
setState((prev) => ({
...prev,
x: centerX,
y: centerY,
}));
}, [centerX, centerY]);
// beforeunload warning
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const hasChanged = JSON.stringify(state) !== JSON.stringify(initialStateRef.current);
if (hasChanged) {
e.preventDefault();
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [state]);
return [state, setState] as const;
}