Compare commits

..

8 Commits

30 changed files with 2621 additions and 3614 deletions

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
# followspot-psn
Solution for controlling followspots, and sending the position data to ma3 over
[posistagenet (PSN)](https://posistage.net/). It uses
[psn-py](https://github.com/vyv/psn-py) to do PSN stuff.
### Deployment
**TLDR**: `docker compose up --build` on a Linux machine.
> [!NOTE]
> Why Linux? This is because PSN uses multicast to transmit positional data to
> ma3. On linux this is done using host networking driver. However the host
> networking driver on macOS or windows will only allow broadcasting on the
> docker linux VM. It could probably be done using some fancy routing/bridge
> setup with the docker VM.
This opens a webserver on port 8000 where you can control the PSN trackers. PSN
data is multicasted on `236.10.10.10:56565`. Tracker positions can also be
updated using OSC, by default OSC listens on port 9000. The OSC endpoint expects
data on `/Tracker/{trackerid}` with three floats specifying x, y and z value.
Currently all configuration is done in the source files, most notably in
[psn_server.py](backend/psn_server.py).
The rest of the functionality is documented in the code.

View File

@@ -1,22 +1,26 @@
import asyncio import asyncio
import json import json
import logging import logging
import os
import socket import socket
import time import time
import weakref import weakref
from dataclasses import dataclass from dataclasses import dataclass
import psn import psn
from pythonosc.osc_server import AsyncIOOSCUDPServer from aiohttp import WSCloseCode, web
from pythonosc.dispatcher import Dispatcher from pythonosc.dispatcher import Dispatcher
from aiohttp import web, WSCloseCode from pythonosc.osc_server import AsyncIOOSCUDPServer
PSN_DEFAULT_UDP_PORT = 56565 PSN_DEFAULT_UDP_PORT = os.getenv("PSN_DEFAULT_UDP_PORT", 56565)
PSN_DEFAULT_UDP_MCAST_ADDRESS = "236.10.10.10" PSN_DEFAULT_UDP_MCAST_ADDRESS = os.getenv(
WEB_SERVER_PORT = 8000 "PSN_DEFAULT_UDP_MCAST_ADDRESS", "236.10.10.10"
)
WEB_SERVER_PORT = int(os.getenv("WEB_SERVER_PORT", 8000))
IP = "0.0.0.0" IP = "0.0.0.0"
OSC_SERVER_PORT = 6969 OSC_SERVER_PORT = int(os.getenv("OSC_SERVER_PORT", 9000))
NUM_TRACKERS = 3 NUM_TRACKERS = int(os.getenv("NUM_TRACKERS", 3))
class SceneDimensions: class SceneDimensions:
x_min: float x_min: float
@@ -30,7 +34,7 @@ class SceneDimensions:
dimension_map = { dimension_map = {
"scene_only": (-13 / 2, 13 / 2, 0, 6.3, 0, 4), "scene_only": (-13 / 2, 13 / 2, 0, 6.3, 0, 4),
"full_arena": (-13 / 2, 13 / 2, -9.7, 6.3 , 0, 4) "full_arena": (-13 / 2, 13 / 2, -9.7, 6.3, 0, 4),
} }
def __init__(self): def __init__(self):
@@ -53,10 +57,12 @@ class SceneDimensions:
self.dimension_name = "full_arena" self.dimension_name = "full_arena"
START_POSITION_INTERNAL = (0.5, 0.5, 2) START_POSITION_INTERNAL = (0.5, 0.5, 2)
def map_range(value: float, in_min: float, in_max: float, out_min: float, out_max: float) -> float:
def map_range(
value: float, in_min: float, in_max: float, out_min: float, out_max: float
) -> float:
return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
@@ -69,7 +75,9 @@ class TrackerData:
z: float z: float
@staticmethod @staticmethod
def internal_to_scene_coords_3d(x: float, y: float, z: float, dim: SceneDimensions) -> tuple[float, float, float]: def internal_to_scene_coords_3d(
x: float, y: float, z: float, dim: SceneDimensions
) -> tuple[float, float, float]:
""" """
Convert internal coordinates to scene coordinates Convert internal coordinates to scene coordinates
@@ -83,7 +91,9 @@ class TrackerData:
return x_val, y_val, z_val return x_val, y_val, z_val
@staticmethod @staticmethod
def scene_to_internal_coords_3d(x: float, y: float, z: float, dim: SceneDimensions) -> tuple[float, float, float]: def scene_to_internal_coords_3d(
x: float, y: float, z: float, dim: SceneDimensions
) -> tuple[float, float, float]:
x_val = map_range(x, dim.x_min, dim.x_max, 0, 1) x_val = map_range(x, dim.x_min, dim.x_max, 0, 1)
y_val = map_range(y, dim.y_max, dim.y_min, 0, 1) # Invert y axis y_val = map_range(y, dim.y_max, dim.y_min, 0, 1) # Invert y axis
z_val = z z_val = z
@@ -117,20 +127,25 @@ def get_elapsed_time_ms():
return get_time_ms() - START_TIME return get_time_ms() - START_TIME
async def update_all_other_clients(app: web.Application, ws: web.WebSocketResponse = None): async def update_all_other_clients(
app: web.Application, ws: web.WebSocketResponse = None
):
for ws_send in app["ws_clients"]: for ws_send in app["ws_clients"]:
if ws_send == ws: if ws_send == ws:
continue continue
await ws_send.send_str(trackers_to_json(app)) await ws_send.send_str(trackers_to_json(app))
async def update_all_clients(app: web.Application): async def update_all_clients(app: web.Application):
for ws in app["ws_clients"]: for ws in app["ws_clients"]:
await ws.send_str(trackers_to_json(app)) await ws.send_str(trackers_to_json(app))
async def update_all_clients_bg(app: web.Application): async def update_all_clients_bg(app: web.Application):
for ws in app["ws_clients"]: for ws in app["ws_clients"]:
await ws.send_str(json.dumps({"refresh": True})) await ws.send_str(json.dumps({"refresh": True}))
async def handle_websocket(request): async def handle_websocket(request):
ws = web.WebSocketResponse() ws = web.WebSocketResponse()
logging.debug("Websocket connection starting") logging.debug("Websocket connection starting")
@@ -161,6 +176,7 @@ async def handle_websocket(request):
return ws return ws
async def on_shutdown(app): async def on_shutdown(app):
for ws in set(app["ws_clients"]): for ws in set(app["ws_clients"]):
await ws.close(code=WSCloseCode.GOING_AWAY, message="Server shutdown") await ws.close(code=WSCloseCode.GOING_AWAY, message="Server shutdown")
@@ -169,6 +185,7 @@ async def on_shutdown(app):
async def handle_root(request): async def handle_root(request):
return web.FileResponse("./static/index.html") return web.FileResponse("./static/index.html")
async def handle_background_image(request): async def handle_background_image(request):
if request.app["scene_dimensions"].dimension_name == "full_arena": if request.app["scene_dimensions"].dimension_name == "full_arena":
return web.FileResponse("./static/scene_and_crowd.png") return web.FileResponse("./static/scene_and_crowd.png")
@@ -176,6 +193,7 @@ async def handle_background_image(request):
return web.FileResponse("./static/scene_only.png") return web.FileResponse("./static/scene_only.png")
return web.Response(text="Incorrect server scende dimension state", status=500) return web.Response(text="Incorrect server scende dimension state", status=500)
async def handle_set_mode(request): async def handle_set_mode(request):
try: try:
data = request.data = await request.json() data = request.data = await request.json()
@@ -193,6 +211,7 @@ async def handle_set_mode(request):
except Exception as e: except Exception as e:
return web.Response(text=f"Error: {e}", status=400) return web.Response(text=f"Error: {e}", status=400)
async def handlet_get_mode(request): async def handlet_get_mode(request):
return web.json_response({"mode": request.app["scene_dimensions"].dimension_name}) return web.json_response({"mode": request.app["scene_dimensions"].dimension_name})
@@ -228,11 +247,42 @@ def osc_tracker_updater(address, fixed_args, *args) -> None:
asyncio.ensure_future(update_all_clients(app)) asyncio.ensure_future(update_all_clients(app))
async def handle_add_tracker(request):
# Add tracker with id from request
try:
trackers = request.app["trackers"]
request_data = await request.json()
tracker_id = request_data["id"]
if tracker_id in trackers:
return web.Response(text="Tracker already exists", status=400)
trackers[tracker_id] = TrackerData(tracker_id, *START_POSITION_INTERNAL)
return web.Response(text="OK", status=200)
except Exception as e:
return web.Response(text=f"Error: {e}", status=400)
async def handler_delete_tracker(request):
# Delete tracker with id from request
try:
trackers = request.app["trackers"]
request_data = await request.json()
tracker_id = request_data["id"]
if tracker_id not in trackers:
return web.Response(text="Tracker does not exist", status=400)
del trackers[tracker_id]
return web.Response(text="OK", status=200)
except Exception as e:
return web.Response(text=f"Error: {e}", status=400)
async def receive_osc_data(app): async def receive_osc_data(app):
dispatcher = Dispatcher() dispatcher = Dispatcher()
dispatcher.map("/Tracker*", osc_tracker_updater, app) dispatcher.map("/Tracker*", osc_tracker_updater, app)
server = AsyncIOOSCUDPServer((IP, OSC_SERVER_PORT), dispatcher, asyncio.get_event_loop()) server = AsyncIOOSCUDPServer(
(IP, OSC_SERVER_PORT), dispatcher, asyncio.get_event_loop()
)
transport, protocol = await server.create_serve_endpoint() transport, protocol = await server.create_serve_endpoint()
app["osc_transport"] = transport app["osc_transport"] = transport
yield yield
@@ -244,8 +294,13 @@ def create_app():
app.router.add_get("/", handle_root) app.router.add_get("/", handle_root)
app.router.add_get("/ws", handle_websocket) app.router.add_get("/ws", handle_websocket)
app.router.add_get("/background_image", handle_background_image) app.router.add_get("/background_image", handle_background_image)
app.router.add_post("/mode", handle_set_mode) app.router.add_post("/mode", handle_set_mode)
app.router.add_get("/mode", handlet_get_mode) app.router.add_get("/mode", handlet_get_mode)
app.router.add_post("/tracker", handle_add_tracker)
app.router.add_delete("/tracker", handler_delete_tracker)
app.router.add_static("/", "./static") app.router.add_static("/", "./static")
# Setup app state # Setup app state

View File

@@ -2,3 +2,14 @@ services:
followspot-psn: followspot-psn:
build: . build: .
network_mode: host network_mode: host
environment:
PSN_DEFAULT_UDP_PORT: 56565
PSN_DEFAULT_UDP_MCAST_ADDRESS: "236.10.10.10"
WEB_SERVER_PORT: 8000
OSC_SERVER_PORT: 9000
NUM_TRACKERS: 3
volumes:
- spot-data:/data
volumes:
spot-data:

View File

@@ -8,5 +8,10 @@ import tailwind from "@astrojs/tailwind";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
integrations: [svelte(), tailwind()], integrations: [
svelte(),
tailwind({
applyBaseStyles: false,
}),
],
}); });

14
frontend/components.json Normal file
View File

@@ -0,0 +1,14 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.mjs",
"css": "src/styles/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils"
},
"typescript": true
}

View File

@@ -11,17 +11,27 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
"@astrojs/svelte": "^7.0.3", "@astrojs/svelte": "^7.0.5",
"@astrojs/tailwind": "^5.1.4", "@astrojs/tailwind": "^5.1.5",
"astro": "^5.1.4", "astro": "^5.4.2",
"svelte": "^5.17.2", "svelte": "^5.22.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.3" "typescript": "^5.8.2"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.4.2", "bits-ui": "0.22.0",
"clsx": "^2.1.1",
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"prettier-plugin-svelte": "^3.3.2", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.9" "prettier-plugin-tailwindcss": "^0.6.11",
"tailwind-merge": "^3.0.2",
"tailwind-variants": "^1.0.0",
"vaul-svelte": "^0.3.2"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
} }
} }

5334
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
import type { TrackerData } from "../utils/types"; import type { TrackerData } from "../utils/types";
import Drag from "./Drag.svelte"; import Drag from "./Drag.svelte";
import Slider from "./Slider.svelte"; import Slider from "./Slider.svelte";
import Settings from "./Settings.svelte";
let ws: WebSocket | null = $state(null); let ws: WebSocket | null = $state(null);
let image: HTMLImageElement | null = $state(null); let image: HTMLImageElement | null = $state(null);
@@ -27,14 +28,12 @@
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log(data) console.log(data);
if (data.refresh && image) { if (data.refresh && image) {
image.src = `/background_image?${Math.random()}`; image.src = `/background_image?${Math.random()}`;
return; return;
} } else {
else {
for (const tracker of data) { for (const tracker of data) {
tracker.x *= width; tracker.x *= width;
tracker.y *= height; tracker.y *= height;
@@ -66,8 +65,10 @@
</script> </script>
<svelte:window onresize={resize} /> <svelte:window onresize={resize} />
<div class="flex h-screen items-center justify-center w-screen"> <div class="flex h-screen w-screen items-center justify-center">
<div class="w-20 mr-auto"></div> <div class="mr-auto w-20">
<Settings />
</div>
<div class="relative"> <div class="relative">
<img <img
src="/background_image?342038402" src="/background_image?342038402"

View File

@@ -76,7 +76,7 @@
onpointermove={onPointerMove} onpointermove={onPointerMove}
style={`transform: translate(${vis_x}px, ${vis_y}px)`} style={`transform: translate(${vis_x}px, ${vis_y}px)`}
class={`absolute flex h-24 w-24 touch-none select-none items-center justify-center rounded-full class={`absolute flex h-24 w-24 touch-none select-none items-center justify-center rounded-full
${selected === id ? 'bg-green-400 border-green-400' : 'bg-red-400 border-red-400'}`} ${selected === id ? "border-green-400 bg-green-400" : "border-red-400 bg-red-400"}`}
> >
Tracker {id} Tracker {id}
</div> </div>

View File

@@ -1,10 +1,38 @@
<script lang="ts"> <script lang="ts">
type Props = { type Props = {
open: boolean; action: () => void;
onClose: () => void; };
let { action }: Props = $props();
let dialog: HTMLDialogElement;
const openModal = () => {
dialog.showModal();
}; };
</script> </script>
<dialog> <button class="mb-24 rounded-md bg-red-400 p-2" onclick={openModal}>
<div></div> Change mode
</button>
<dialog
open={false}
bind:this={dialog}
class="absolute left-0 top-0 z-50 m-0 h-full max-h-full w-full max-w-full p-0 open:bg-black/80"
>
<div class="flex h-full w-full flex-col items-center justify-center">
<h1 class="text-8xl text-red-600">THIS GONNA FUCK THINGS UP</h1>
<div class="flex items-center justify-center gap-6">
<button class="rounded-md bg-gray-400 p-4" onclick={() => dialog.close()}
>Close</button
>
<button
class="rounded-md bg-red-400 p-4"
onclick={() => {
action();
dialog.close();
}}>Change mode</button
>
</div>
</div>
</dialog> </dialog>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import * as Drawer from "$lib/components/ui/drawer/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import TrackerSetting from "./TrackerSetting.svelte";
const addTracker = async () => {
const response = await fetch("/tracker", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: 4,
}),
});
console.log(response);
};
const deleteTracker = async () => {
const response = await fetch("/tracker", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: 4,
}),
});
console.log(response);
};
</script>
<Drawer.Root>
<Drawer.Trigger asChild let:builder>
<Button builders={[builder]} variant="outline">Settings</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Are you sure absolutely sure?</Drawer.Title>
<Drawer.Description>This action cannot be undone.</Drawer.Description>
</Drawer.Header>
<div class="flex items-center justify-evenly">
<TrackerSetting action={addTracker} text="Add Tracker" />
<TrackerSetting action={deleteTracker} text="Delete Tracker" />
</div>
</Drawer.Content>
</Drawer.Root>

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { TrackerData } from "$lib/utils/types"; import type { TrackerData } from "$lib/utils/types";
import { onMount } from "svelte";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
type Props = { type Props = {
@@ -21,15 +20,19 @@
let arena_state: "full_arena" | "scene_only" = $state("scene_only"); let arena_state: "full_arena" | "scene_only" = $state("scene_only");
let tracker = trackers.find((ball) => {
ball.id === selected;
});
const onchange = () => { const onchange = () => {
const x_send = trackers[selected].x / width; const x_send = tracker!.x / width;
const y_send = trackers[selected].y / height; const y_send = tracker!.y / height;
ws?.send( ws?.send(
JSON.stringify({ JSON.stringify({
id: trackers[selected].id, id: tracker!.id,
x: x_send, x: x_send,
y: y_send, y: y_send,
z: trackers[selected].z, z: tracker!.z,
}), }),
); );
}; };
@@ -53,13 +56,11 @@
}); });
}; };
let z_viz = $derived(trackers[selected].z.toFixed(2)); let z_viz = $derived(tracker?.z.toFixed(2));
</script> </script>
<Modal />
<div class="slider-container ml-auto"> <div class="slider-container ml-auto">
<button class="bg-red-400 rounded-md p-2 mb-24" <Modal action={buttonAction} />
onclick={buttonAction}>Change mode</button>
<input <input
type="range" type="range"
id="z" id="z"
@@ -67,7 +68,6 @@
max="4" max="4"
step="0.01" step="0.01"
oninput={onchange} oninput={onchange}
bind:value={trackers[selected].z}
/> />
<output for="z">{z_viz} m</output> <output for="z">{z_viz} m</output>
</div> </div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import Button from "./ui/button/button.svelte";
import Input from "./ui/input/input.svelte";
type Props = {
text: string;
action: () => void;
};
let { action, text }: Props = $props();
</script>
<div>
<h2>{text}</h2>
<Input placeholder={"1"} type="tel" />
<Button onclick={action}>Submit</Button>
</div>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { Button as ButtonPrimitive } from "bits-ui";
import { type Events, type Props, buttonVariants } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = Props;
type $$Events = Events;
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export let size: $$Props["size"] = "default";
export let builders: $$Props["builders"] = [];
export { className as class };
</script>
<ButtonPrimitive.Root
{builders}
class={cn(buttonVariants({ variant, size, className }))}
type="button"
{...$$restProps}
on:click
on:keydown
>
<slot />
</ButtonPrimitive.Root>

View File

@@ -0,0 +1,49 @@
import { type VariantProps, tv } from "tailwind-variants";
import type { Button as ButtonPrimitive } from "bits-ui";
import Root from "./button.svelte";
const buttonVariants = tv({
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
type Variant = VariantProps<typeof buttonVariants>["variant"];
type Size = VariantProps<typeof buttonVariants>["size"];
type Props = ButtonPrimitive.Props & {
variant?: Variant;
size?: Size;
};
type Events = ButtonPrimitive.Events;
export {
Root,
type Props,
type Events,
//
Root as Button,
type Props as ButtonProps,
type Events as ButtonEvents,
buttonVariants,
};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import DrawerOverlay from "./drawer-overlay.svelte";
import { cn } from "$lib/utils.js";
type $$Props = DrawerPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<DrawerPrimitive.Portal>
<DrawerOverlay />
<DrawerPrimitive.Content
class={cn(
"bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border",
className
)}
{...$$restProps}
>
<div class="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full"></div>
<slot />
</DrawerPrimitive.Content>
</DrawerPrimitive.Portal>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
type $$Props = DrawerPrimitive.DescriptionProps;
export let el: $$Props["el"] = undefined;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<DrawerPrimitive.Description
bind:el
class={cn("text-muted-foreground text-sm", className)}
{...$$restProps}
>
<slot />
</DrawerPrimitive.Description>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement> & {
el?: HTMLDivElement;
};
export let el: $$Props["el"] = undefined;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div bind:this={el} class={cn("mt-auto flex flex-col gap-2 p-4", className)} {...$$restProps}>
<slot />
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
type $$Props = HTMLAttributes<HTMLDivElement> & {
el?: HTMLDivElement;
};
export let el: $$Props["el"] = undefined;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div
bind:this={el}
class={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...$$restProps}
>
<slot />
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
type $$Props = DrawerPrimitive.Props;
export let shouldScaleBackground: $$Props["shouldScaleBackground"] = true;
export let open: $$Props["open"] = false;
export let activeSnapPoint: $$Props["activeSnapPoint"] = undefined;
</script>
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...$$restProps}>
<slot />
</DrawerPrimitive.NestedRoot>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
type $$Props = DrawerPrimitive.OverlayProps;
export let el: $$Props["el"] = undefined;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<DrawerPrimitive.Overlay
bind:el
class={cn("fixed inset-0 z-50 bg-black/80", className)}
{...$$restProps}
>
<slot />
</DrawerPrimitive.Overlay>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import { cn } from "$lib/utils.js";
type $$Props = DrawerPrimitive.TitleProps;
export let el: $$Props["el"] = undefined;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<DrawerPrimitive.Title
bind:el
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...$$restProps}
>
<slot />
</DrawerPrimitive.Title>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Drawer as DrawerPrimitive } from "vaul-svelte";
type $$Props = DrawerPrimitive.Props;
export let shouldScaleBackground: $$Props["shouldScaleBackground"] = true;
export let open: $$Props["open"] = false;
export let activeSnapPoint: $$Props["activeSnapPoint"] = undefined;
</script>
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...$$restProps}>
<slot />
</DrawerPrimitive.Root>

View File

@@ -0,0 +1,41 @@
import { Drawer as DrawerPrimitive } from "vaul-svelte";
import Root from "./drawer.svelte";
import Content from "./drawer-content.svelte";
import Description from "./drawer-description.svelte";
import Overlay from "./drawer-overlay.svelte";
import Footer from "./drawer-footer.svelte";
import Header from "./drawer-header.svelte";
import Title from "./drawer-title.svelte";
import NestedRoot from "./drawer-nested.svelte";
const Trigger = DrawerPrimitive.Trigger;
const Portal = DrawerPrimitive.Portal;
const Close = DrawerPrimitive.Close;
export {
Root,
NestedRoot,
Content,
Description,
Overlay,
Footer,
Header,
Title,
Trigger,
Portal,
Close,
//
Root as Drawer,
NestedRoot as DrawerNestedRoot,
Content as DrawerContent,
Description as DrawerDescription,
Overlay as DrawerOverlay,
Footer as DrawerFooter,
Header as DrawerHeader,
Title as DrawerTitle,
Trigger as DrawerTrigger,
Portal as DrawerPortal,
Close as DrawerClose,
};

View File

@@ -0,0 +1,29 @@
import Root from "./input.svelte";
export type FormInputEvent<T extends Event = Event> = T & {
currentTarget: EventTarget & HTMLInputElement;
};
export type InputEvents = {
blur: FormInputEvent<FocusEvent>;
change: FormInputEvent<Event>;
click: FormInputEvent<MouseEvent>;
focus: FormInputEvent<FocusEvent>;
focusin: FormInputEvent<FocusEvent>;
focusout: FormInputEvent<FocusEvent>;
keydown: FormInputEvent<KeyboardEvent>;
keypress: FormInputEvent<KeyboardEvent>;
keyup: FormInputEvent<KeyboardEvent>;
mouseover: FormInputEvent<MouseEvent>;
mouseenter: FormInputEvent<MouseEvent>;
mouseleave: FormInputEvent<MouseEvent>;
mousemove: FormInputEvent<MouseEvent>;
paste: FormInputEvent<ClipboardEvent>;
input: FormInputEvent<InputEvent>;
wheel: FormInputEvent<WheelEvent>;
};
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import type { InputEvents } from "./index.js";
import { cn } from "$lib/utils.js";
type $$Props = HTMLInputAttributes;
type $$Events = InputEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"] = undefined;
export { className as class };
// Workaround for https://github.com/sveltejs/svelte/issues/9305
// Fixed in Svelte 5, but not backported to 4.x.
export let readonly: $$Props["readonly"] = undefined;
</script>
<input
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:value
{readonly}
on:blur
on:change
on:click
on:focus
on:focusin
on:focusout
on:keydown
on:keypress
on:keyup
on:mouseover
on:mouseenter
on:mouseleave
on:mousemove
on:paste
on:input
on:wheel|passive
{...$$restProps}
/>

View File

@@ -1,5 +1,6 @@
--- ---
import Container from "../components/Container.svelte"; import Container from "../components/Container.svelte";
import "$lib/styles/app.css";
--- ---
<html lang="en"> <html lang="en">

View File

@@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

62
frontend/src/utils.ts Normal file
View File

@@ -0,0 +1,62 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
type FlyAndScaleParams = {
y?: number;
x?: number;
start?: number;
duration?: number;
};
export const flyAndScale = (
node: Element,
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
): TransitionConfig => {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
const scaleConversion = (
valueA: number,
scaleA: [number, number],
scaleB: [number, number]
) => {
const [minA, maxA] = scaleA;
const [minB, maxB] = scaleB;
const percentage = (valueA - minA) / (maxA - minA);
const valueB = percentage * (maxB - minB) + minB;
return valueB;
};
const styleToString = (
style: Record<string, number | string | undefined>
): string => {
return Object.keys(style).reduce((str, key) => {
if (style[key] === undefined) return str;
return str + `${key}:${style[key]};`;
}, "");
};
return {
duration: params.duration ?? 200,
delay: 0,
css: (t) => {
const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);
const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);
const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);
return styleToString({
transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
opacity: t
});
},
easing: cubicOut
};
};

View File

@@ -1,8 +1,64 @@
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { const config = {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"],
safelist: ["dark"],
theme: { theme: {
extend: {}, container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px"
}
},
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
}
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)"
},
fontFamily: {
sans: [...fontFamily.sans]
}
}
}, },
plugins: [],
}; };
export default config;