Compare commits

..

29 Commits

Author SHA1 Message Date
349495019f Slightly darker red 2025-03-09 19:29:29 +01:00
bb31650718 Add toast 2025-03-09 19:26:19 +01:00
ce9ed1f950 Minor update 2025-03-09 18:50:45 +01:00
f314b960e1 smol stuff 2025-03-09 18:29:00 +01:00
4f97e26295 Is fixed 2025-03-09 18:26:20 +01:00
a2fe2780cf Change to tel type 2025-03-09 18:03:25 +01:00
3c16148bb7 Send arg a number 2025-03-09 17:56:16 +01:00
9a8e97885c Fix z issue on active component 2025-03-09 17:51:19 +01:00
46feef3a48 Actually use input value 2025-03-09 17:48:31 +01:00
6e7dd7a560 update clients after trackers are updated 2025-03-09 17:41:20 +01:00
7a347ed246 Merge pull request #2 from AbaTekNTNU/config-settings
Config settings
2025-03-09 17:32:46 +01:00
4f5b301e7d Fix a thing 2025-03-09 17:31:09 +01:00
8fedbc34fa tmp 2025-03-09 17:13:31 +01:00
47d4c7926e api endpoints for adding and removing trackers 2025-03-09 16:16:26 +01:00
3fe9ecbbb8 Format and update 2025-03-09 15:23:10 +01:00
7435e91b07 Make backend configurable 2025-03-07 15:53:11 +01:00
e0b685bfda Format readme 2025-03-07 15:44:15 +01:00
7498d9066e Add modal confirmation to mode change 2025-03-07 15:40:42 +01:00
b974ac8936 readme 2025-03-07 15:29:26 +01:00
02526f340a Add change view button 2025-03-07 14:21:58 +01:00
d7da674267 mode get endpoint 2025-03-07 14:02:06 +01:00
9029739657 more shit 2025-03-07 12:41:06 +01:00
27eadaeca4 looking better and better 2025-03-06 15:22:21 +01:00
7bd76c1d4a osc: update trackers from osc 2025-03-04 18:59:02 +01:00
62a656cfda feat: add z posish
also osc shit
2025-01-31 15:47:24 +01:00
6e3a97da5f feat: add osc scaffolding 2025-01-10 15:33:19 +01:00
724a423ce8 chore: proper shutdown 2025-01-10 14:49:02 +01:00
2ae20cdac1 Close previous connection 2025-01-10 14:04:09 +01:00
81fb8beece Format everything 2025-01-09 20:44:34 +01:00
44 changed files with 1998 additions and 1041 deletions

View File

@@ -31,7 +31,7 @@ RUN cp /psn-py/psn$(python3-config --extension-suffix) /output
FROM alpine:3.20 AS final FROM alpine:3.20 AS final
RUN apk add --no-cache python3 py3-aiohttp RUN apk add --no-cache python3 py3-aiohttp py3-python-osc
COPY --from=backend /output /backend/psn.so COPY --from=backend /output /backend/psn.so

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,18 +1,69 @@
import asyncio import asyncio
import json import json
import logging import logging
import os
import socket import socket
import time import time
import weakref
from dataclasses import dataclass from dataclasses import dataclass
import psn import psn
from aiohttp import web from aiohttp import WSCloseCode, web
from pythonosc.dispatcher import Dispatcher
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(
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"
NUM_TRACKERS = 3 OSC_SERVER_PORT = int(os.getenv("OSC_SERVER_PORT", 9000))
NUM_TRACKERS = int(os.getenv("NUM_TRACKERS", 3))
class SceneDimensions:
x_min: float
x_max: float
y_min: float
y_max: float
z_min: float
z_max: float
dimension_name: str
dimension_map = {
"scene_only": (-13 / 2, 13 / 2, 0, 6.3, 0, 4),
"full_arena": (-13 / 2, 13 / 2, -9.7, 6.3, 0, 4),
}
def __init__(self):
self.set_scene_only_dimensions()
def _set_new_dimensions(self, x_min, x_max, y_min, y_max, z_min, z_max):
self.x_min = x_min
self.x_max = x_max
self.y_min = y_min
self.y_max = y_max
self.z_min = z_min
self.z_max = z_max
def set_scene_only_dimensions(self):
self._set_new_dimensions(*SceneDimensions.dimension_map["scene_only"])
self.dimension_name = "scene_only"
def set_full_arena_dimensions(self):
self._set_new_dimensions(*SceneDimensions.dimension_map["full_arena"])
self.dimension_name = "full_arena"
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:
return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
# Internal state is a list of TrackerData objects # Internal state is a list of TrackerData objects
@@ -21,23 +72,37 @@ class TrackerData:
id: int id: int
x: float x: float
y: float y: float
z: float
# Convert from internal coordinates to actual scene coordinates @staticmethod
# Internal representation is float values from 0 to 1 for x and y def internal_to_scene_coords_3d(
# Thus we need to know the max size and aspect ratio of the scene x: float, y: float, z: float, dim: SceneDimensions
# x is the width of the scene, y is the depth, z is the height ) -> tuple[float, float, float]:
def pic_to_scene_coords(x, y): """
scene_width = 8.0 Convert internal coordinates to scene coordinates
scene_depth = 6.3
tracker_height = 0.8 + 1.5 # Scene height + height of person
x_val = x * scene_width - (scene_width / 2) Internal coordinates are in the range [0, 1] for x and y with (0,0) at the top left
y_val = y * scene_depth Scene coordinates are in the range [X_MIN, X_MAX] for x and [Y_MIN, YMAX] for y
return x_val, y_val, tracker_height """
x_val = map_range(x, 0, 1, dim.x_min, dim.x_max)
y_val = map_range(y, 0, 1, dim.y_max, dim.y_min) # Invert y axis
z_val = z
def to_tracker(self): return x_val, y_val, z_val
@staticmethod
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)
y_val = map_range(y, dim.y_max, dim.y_min, 0, 1) # Invert y axis
z_val = z
return x_val, y_val, z_val
def to_tracker(self, dim: SceneDimensions) -> psn.Tracker:
tracker = psn.Tracker(self.id, f"Tracker {self.id}") tracker = psn.Tracker(self.id, f"Tracker {self.id}")
x, y, z = self.pic_to_scene_coords(self.x, self.y) x, y, z = TrackerData.internal_to_scene_coords_3d(self.x, self.y, self.z, dim)
tracker.set_pos(psn.Float3(x, y, z)) tracker.set_pos(psn.Float3(x, y, z))
return tracker return tracker
@@ -62,13 +127,25 @@ def get_elapsed_time_ms():
return get_time_ms() - START_TIME return get_time_ms() - START_TIME
async def update_all_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):
for ws in app["ws_clients"]:
await ws.send_str(trackers_to_json(app))
async def update_all_clients_bg(app: web.Application):
for ws in app["ws_clients"]:
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")
@@ -83,34 +160,68 @@ async def handle_websocket(request):
async for msg in ws: async for msg in ws:
if msg.type == web.WSMsgType.TEXT: if msg.type == web.WSMsgType.TEXT:
# Each message is a single tracker object # Each message is a single tracker object
logging.debug(f"Received ws update: {msg.data}")
update_tracker(msg.data, request.app) update_tracker(msg.data, request.app)
await update_all_clients(request.app, ws) await update_all_other_clients(request.app, ws)
elif msg.type == web.WSMsgType.ERROR: elif msg.type == web.WSMsgType.ERROR:
logging.error("ws connection closed with exception %s" % ws.exception()) logging.error("ws connection closed with exception %s" % ws.exception())
print("ws connection closed with exception %s" % ws.exception())
except Exception as e: except Exception as e:
logging.error(f"Websocket exception: {e}") logging.error(f"Websocket exception: {e}")
finally: finally:
logging.debug("Websocket connection closing") logging.debug("Websocket connection closing")
await ws.close() request.app["ws_clients"].discard(ws)
request.app["ws_clients"].remove(ws)
return ws return ws
async def on_shutdown(app):
for ws in set(app["ws_clients"]):
await ws.close(code=WSCloseCode.GOING_AWAY, message="Server shutdown")
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):
if request.app["scene_dimensions"].dimension_name == "full_arena":
return web.FileResponse("./static/scene_and_crowd.png")
elif request.app["scene_dimensions"].dimension_name == "scene_only":
return web.FileResponse("./static/scene_only.png")
return web.Response(text="Incorrect server scende dimension state", status=500)
async def handle_set_mode(request):
try:
data = request.data = await request.json()
mode = data["mode"]
if mode == "full_arena":
request.app["scene_dimensions"].set_full_arena_dimensions()
elif mode == "scene_only":
request.app["scene_dimensions"].set_scene_only_dimensions()
else:
return web.Response(text="Invalid mode", status=400)
await update_all_clients_bg(request.app)
return web.Response(text="OK")
except Exception as e:
return web.Response(text=f"Error: {e}", status=400)
async def handlet_get_mode(request):
return web.json_response({"mode": request.app["scene_dimensions"].dimension_name})
async def broadcast_psn_data(app): async def broadcast_psn_data(app):
encoder = psn.Encoder("Server 1") encoder = psn.Encoder("Server 1")
while True: while True:
trackers = {} trackers = {}
for tracker_data in app["trackers"].values(): for tracker_data in app["trackers"].values():
trackers[tracker_data.id] = tracker_data.to_tracker() trackers[tracker_data.id] = tracker_data.to_tracker(app["scene_dimensions"])
packets = encoder.encode_data(trackers, get_elapsed_time_ms()) packets = encoder.encode_data(trackers, get_elapsed_time_ms())
for packet in packets: for packet in packets:
app["sock"].sendto( app["sock"].sendto(
@@ -126,21 +237,89 @@ async def background_tasks(app: web.Application):
await app["broadcast_psn_data"] await app["broadcast_psn_data"]
def osc_tracker_updater(address, fixed_args, *args) -> None:
app = fixed_args[0]
tracker_id = int(address.split("/")[-1])
x, y, z = TrackerData.scene_to_internal_coords_3d(*args, app["scene_dimensions"])
logging.debug(f"OSC received: id: {tracker_id} at {x}, {y}, {z}")
tracker = TrackerData(tracker_id, x, y, z)
app["trackers"][tracker_id] = tracker
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)
await update_all_clients(request.app)
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]
await update_all_clients(request.app)
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):
dispatcher = Dispatcher()
dispatcher.map("/Tracker*", osc_tracker_updater, app)
server = AsyncIOOSCUDPServer(
(IP, OSC_SERVER_PORT), dispatcher, asyncio.get_event_loop()
)
transport, protocol = await server.create_serve_endpoint()
app["osc_transport"] = transport
yield
await transport.close()
def create_app(): def create_app():
app = web.Application() app = web.Application()
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_post("/mode", handle_set_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
app["ws_clients"] = set() # All app state needs to be mutable as changing state variables directly while running is not supported
app["ws_clients"] = weakref.WeakSet()
app["trackers"] = {} app["trackers"] = {}
app["sock"] = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) app["sock"] = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
app["scene_dimensions"] = SceneDimensions()
for i in range(NUM_TRACKERS): for i in range(NUM_TRACKERS):
app["trackers"][i] = TrackerData(i, 0, 0) app["trackers"][i] = TrackerData(i, *START_POSITION_INTERNAL)
app.on_shutdown.append(on_shutdown)
app.cleanup_ctx.append(background_tasks) app.cleanup_ctx.append(background_tasks)
app.cleanup_ctx.append(receive_osc_data)
return app return app
@@ -148,4 +327,4 @@ def create_app():
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
app = create_app() app = create_app()
web.run_app(app, host=IP, port=PORT) web.run_app(app, host=IP, port=WEB_SERVER_PORT)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

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: 0
volumes:
- spot-data:/data
volumes:
spot-data:

View File

@@ -1,3 +1,7 @@
{ {
"plugins": ["prettier-plugin-astro","prettier-plugin-svelte","prettier-plugin-tailwindcss"] "plugins": [
"prettier-plugin-astro",
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
]
} }

View File

@@ -1,12 +1,17 @@
// @ts-check // @ts-check
// @ts-check // @ts-check
import { defineConfig } from 'astro/config'; import { defineConfig } from "astro/config";
import svelte from '@astrojs/svelte'; import svelte from "@astrojs/svelte";
import tailwind from '@astrojs/tailwind'; 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,
}),
],
}); });

17
frontend/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://next.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",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
}

View File

@@ -6,21 +6,35 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro check && astro build", "build": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"format": "prettier --write .",
"astro": "astro" "astro": "astro"
}, },
"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",
"mode-watcher": "^0.5.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",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^3.0.2",
"tailwind-variants": "^1.0.0",
"tailwindcss-animate": "^1.0.7",
"vaul-svelte": "^0.3.2"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
} }
} }

1817
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

View File

@@ -2,30 +2,41 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
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 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);
let trackers: TrackerData[] = $state([ let trackers: TrackerData[] = $state([]);
{ id: 1, x: 0, y: 0 },
{ id: 2, x: 0, y: 0 },
]);
let width = $state(0); let width = $state(0);
let height = $state(0); let height = $state(0);
let selected = $state(0);
let debounce: number | null = $state(null); let debounce: number | null = $state(null);
const connect = () => { const connect = () => {
if (ws) {
ws.close();
}
ws = new WebSocket("/ws"); ws = new WebSocket("/ws");
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
console.log(data);
if (data.refresh && image) {
image.src = `/background_image?${Math.random()}`;
return;
} else {
for (const tracker of data) { for (const tracker of data) {
tracker.x *= width; tracker.x *= width;
tracker.y *= height; tracker.y *= height;
} }
trackers = data; trackers = data;
}
}; };
}; };
@@ -51,9 +62,13 @@
</script> </script>
<svelte:window onresize={resize} /> <svelte:window onresize={resize} />
<div class="flex h-screen w-screen items-center justify-center gap-2 py-2">
<div class="mr-auto w-20">
<Settings />
</div>
<div class="relative"> <div class="relative">
<img <img
src="/scene_drawing.png" src="/background_image?342038402"
alt="" alt=""
bind:this={image} bind:this={image}
class="max-w-screen max-h-screen" class="max-w-screen max-h-screen"
@@ -65,10 +80,15 @@
bind:id={tracker.id} bind:id={tracker.id}
bind:x={tracker.x} bind:x={tracker.x}
bind:y={tracker.y} bind:y={tracker.y}
bind:z={tracker.z}
bind:width bind:width
bind:height bind:height
bind:selected
{ws} {ws}
/> />
{/each} {/each}
</div> </div>
</div> </div>
<Slider bind:trackers {ws} bind:height bind:width bind:selected />
</div>

View File

@@ -1,20 +1,26 @@
<script lang="ts"> <script lang="ts">
import { cn } from "$lib/utils";
type Props = { type Props = {
id: number; id: number;
x: number; x: number;
y: number; y: number;
z: number;
width: number; width: number;
height: number; height: number;
ws: WebSocket | null; ws: WebSocket | null;
selected: number;
}; };
let { let {
id = $bindable(), id = $bindable(),
x = $bindable(), x = $bindable(),
y = $bindable(), y = $bindable(),
z = $bindable(),
ws = $bindable(), ws = $bindable(),
width = $bindable(), width = $bindable(),
height = $bindable(), height = $bindable(),
selected = $bindable(),
}: Props = $props(); }: Props = $props();
let element: HTMLElement; let element: HTMLElement;
@@ -33,6 +39,7 @@
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
element!.setPointerCapture(e.pointerId); element!.setPointerCapture(e.pointerId);
selected = id;
capturedPointerId = e.pointerId; capturedPointerId = e.pointerId;
} }
function onPointerUp(e: PointerEvent) { function onPointerUp(e: PointerEvent) {
@@ -58,6 +65,7 @@
id: id, id: id,
x: x_send, x: x_send,
y: y_send, y: y_send,
z: z,
}), }),
); );
} }
@@ -69,7 +77,12 @@
onpointerup={onPointerUp} onpointerup={onPointerUp}
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-32 w-32 touch-none select-none items-center justify-center rounded-full border-red-400 bg-red-400" class={cn(
"absolute flex h-24 w-24 touch-none select-none items-center justify-center rounded-full",
selected === id
? "z-50 border-green-400 bg-green-400"
: "border-red-400 bg-red-400",
)}
> >
Tracker {id} Tracker {id}
</div> </div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
type Props = {
action: () => void;
};
let { action }: Props = $props();
let dialog: HTMLDialogElement;
const openModal = () => {
dialog.showModal();
};
</script>
<button class="mb-24 rounded-md bg-red-500 p-2" onclick={openModal}>
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-center 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-500 p-4"
onclick={() => {
action();
dialog.close();
}}>Change mode</button
>
</div>
</div>
</dialog>

View File

@@ -0,0 +1,59 @@
<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";
import { toast } from "svelte-sonner";
const addTracker = async (arg: number) => {
const response = await fetch("/tracker", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: Number(arg),
}),
});
if (!response.ok) {
toast.error("Could not add tracker");
}
};
const deleteTracker = async (arg: number) => {
const response = await fetch("/tracker", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id: Number(arg),
}),
});
if (!response.ok) {
toast.error("Could not delete tracker");
}
};
let id = $state(0);
</script>
<Drawer.Root>
<Drawer.Trigger asChild let:builder>
<Button builders={[builder]} variant="secondary">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 bind:value={id} action={addTracker} text="Add Tracker" />
<TrackerSetting
bind:value={id}
action={deleteTracker}
text="Delete Tracker"
/>
</div>
</Drawer.Content>
</Drawer.Root>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import type { TrackerData } from "$lib/utils/types";
import Modal from "./Modal.svelte";
type Props = {
selected: number;
trackers: TrackerData[];
ws: WebSocket | null;
width: number;
height: number;
};
let {
selected = $bindable(),
trackers = $bindable(),
ws,
width = $bindable(),
height = $bindable(),
}: Props = $props();
let arena_state: "full_arena" | "scene_only" = $state("scene_only");
let tracker = $state<TrackerData | undefined>(undefined);
$effect(() => {
tracker = trackers.find((ball) => ball.id === selected);
});
const onchange = () => {
const x_send = tracker!.x / width;
const y_send = tracker!.y / height;
ws?.send(
JSON.stringify({
id: tracker!.id,
x: x_send,
y: y_send,
z: tracker!.z,
}),
);
};
type arenaResponse = {
mode: "full_arena" | "scene_only";
};
const buttonAction = async () => {
await fetch("/mode")
.then((res) => res.json())
.then((data: arenaResponse) => {
arena_state = data.mode;
});
await fetch("/mode", {
method: "POST",
body: JSON.stringify({
mode: arena_state === "full_arena" ? "scene_only" : "full_arena",
}),
});
};
let z_viz = $derived(tracker?.z.toFixed(2));
</script>
<div class="slider-container ml-auto w-20">
<Modal action={buttonAction} />
{#if tracker}
<input
type="range"
id="z"
min="0"
max="4"
step="0.01"
oninput={onchange}
bind:value={tracker.z}
/>
<output for="z">{z_viz} m</output>
{/if}
</div>
<style>
.slider-container {
display: flex;
flex-direction: column;
align-items: center;
}
input {
writing-mode: vertical-lr;
direction: rtl;
appearance: none;
width: 120px;
height: 50%;
vertical-align: bottom;
}
/* WebKit Browsers (Chrome, Safari, Edge) */
input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 50px; /* Larger size */
height: 50px;
background: red; /* Customize color */
margin-left: -22px; /* Center the thumb */
border-radius: 50%;
cursor: pointer;
}
input::-webkit-slider-runnable-track {
background: lightgray; /* Customize track */
width: 6px;
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import Button from "./ui/button/button.svelte";
import Input from "./ui/input/input.svelte";
type Props = {
text: string;
action: (arg: number) => void;
value: number;
};
let { action, text, value = $bindable() }: Props = $props();
</script>
<div>
<h2>{text}</h2>
<Input placeholder={"1"} bind:value required />
<Button onclick={() => action(value)}>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,43 @@
<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(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
bind:value
{readonly}
type="tel"
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

@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let restProps: SonnerProps = $props();
</script>
<Sonner
theme={$mode}
class="toaster group"
toastOptions={{
classes: {
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...restProps}
/>

View File

@@ -1,19 +1,22 @@
--- ---
import Container from "../components/Container.svelte"; import Container from "../components/Container.svelte";
import "$lib/styles/app.css";
import { Toaster } from "$lib/components/ui/sonner/index.js";
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
/> />
<title>Astro</title> <title>Followspot</title>
</head> </head>
<body class="flex h-dvh w-screen items-center justify-center overflow-hidden"> <body class="flex h-dvh w-screen items-center justify-center overflow-hidden">
<Toaster client:load />
<Container client:load /> <Container client:load />
</body> </body>
</html> </html>

View File

@@ -0,0 +1,76 @@
@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;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.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%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

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

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -2,4 +2,5 @@ export interface TrackerData {
id: number; id: number;
x: number; x: number;
y: number; y: number;
z: number;
} }

View File

@@ -1,5 +1,5 @@
import { vitePreprocess } from '@astrojs/svelte'; import { vitePreprocess } from "@astrojs/svelte";
export default { export default {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
} };

View File

@@ -1,8 +1,96 @@
import { fontFamily } from "tailwindcss/defaultTheme";
import tailwindcssAnimate from "tailwindcss-animate";
/** @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,astro}"],
safelist: ["dark"],
theme: { theme: {
extend: {}, container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
}, },
plugins: [], },
} 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>)",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: [...fontFamily.sans],
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--bits-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--bits-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
},
plugins: [tailwindcssAnimate],
};
export default config;

View File

@@ -7,11 +7,6 @@
"$lib/*": ["./src/*"] "$lib/*": ["./src/*"]
} }
}, },
"include": [ "include": [".astro/types.d.ts", "**/*"],
".astro/types.d.ts", "exclude": ["dist"]
"**/*"
],
"exclude": [
"dist"
]
} }