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:
19
src/App.tsx
19
src/App.tsx
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
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