mirror of
https://github.com/hex248/tsos.git
synced 2026-02-07 18:23:05 +00:00
phase 2 complete
This commit is contained in:
@@ -104,12 +104,12 @@ src/
|
|||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
- [ ] Create `useShapeState.ts` hook with default values
|
- [x] Create `useShapeState.ts` hook with default values
|
||||||
- [ ] Add `beforeunload` warning when state has changed
|
- [x] Add `beforeunload` warning when state has changed
|
||||||
- [ ] Create `ShapeCanvas.tsx` - Konva Stage that fills container
|
- [x] Create `ShapeCanvas.tsx` - Konva Stage that fills container
|
||||||
- [ ] Create `MorphableShape.tsx` - renders a simple circle for now
|
- [x] Create `MorphableShape.tsx` - renders a simple circle for now
|
||||||
- [ ] Make shape draggable, update state on drag end
|
- [x] Make shape draggable, update state on drag end
|
||||||
- [ ] Display current x/y in sidebar (debug, remove later)
|
- [x] Display current x/y in sidebar (debug, remove later)
|
||||||
|
|
||||||
### Deliverables
|
### Deliverables
|
||||||
|
|
||||||
|
|||||||
19
src/App.tsx
19
src/App.tsx
@@ -1,28 +1,13 @@
|
|||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
import Index from "@/Index";
|
import Index from "@/Index";
|
||||||
import Settings from "./Settings";
|
import Settings from "./Settings";
|
||||||
import Layout from "./Layout";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="/" element={<Index />} />
|
||||||
path="/"
|
<Route path="/settings" element={<Settings />} />
|
||||||
element={
|
|
||||||
<Layout>
|
|
||||||
<Index />
|
|
||||||
</Layout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/settings"
|
|
||||||
element={
|
|
||||||
<Layout>
|
|
||||||
<Settings />
|
|
||||||
</Layout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Stage, Layer, Circle } from "react-konva";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import ShapeCanvas from "@/components/canvas/ShapeCanvas";
|
||||||
|
import { useShapeState } from "@/hooks/useShapeState";
|
||||||
|
import Layout from "./Layout";
|
||||||
|
|
||||||
function Index() {
|
function Index() {
|
||||||
const [dimensions, setDimensions] = useState({
|
const [dimensions, setDimensions] = useState({
|
||||||
width: window.innerWidth,
|
width: window.innerWidth - 320,
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
setDimensions({
|
setDimensions({
|
||||||
width: window.innerWidth,
|
width: window.innerWidth - 320,
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -19,18 +21,15 @@ function Index() {
|
|||||||
return () => window.removeEventListener("resize", handleResize);
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// canvas fills the available space (accounting for sidebar width of 320px)
|
const centerX = dimensions.width / 2;
|
||||||
const canvasWidth = dimensions.width - 320;
|
const centerY = dimensions.height / 2;
|
||||||
const canvasHeight = dimensions.height;
|
|
||||||
|
const [state, setState] = useShapeState(centerX, centerY);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Layout sidebarContent={<>controls here</>}>
|
||||||
<Stage width={canvasWidth} height={canvasHeight} className="">
|
<ShapeCanvas state={state} onStateChange={setState} />
|
||||||
<Layer>
|
</Layout>
|
||||||
<Circle x={canvasWidth / 2} y={canvasHeight / 2} radius={100} fill="#68d436" draggable />
|
|
||||||
</Layer>
|
|
||||||
</Stage>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { Link, useLocation } from "react-router-dom";
|
|||||||
import { Home, Settings } from "lucide-react";
|
import { Home, Settings } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -13,7 +19,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold">The Shape of Sound</h1>
|
<h1 className="text-2xl font-semibold">The Shape of Sound</h1>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-4">
|
||||||
<ThemeToggle className="rounded-lg" />
|
<ThemeToggle className="rounded-lg" />
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import Layout from "@/Layout";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-start">
|
<Layout>
|
||||||
<p>Settings Page</p>
|
<p>Settings Page</p>
|
||||||
</div>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/components/canvas/MorphableShape.tsx
Normal file
23
src/components/canvas/MorphableShape.tsx
Normal 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} />
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/canvas/ShapeCanvas.tsx
Normal file
37
src/components/canvas/ShapeCanvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,61 +1,57 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Slider({
|
function Slider({
|
||||||
className,
|
className,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
value,
|
value,
|
||||||
min = 0,
|
min = 0,
|
||||||
max = 100,
|
max = 100,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
const _values = React.useMemo(
|
const _values = React.useMemo(
|
||||||
() =>
|
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
|
||||||
Array.isArray(value)
|
[value, defaultValue, min, max],
|
||||||
? value
|
);
|
||||||
: Array.isArray(defaultValue)
|
|
||||||
? defaultValue
|
|
||||||
: [min, max],
|
|
||||||
[value, defaultValue, min, max]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
data-slot="slider"
|
data-slot="slider"
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
value={value}
|
value={value}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
className={cn(
|
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",
|
"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
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track
|
<SliderPrimitive.Track
|
||||||
data-slot="slider-track"
|
data-slot="slider-track"
|
||||||
className={cn(
|
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"
|
"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
|
<SliderPrimitive.Range
|
||||||
data-slot="slider-range"
|
data-slot="slider-range"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
{Array.from({ length: _values.length }, (_, index) => (
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
data-slot="slider-thumb"
|
data-slot="slider-thumb"
|
||||||
key={index}
|
// biome-ignore lint/suspicious/noArrayIndexKey: <>
|
||||||
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"
|
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>
|
))}
|
||||||
)
|
</SliderPrimitive.Root>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Slider }
|
export { Slider };
|
||||||
|
|||||||
@@ -1,47 +1,46 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import type * as React from "react";
|
||||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const toggleVariants = cva(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-transparent",
|
default: "bg-transparent",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-2 min-w-9",
|
default: "h-9 px-2 min-w-9",
|
||||||
sm: "h-8 px-1.5 min-w-8",
|
sm: "h-8 px-1.5 min-w-8",
|
||||||
lg: "h-10 px-2.5 min-w-10",
|
lg: "h-10 px-2.5 min-w-10",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
);
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Toggle({
|
function Toggle({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
size,
|
size,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||||
VariantProps<typeof toggleVariants>) {
|
return (
|
||||||
return (
|
<TogglePrimitive.Root
|
||||||
<TogglePrimitive.Root
|
data-slot="toggle"
|
||||||
data-slot="toggle"
|
className={cn(toggleVariants({ variant, size, className }))}
|
||||||
className={cn(toggleVariants({ variant, size, className }))}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Toggle, toggleVariants }
|
export { Toggle, toggleVariants };
|
||||||
|
|||||||
53
src/hooks/useShapeState.ts
Normal file
53
src/hooks/useShapeState.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user