Compare commits
17 Commits
7498d9066e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
349495019f
|
|||
|
bb31650718
|
|||
|
ce9ed1f950
|
|||
| f314b960e1 | |||
|
4f97e26295
|
|||
|
a2fe2780cf
|
|||
|
3c16148bb7
|
|||
|
9a8e97885c
|
|||
|
46feef3a48
|
|||
| 6e7dd7a560 | |||
| 7a347ed246 | |||
|
4f5b301e7d
|
|||
|
8fedbc34fa
|
|||
| 47d4c7926e | |||
|
3fe9ecbbb8
|
|||
|
7435e91b07
|
|||
|
e0b685bfda
|
19
README.md
19
README.md
@@ -1,15 +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.
|
||||
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.
|
||||
> 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.
|
||||
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).
|
||||
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.
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
|
||||
import psn
|
||||
from pythonosc.osc_server import AsyncIOOSCUDPServer
|
||||
from aiohttp import WSCloseCode, web
|
||||
from pythonosc.dispatcher import Dispatcher
|
||||
from aiohttp import web, WSCloseCode
|
||||
from pythonosc.osc_server import AsyncIOOSCUDPServer
|
||||
|
||||
PSN_DEFAULT_UDP_PORT = 56565
|
||||
PSN_DEFAULT_UDP_MCAST_ADDRESS = "236.10.10.10"
|
||||
WEB_SERVER_PORT = 8000
|
||||
PSN_DEFAULT_UDP_PORT = os.getenv("PSN_DEFAULT_UDP_PORT", 56565)
|
||||
PSN_DEFAULT_UDP_MCAST_ADDRESS = os.getenv(
|
||||
"PSN_DEFAULT_UDP_MCAST_ADDRESS", "236.10.10.10"
|
||||
)
|
||||
WEB_SERVER_PORT = int(os.getenv("WEB_SERVER_PORT", 8000))
|
||||
IP = "0.0.0.0"
|
||||
OSC_SERVER_PORT = 9000
|
||||
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
|
||||
@@ -30,7 +34,7 @@ class SceneDimensions:
|
||||
|
||||
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)
|
||||
"full_arena": (-13 / 2, 13 / 2, -9.7, 6.3, 0, 4),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
@@ -53,10 +57,12 @@ class SceneDimensions:
|
||||
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:
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -69,7 +75,9 @@ class TrackerData:
|
||||
z: float
|
||||
|
||||
@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
|
||||
|
||||
@@ -83,7 +91,9 @@ class TrackerData:
|
||||
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]:
|
||||
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
|
||||
@@ -117,20 +127,25 @@ def get_elapsed_time_ms():
|
||||
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"]:
|
||||
if ws_send == ws:
|
||||
continue
|
||||
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):
|
||||
ws = web.WebSocketResponse()
|
||||
logging.debug("Websocket connection starting")
|
||||
@@ -161,6 +176,7 @@ async def handle_websocket(request):
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
async def on_shutdown(app):
|
||||
for ws in set(app["ws_clients"]):
|
||||
await ws.close(code=WSCloseCode.GOING_AWAY, message="Server shutdown")
|
||||
@@ -169,6 +185,7 @@ async def on_shutdown(app):
|
||||
async def handle_root(request):
|
||||
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")
|
||||
@@ -176,6 +193,7 @@ async def handle_background_image(request):
|
||||
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()
|
||||
@@ -193,6 +211,7 @@ async def handle_set_mode(request):
|
||||
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})
|
||||
|
||||
@@ -228,11 +247,46 @@ def osc_tracker_updater(address, fixed_args, *args) -> None:
|
||||
|
||||
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())
|
||||
server = AsyncIOOSCUDPServer(
|
||||
(IP, OSC_SERVER_PORT), dispatcher, asyncio.get_event_loop()
|
||||
)
|
||||
transport, protocol = await server.create_serve_endpoint()
|
||||
app["osc_transport"] = transport
|
||||
yield
|
||||
@@ -244,8 +298,13 @@ def create_app():
|
||||
app.router.add_get("/", handle_root)
|
||||
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")
|
||||
|
||||
# Setup app state
|
||||
|
||||
11
compose.yaml
11
compose.yaml
@@ -2,3 +2,14 @@ services:
|
||||
followspot-psn:
|
||||
build: .
|
||||
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:
|
||||
|
||||
@@ -8,5 +8,10 @@ import tailwind from "@astrojs/tailwind";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [svelte(), tailwind()],
|
||||
integrations: [
|
||||
svelte(),
|
||||
tailwind({
|
||||
applyBaseStyles: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
17
frontend/components.json
Normal file
17
frontend/components.json
Normal 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"
|
||||
}
|
||||
@@ -11,17 +11,30 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/svelte": "^7.0.3",
|
||||
"@astrojs/tailwind": "^5.1.4",
|
||||
"astro": "^5.1.4",
|
||||
"svelte": "^5.17.2",
|
||||
"@astrojs/svelte": "^7.0.5",
|
||||
"@astrojs/tailwind": "^5.1.5",
|
||||
"astro": "^5.4.2",
|
||||
"svelte": "^5.22.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"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-svelte": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9"
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
5370
frontend/pnpm-lock.yaml
generated
5370
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,12 @@
|
||||
import type { TrackerData } from "../utils/types";
|
||||
import Drag from "./Drag.svelte";
|
||||
import Slider from "./Slider.svelte";
|
||||
import Settings from "./Settings.svelte";
|
||||
|
||||
let ws: WebSocket | null = $state(null);
|
||||
let image: HTMLImageElement | null = $state(null);
|
||||
|
||||
let trackers: TrackerData[] = $state([
|
||||
{ id: 1, x: 0, y: 0, z: 0 },
|
||||
{ id: 2, x: 0, y: 0, z: 0 },
|
||||
]);
|
||||
let trackers: TrackerData[] = $state([]);
|
||||
|
||||
let width = $state(0);
|
||||
let height = $state(0);
|
||||
@@ -27,14 +25,12 @@
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(data)
|
||||
console.log(data);
|
||||
|
||||
if (data.refresh && image) {
|
||||
image.src = `/background_image?${Math.random()}`;
|
||||
return;
|
||||
}
|
||||
|
||||
else {
|
||||
} else {
|
||||
for (const tracker of data) {
|
||||
tracker.x *= width;
|
||||
tracker.y *= height;
|
||||
@@ -63,13 +59,13 @@
|
||||
width = image?.getBoundingClientRect().width ?? 0;
|
||||
height = image?.getBoundingClientRect().height ?? 0;
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={resize} />
|
||||
<div class="flex h-screen items-center justify-center w-screen">
|
||||
<div class="w-20 mr-auto"></div>
|
||||
<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">
|
||||
<img
|
||||
src="/background_image?342038402"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
x: number;
|
||||
@@ -75,8 +77,12 @@
|
||||
onpointerup={onPointerUp}
|
||||
onpointermove={onPointerMove}
|
||||
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
|
||||
${selected === id ? 'bg-green-400 border-green-400' : 'bg-red-400 border-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}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<button class="mb-24 rounded-md bg-red-400 p-2" onclick={openModal}>
|
||||
<button class="mb-24 rounded-md bg-red-500 p-2" onclick={openModal}>
|
||||
Change mode
|
||||
</button>
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
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-red-600 text-8xl">THIS GONNA FUCK THINGS UP</h1>
|
||||
<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-400 p-4"
|
||||
class="rounded-md bg-red-500 p-4"
|
||||
onclick={() => {
|
||||
action();
|
||||
dialog.close();
|
||||
|
||||
59
frontend/src/components/Settings.svelte
Normal file
59
frontend/src/components/Settings.svelte
Normal 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>
|
||||
@@ -20,15 +20,21 @@
|
||||
|
||||
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 = trackers[selected].x / width;
|
||||
const y_send = trackers[selected].y / height;
|
||||
const x_send = tracker!.x / width;
|
||||
const y_send = tracker!.y / height;
|
||||
ws?.send(
|
||||
JSON.stringify({
|
||||
id: trackers[selected].id,
|
||||
id: tracker!.id,
|
||||
x: x_send,
|
||||
y: y_send,
|
||||
z: trackers[selected].z,
|
||||
z: tracker!.z,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -52,11 +58,12 @@
|
||||
});
|
||||
};
|
||||
|
||||
let z_viz = $derived(trackers[selected].z.toFixed(2));
|
||||
let z_viz = $derived(tracker?.z.toFixed(2));
|
||||
</script>
|
||||
|
||||
<div class="slider-container ml-auto">
|
||||
<div class="slider-container ml-auto w-20">
|
||||
<Modal action={buttonAction} />
|
||||
{#if tracker}
|
||||
<input
|
||||
type="range"
|
||||
id="z"
|
||||
@@ -64,9 +71,10 @@
|
||||
max="4"
|
||||
step="0.01"
|
||||
oninput={onchange}
|
||||
bind:value={trackers[selected].z}
|
||||
bind:value={tracker.z}
|
||||
/>
|
||||
<output for="z">{z_viz} m</output>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
18
frontend/src/components/TrackerSetting.svelte
Normal file
18
frontend/src/components/TrackerSetting.svelte
Normal 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>
|
||||
25
frontend/src/components/ui/button/button.svelte
Normal file
25
frontend/src/components/ui/button/button.svelte
Normal 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>
|
||||
49
frontend/src/components/ui/button/index.ts
Normal file
49
frontend/src/components/ui/button/index.ts
Normal 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,
|
||||
};
|
||||
24
frontend/src/components/ui/drawer/drawer-content.svelte
Normal file
24
frontend/src/components/ui/drawer/drawer-content.svelte
Normal 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>
|
||||
18
frontend/src/components/ui/drawer/drawer-description.svelte
Normal file
18
frontend/src/components/ui/drawer/drawer-description.svelte
Normal 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>
|
||||
16
frontend/src/components/ui/drawer/drawer-footer.svelte
Normal file
16
frontend/src/components/ui/drawer/drawer-footer.svelte
Normal 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>
|
||||
19
frontend/src/components/ui/drawer/drawer-header.svelte
Normal file
19
frontend/src/components/ui/drawer/drawer-header.svelte
Normal 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>
|
||||
12
frontend/src/components/ui/drawer/drawer-nested.svelte
Normal file
12
frontend/src/components/ui/drawer/drawer-nested.svelte
Normal 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>
|
||||
18
frontend/src/components/ui/drawer/drawer-overlay.svelte
Normal file
18
frontend/src/components/ui/drawer/drawer-overlay.svelte
Normal 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>
|
||||
18
frontend/src/components/ui/drawer/drawer-title.svelte
Normal file
18
frontend/src/components/ui/drawer/drawer-title.svelte
Normal 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>
|
||||
12
frontend/src/components/ui/drawer/drawer.svelte
Normal file
12
frontend/src/components/ui/drawer/drawer.svelte
Normal 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>
|
||||
41
frontend/src/components/ui/drawer/index.ts
Normal file
41
frontend/src/components/ui/drawer/index.ts
Normal 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,
|
||||
};
|
||||
29
frontend/src/components/ui/input/index.ts
Normal file
29
frontend/src/components/ui/input/index.ts
Normal 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,
|
||||
};
|
||||
43
frontend/src/components/ui/input/input.svelte
Normal file
43
frontend/src/components/ui/input/input.svelte
Normal 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}
|
||||
/>
|
||||
1
frontend/src/components/ui/sonner/index.ts
Normal file
1
frontend/src/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
||||
20
frontend/src/components/ui/sonner/sonner.svelte
Normal file
20
frontend/src/components/ui/sonner/sonner.svelte
Normal 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}
|
||||
/>
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
import Container from "../components/Container.svelte";
|
||||
import "$lib/styles/app.css";
|
||||
import { Toaster } from "$lib/components/ui/sonner/index.js";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
@@ -14,6 +16,7 @@ import Container from "../components/Container.svelte";
|
||||
<title>Followspot</title>
|
||||
</head>
|
||||
<body class="flex h-dvh w-screen items-center justify-center overflow-hidden">
|
||||
<Toaster client:load />
|
||||
<Container client:load />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
76
frontend/src/styles/app.css
Normal file
76
frontend/src/styles/app.css
Normal 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
6
frontend/src/utils.ts
Normal 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));
|
||||
}
|
||||
@@ -1,8 +1,96 @@
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{html,js,svelte,ts,astro}"],
|
||||
safelist: ["dark"],
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user