This commit is contained in:
2025-10-06 18:27:50 +02:00
commit 3e191a4f60
213 changed files with 22261 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export const BASE_URL = window.location.origin;
+26
View File
@@ -0,0 +1,26 @@
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { authStore } from '../stores/auth';
export async function authenticatedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const authState = get(authStore);
const headers = new Headers(init?.headers);
if (authState.token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${authState.token}`);
}
const updatedInit = { ...init, headers };
const response = await fetch(input, updatedInit);
if (response.status === 401) {
authStore.logout();
goto('/login');
}
return response;
}
+402
View File
@@ -0,0 +1,402 @@
import {
GetSettingsResponse,
SettingsData,
UpdateSettingsRequest,
UpdateSettingsResponse
} from '../gen/settings';
import { HardwareConfig, SensorOTAEnable } from '../gen/hardware';
import {
AddUserRequest,
AddUserResponse,
DeleteUserRequest,
DeleteUserResponse,
GetUsersResponse,
UpdateUserPasswordRequest,
UpdateUserPasswordResponse,
WebUiAuthCheckResponse,
WebUiLoginRequest,
WebUiLoginResponse
} from '../gen/webui';
import { DeviceStatus } from '../gen/device';
import { BASE_URL } from './constants';
import { authenticatedFetch } from './fetch';
export class WebUIApi {
private baseUrl: string;
private username: string | null = null;
constructor(baseUrl: string = BASE_URL) {
this.baseUrl = baseUrl;
}
async login(username: string, password: string): Promise<WebUiLoginResponse> {
const request = WebUiLoginRequest.create({ username, password });
const requestData = WebUiLoginRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const loginResponse = WebUiLoginResponse.decode(new Uint8Array(responseData));
if (loginResponse.token) {
this.username = username;
}
return loginResponse;
}
async authchk(): Promise<WebUiAuthCheckResponse> {
const response = await authenticatedFetch(`${this.baseUrl}/api/authchk`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const authResponse = WebUiAuthCheckResponse.decode(new Uint8Array(responseData));
if (authResponse.authenticated) {
this.username = authResponse.username || null;
}
return authResponse;
}
async getStatus(): Promise<DeviceStatus> {
const response = await authenticatedFetch(`${this.baseUrl}/api/status`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const statusResponse = DeviceStatus.decode(new Uint8Array(responseData));
return statusResponse;
}
async getSettings(): Promise<GetSettingsResponse> {
const response = await authenticatedFetch(`${this.baseUrl}/api/settings`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const settingsResponse = GetSettingsResponse.decode(new Uint8Array(responseData));
return settingsResponse;
}
async updateSettings(settings: SettingsData): Promise<UpdateSettingsResponse> {
const request = UpdateSettingsRequest.create({ settings });
const requestData = UpdateSettingsRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const updateResponse = UpdateSettingsResponse.decode(new Uint8Array(responseData));
return updateResponse;
}
async getHardware(): Promise<HardwareConfig> {
const response = await authenticatedFetch(`${this.baseUrl}/api/hardware`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const hardwareConfig = HardwareConfig.decode(new Uint8Array(responseData));
return hardwareConfig;
}
async updateHardware(config: HardwareConfig): Promise<void> {
const requestData = HardwareConfig.encode(config).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/hardware`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
async getUsers(): Promise<GetUsersResponse> {
const response = await authenticatedFetch(`${this.baseUrl}/api/users`, {
method: 'GET',
headers: {
'Content-Type': 'application/octet-stream'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const usersResponse = GetUsersResponse.decode(new Uint8Array(responseData));
return usersResponse;
}
async addUser(username: string, password: string): Promise<AddUserResponse> {
const request = AddUserRequest.create({ username, password });
const requestData = AddUserRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const addResponse = AddUserResponse.decode(new Uint8Array(responseData));
return addResponse;
}
async deleteUser(username: string): Promise<DeleteUserResponse> {
const request = DeleteUserRequest.create({ username });
const requestData = DeleteUserRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/users`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const deleteResponse = DeleteUserResponse.decode(new Uint8Array(responseData));
return deleteResponse;
}
async updateUserPassword(
username: string,
newPassword: string
): Promise<UpdateUserPasswordResponse> {
const request = UpdateUserPasswordRequest.create({ username, newPassword });
const requestData = UpdateUserPasswordRequest.encode(request).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/users/password`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseData = await response.arrayBuffer();
const updateResponse = UpdateUserPasswordResponse.decode(new Uint8Array(responseData));
return updateResponse;
}
get getUsername(): string | null {
return this.username;
}
async getRfidDatabase(): Promise<Uint32Array> {
const response = await authenticatedFetch(`${this.baseUrl}/api/db`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const binaryData = await response.arrayBuffer();
const dataView = new DataView(binaryData);
// Read uint32 values as big endian
const rfidIds = new Uint32Array(binaryData.byteLength / 4);
for (let i = 0; i < rfidIds.length; i++) {
rfidIds[i] = dataView.getUint32(i * 4, false); // false = big endian
}
return rfidIds;
}
async updateRfidDatabase(rfidIds: Uint32Array): Promise<void> {
// Sort the RFID IDs
const sortedIds = new Uint32Array(rfidIds);
sortedIds.sort((a, b) => a - b);
// Create a buffer and write values in big endian format
const buffer = new ArrayBuffer(sortedIds.length * 4);
const dataView = new DataView(buffer);
for (let i = 0; i < sortedIds.length; i++) {
dataView.setUint32(i * 4, sortedIds[i], false); // false = big endian
}
const response = await authenticatedFetch(`${this.baseUrl}/api/db`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: buffer
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
async sync(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/sync`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async fullSync(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/sync/full`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async enableOTA(otaConfig: SensorOTAEnable): Promise<string> {
const requestData = SensorOTAEnable.encode(otaConfig).finish();
const response = await authenticatedFetch(`${this.baseUrl}/api/ota`, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream'
},
body: requestData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async restartSensor(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/restart-sensor`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async enableESPOTA(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-enable`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async disableESPOTA(): Promise<string> {
const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-disable`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
async getESPOTAStatus(): Promise<{ enabled: boolean; status: string }> {
const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-status`, {
method: 'GET'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
async uploadESPOTAFirmware(file: File): Promise<string> {
const formData = new FormData();
formData.append('firmware', file);
const response = await authenticatedFetch(`${this.baseUrl}/api/esp-ota-upload`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
}
export const webUIApi = new WebUIApi();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,88 @@
<script lang="ts">
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Label } from '$lib/components/ui/label';
import type { DeviceInfo } from '$lib/gen/device';
export let deviceInfo: DeviceInfo | undefined;
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return `${days}d ${hours}h ${mins}m`;
}
function getWifiStatus(state: number): string {
switch (state) {
case 0:
return 'Disconnected';
case 1:
return 'Connected';
case 2:
return 'Connecting';
case 3:
return 'Failed';
default:
return 'Unknown';
}
}
</script>
<Card>
<CardHeader>
<CardTitle>Device Information</CardTitle>
<CardDescription>Read-only information about this device</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if deviceInfo}
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-sm font-medium text-gray-700">Device ID</Label>
<p class="text-sm text-gray-900">{deviceInfo.deviceId || 'N/A'}</p>
</div>
<div>
<Label class="text-sm font-medium text-gray-700">Firmware Version</Label>
<p class="text-sm text-gray-900">{deviceInfo.firmwareVersion || 'N/A'}</p>
</div>
<div>
<Label class="text-sm font-medium text-gray-700">Hardware Version</Label>
<p class="text-sm text-gray-900">{deviceInfo.hardwareVersion || 'N/A'}</p>
</div>
<div>
<Label class="text-sm font-medium text-gray-700">Uptime</Label>
<p class="text-sm text-gray-900">{formatUptime(deviceInfo.uptimeSeconds)}</p>
</div>
</div>
<div class="mt-4">
<h4 class="mb-2 text-sm font-medium text-gray-700">WiFi Status</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<Label class="text-sm font-medium text-gray-700">Station</Label>
<p class="text-sm text-gray-900">{getWifiStatus(deviceInfo.staConnectionState)}</p>
{#if deviceInfo.staConnectionState === 1}
<p class="text-xs text-gray-500">IP: {deviceInfo.staIp}</p>
<p class="text-xs text-gray-500">Signal: {deviceInfo.staSignalStrength} dBm</p>
{/if}
</div>
<div>
<Label class="text-sm font-medium text-gray-700">Access Point</Label>
<p class="text-sm text-gray-900">{getWifiStatus(deviceInfo.apConnectionState)}</p>
{#if deviceInfo.apConnectionState === 1}
<p class="text-xs text-gray-500">IP: {deviceInfo.apIp}</p>
<p class="text-xs text-gray-500">Clients: {deviceInfo.apClientCount}</p>
{/if}
</div>
</div>
</div>
{:else}
<p class="text-sm text-gray-500">Loading device information...</p>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,369 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { LedConfig } from '$lib/gen/hardware';
import LedConfigDialog from './LedConfigDialog.svelte';
interface Props {
holdDurationMs: number;
override: boolean;
relayPin: number;
enableSerialSensor: boolean;
sensorRxPin: number;
sensorTxPin: number;
onOpenLed?: LedConfig;
defaultLed?: LedConfig;
onInvalidLed?: LedConfig;
onOverrideOpenLed?: LedConfig;
onInterceptionLed?: LedConfig;
}
let {
holdDurationMs = $bindable(5000),
override = $bindable(false),
relayPin = $bindable(12),
enableSerialSensor = $bindable(true),
sensorRxPin = $bindable(16),
sensorTxPin = $bindable(17),
onOpenLed = $bindable(),
defaultLed = $bindable(),
onInvalidLed = $bindable(),
onOverrideOpenLed = $bindable(),
onInterceptionLed = $bindable()
}: Props = $props();
// Dialog state
let dialogOpen = $state(false);
let currentLedType = $state<
'onOpen' | 'default' | 'onInvalid' | 'onOverrideOpen' | 'onInterception'
>('onOpen');
let currentLedConfig = $state<LedConfig | undefined>();
function openDialog(
type: 'onOpen' | 'default' | 'onInvalid' | 'onOverrideOpen' | 'onInterception'
) {
currentLedType = type;
currentLedConfig =
type === 'onOpen'
? onOpenLed
: type === 'default'
? defaultLed
: type === 'onInvalid'
? onInvalidLed
: type === 'onOverrideOpen'
? onOverrideOpenLed
: onInterceptionLed;
dialogOpen = true;
}
function saveLedConfig(config: LedConfig) {
if (currentLedType === 'onOpen') {
onOpenLed = config;
} else if (currentLedType === 'default') {
defaultLed = config;
} else if (currentLedType === 'onInvalid') {
onInvalidLed = config;
} else if (currentLedType === 'onOverrideOpen') {
onOverrideOpenLed = config;
} else {
onInterceptionLed = config;
}
dialogOpen = false;
}
function removeLedConfig(
type: 'onOpen' | 'default' | 'onInvalid' | 'onOverrideOpen' | 'onInterception'
) {
if (type === 'onOpen') {
onOpenLed = undefined;
} else if (type === 'default') {
defaultLed = undefined;
} else if (type === 'onInvalid') {
onInvalidLed = undefined;
} else if (type === 'onOverrideOpen') {
onOverrideOpenLed = undefined;
} else {
onInterceptionLed = undefined;
}
}
// Helper to get animation name
function getAnimationName(animation: string): string {
switch (animation) {
case 'static':
return 'Static';
case 'pulse':
return 'Pulse';
case 'fade':
return 'Fade';
case 'flicker':
return 'Flicker';
default:
return 'Unknown';
}
}
// Helper to get current animation
function getCurrentAnimation(config?: LedConfig): string {
if (!config) return 'static';
if (config.staticParams) return 'static';
if (config.pulseParams) return 'pulse';
if (config.fadeParams) return 'fade';
if (config.flickerParams) return 'flicker';
return 'static';
}
</script>
<div class="space-y-8">
<!-- Door Relay Section -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span>🔒</span>
Door Relay
</CardTitle>
<CardDescription>
Configure the door lock mechanism. The relay controls the door opening duration and which
GPIO pin is used.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="hold-duration">Hold Duration (ms)</Label>
<Input id="hold-duration" type="number" bind:value={holdDurationMs} placeholder="5000" />
<p class="mt-1 text-xs text-gray-500">
How long the door stays unlocked after valid RFID scan
</p>
</div>
<div>
<Label for="relay-pin">Relay Pin</Label>
<Input id="relay-pin" type="number" bind:value={relayPin} placeholder="12" />
<p class="mt-1 text-xs text-gray-500">GPIO pin connected to the door relay</p>
</div>
</div>
</CardContent>
</Card>
<!-- Sensor Unit Section -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span>📡</span>
Sensor Unit (RFID Reader)
</CardTitle>
<CardDescription>
The RFID reader unit with integrated RGB LED. Communication happens via serial connection
(UART). The reader scans RFID cards and communicates card data to the main controller.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center space-x-2">
<Switch id="enable-serial-sensor" bind:checked={enableSerialSensor} />
<Label for="enable-serial-sensor">Enable Serial Sensor</Label>
</div>
<p class="mt-2 text-xs text-gray-500">Enable or disable the serial sensor unit.</p>
{#if enableSerialSensor}
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="sensor-rx">Sensor RX Pin</Label>
<Input id="sensor-rx" type="number" bind:value={sensorRxPin} placeholder="16" />
<p class="mt-1 text-xs text-gray-500">
UART RX pin for receiving data from RFID reader
</p>
</div>
<div>
<Label for="sensor-tx">Sensor TX Pin</Label>
<Input id="sensor-tx" type="number" bind:value={sensorTxPin} placeholder="17" />
<p class="mt-1 text-xs text-gray-500">
UART TX pin for sending commands to RFID reader
</p>
</div>
</div>
{/if}
</CardContent>
</Card>
<!-- Permanent Unlock Section -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span>🔓</span>
Permanent Unlock
</CardTitle>
<CardDescription>
Override mode allows keeping the door permanently unlocked. Use with caution - this bypasses
all access control.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex items-center space-x-2">
<Switch id="override" bind:checked={override} />
<Label for="override">Override Mode</Label>
</div>
<p class="mt-2 text-xs text-gray-500">
When enabled, the door relay stays activated permanently, ignoring RFID access control.
</p>
</CardContent>
</Card>
<!-- LED Configuration Section -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<span>💡</span>
LED Configuration
</CardTitle>
<CardDescription>
Configure RGB LED animations for different access states. Each LED can have custom colors,
brightness, and animation patterns.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- On Open LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">On Open LED</p>
<p class="text-sm text-gray-600">
{onOpenLed
? `Animation: ${getAnimationName(getCurrentAnimation(onOpenLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Shows when door is unlocked after valid RFID scan</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('onOpen')}>
{onOpenLed ? 'Update' : 'Set'}
</Button>
{#if onOpenLed}
<Button size="sm" variant="destructive" onclick={() => removeLedConfig('onOpen')}>
Remove
</Button>
{/if}
</div>
</div>
<!-- Default LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">Default LED</p>
<p class="text-sm text-gray-600">
{defaultLed
? `Animation: ${getAnimationName(getCurrentAnimation(defaultLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Default idle state when system is ready</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('default')}>
{defaultLed ? 'Update' : 'Set'}
</Button>
{#if defaultLed}
<Button size="sm" variant="destructive" onclick={() => removeLedConfig('default')}>
Remove
</Button>
{/if}
</div>
</div>
<!-- On Invalid LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">On Invalid LED</p>
<p class="text-sm text-gray-600">
{onInvalidLed
? `Animation: ${getAnimationName(getCurrentAnimation(onInvalidLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Shows when RFID scan is rejected</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('onInvalid')}>
{onInvalidLed ? 'Update' : 'Set'}
</Button>
{#if onInvalidLed}
<Button size="sm" variant="destructive" onclick={() => removeLedConfig('onInvalid')}>
Remove
</Button>
{/if}
</div>
</div>
<!-- On Override Open LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">On Override Open LED</p>
<p class="text-sm text-gray-600">
{onOverrideOpenLed
? `Animation: ${getAnimationName(getCurrentAnimation(onOverrideOpenLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Shows when door is in permanent unlock mode</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('onOverrideOpen')}>
{onOverrideOpenLed ? 'Update' : 'Set'}
</Button>
{#if onOverrideOpenLed}
<Button
size="sm"
variant="destructive"
onclick={() => removeLedConfig('onOverrideOpen')}
>
Remove
</Button>
{/if}
</div>
</div>
<!-- On Interception LED -->
<div class="flex items-center justify-between rounded border p-4">
<div>
<p class="font-medium">On Interception LED</p>
<p class="text-sm text-gray-600">
{onInterceptionLed
? `Animation: ${getAnimationName(getCurrentAnimation(onInterceptionLed))}`
: 'Not configured'}
</p>
<p class="text-xs text-gray-500">Shows when RFID card is read to the ui</p>
</div>
<div class="flex gap-2">
<Button size="sm" onclick={() => openDialog('onInterception')}>
{onInterceptionLed ? 'Update' : 'Set'}
</Button>
{#if onInterceptionLed}
<Button
size="sm"
variant="destructive"
onclick={() => removeLedConfig('onInterception')}
>
Remove
</Button>
{/if}
</div>
</div>
</CardContent>
</Card>
</div>
<!-- LED Config Dialog -->
<LedConfigDialog
bind:open={dialogOpen}
ledType={currentLedType}
config={currentLedConfig || {
brightness: 255,
durationMs: 0,
staticParams: { color: 0xffffff }
}}
onSave={saveLedConfig}
onCancel={() => (dialogOpen = false)}
/>
@@ -0,0 +1,271 @@
<script lang="ts">
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle
} from '$lib/components/ui/alert-dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import ColorPicker from '$lib/components/ui/color-picker.svelte';
import { LedConfig } from '$lib/gen/hardware';
interface Props {
open: boolean;
ledType: 'onOpen' | 'default' | 'onInvalid' | 'onOverrideOpen' | 'onInterception';
config: LedConfig;
onSave: (config: LedConfig) => void;
onCancel: () => void;
}
let {
open = $bindable(false),
ledType,
config = $bindable(),
onSave,
onCancel
}: Props = $props();
// Temp config for editing
let tempConfig = $state<LedConfig>({ ...config });
let selectedAnimation = $state('static');
// Color bindings for inputs
let staticColor = $state('#ffffff');
let pulseColor = $state('#ffffff');
let flickerColor = $state('#ffffff');
let fadeColors = $state(['#ffffff', '#000000']);
// Initialize colors when config changes
$effect(() => {
if (config.staticParams) {
staticColor = numberToHexColor(config.staticParams.color);
}
if (config.pulseParams) {
pulseColor = numberToHexColor(config.pulseParams.color);
}
if (config.flickerParams) {
flickerColor = numberToHexColor(config.flickerParams.color);
}
if (config.fadeParams) {
fadeColors = config.fadeParams.colors.map(numberToHexColor);
}
selectedAnimation = getCurrentAnimation(config);
tempConfig = { ...config };
});
function getCurrentAnimation(config?: LedConfig): string {
if (!config) return 'static';
if (config.staticParams) return 'static';
if (config.pulseParams) return 'pulse';
if (config.fadeParams) return 'fade';
if (config.flickerParams) return 'flicker';
return 'static';
}
function updateAnimation(event: Event) {
const target = event.target as HTMLSelectElement;
const animation = target.value;
selectedAnimation = animation;
// Clear all animation params
tempConfig.staticParams = undefined;
tempConfig.pulseParams = undefined;
tempConfig.fadeParams = undefined;
tempConfig.flickerParams = undefined;
// Set the selected animation params
if (animation === 'static') {
tempConfig.staticParams = { color: 0xffffff };
staticColor = numberToHexColor(0xffffff);
} else if (animation === 'pulse') {
tempConfig.pulseParams = { color: 0xffffff, speedMs: 500 };
pulseColor = numberToHexColor(0xffffff);
} else if (animation === 'fade') {
tempConfig.fadeParams = { colors: [0xffffff, 0x000000], speedMs: 1000 };
fadeColors = ['#ffffff', '#000000'];
} else if (animation === 'flicker') {
tempConfig.flickerParams = { color: 0xffffff, intensity: 50 };
flickerColor = numberToHexColor(0xffffff);
}
}
function updateStaticColor() {
if (tempConfig.staticParams) {
tempConfig.staticParams.color = hexColorToNumber(staticColor);
}
}
function updatePulseColor() {
if (tempConfig.pulseParams) {
tempConfig.pulseParams.color = hexColorToNumber(pulseColor);
}
}
function updateFlickerColor() {
if (tempConfig.flickerParams) {
tempConfig.flickerParams.color = hexColorToNumber(flickerColor);
}
}
function updateFadeColor(index: number) {
if (tempConfig.fadeParams) {
tempConfig.fadeParams.colors[index] = hexColorToNumber(fadeColors[index]);
}
}
// Helper functions for color conversion
function numberToHexColor(num: number): string {
return '#' + num.toString(16).padStart(6, '0');
}
function hexColorToNumber(hex: string): number {
return parseInt(hex.substring(1), 16);
}
function handleSave() {
onSave(tempConfig);
}
</script>
<AlertDialog bind:open>
<AlertDialogContent class="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Configure {ledType} LED</AlertDialogTitle>
<AlertDialogDescription>Set the LED animation and parameters.</AlertDialogDescription>
</AlertDialogHeader>
<div class="space-y-4">
<div>
<Label for="brightness">Brightness (0-255)</Label>
<Input id="brightness" type="number" min="0" max="255" bind:value={tempConfig.brightness} />
</div>
<div>
<Label for="duration">Duration (ms, 0 for indefinite)</Label>
<Input id="duration" type="number" min="0" bind:value={tempConfig.durationMs} />
</div>
<div>
<Label for="animation">Animation</Label>
<select bind:value={selectedAnimation} onchange={updateAnimation}>
<option value="static">Static</option>
<option value="pulse">Pulse</option>
<option value="fade">Fade</option>
<option value="flicker">Flicker</option>
</select>
</div>
<!-- Animation-specific options -->
{#if tempConfig.staticParams}
<div>
<Label>Color</Label>
<ColorPicker
value={staticColor}
onChange={(color) => {
staticColor = color;
updateStaticColor();
}}
/>
</div>
{:else if tempConfig.pulseParams}
<div class="space-y-2">
<div>
<Label>Color</Label>
<ColorPicker
value={pulseColor}
onChange={(color) => {
pulseColor = color;
updatePulseColor();
}}
/>
</div>
<div>
<Label for="pulse-speed">Speed (ms)</Label>
<Input
id="pulse-speed"
type="number"
min="100"
bind:value={tempConfig.pulseParams.speedMs}
/>
</div>
</div>
{:else if tempConfig.fadeParams}
<div class="space-y-2">
<div>
<Label>Colors</Label>
{#each fadeColors as color, i}
<div class="flex items-center gap-2">
<ColorPicker
value={color}
onChange={(newColor) => {
fadeColors[i] = newColor;
updateFadeColor(i);
}}
class="flex-1"
/>
<Button
size="sm"
variant="destructive"
onclick={() => {
fadeColors.splice(i, 1);
tempConfig.fadeParams?.colors.splice(i, 1);
}}
>
Remove
</Button>
</div>
{/each}
<Button
size="sm"
onclick={() => {
fadeColors.push('#ffffff');
tempConfig.fadeParams?.colors.push(0xffffff);
}}
>
Add Color
</Button>
</div>
<div>
<Label for="fade-speed">Speed (ms)</Label>
<Input
id="fade-speed"
type="number"
min="100"
bind:value={tempConfig.fadeParams.speedMs}
/>
</div>
</div>
{:else if tempConfig.flickerParams}
<div class="space-y-2">
<div>
<Label>Color</Label>
<ColorPicker
value={flickerColor}
onChange={(color) => {
flickerColor = color;
updateFlickerColor();
}}
/>
</div>
<div>
<Label for="flicker-intensity">Intensity (0-100)</Label>
<Input
id="flicker-intensity"
type="number"
min="0"
max="100"
bind:value={tempConfig.flickerParams.intensity}
/>
</div>
</div>
{/if}
</div>
<div class="mt-4 flex justify-end gap-2">
<Button variant="outline" onclick={onCancel}>Cancel</Button>
<Button onclick={handleSave}>Save</Button>
</div>
</AlertDialogContent>
</AlertDialog>
@@ -0,0 +1,83 @@
<script lang="ts">
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { Separator } from '$lib/components/ui/separator';
interface Props {
syncServerUrl: string;
deviceApiKey: string;
syncInterval: number;
autoSync: boolean;
}
let {
syncServerUrl = $bindable(),
deviceApiKey = $bindable(),
syncInterval = $bindable(),
autoSync = $bindable()
}: Props = $props();
</script>
<Card>
<CardHeader>
<CardTitle>Sync Server Configuration</CardTitle>
<CardDescription>Configure the server URL and synchronization settings</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="sync-url">Server URL</Label>
<Input
id="sync-url"
type="url"
placeholder="https://sync.example.com/api"
bind:value={syncServerUrl}
/>
<p class="text-sm text-gray-500">The URL of the synchronization server for data exchange</p>
</div>
<div class="space-y-2">
<Label for="device-api-key">Device API Key</Label>
<Input
id="device-api-key"
type="text"
placeholder="Enter device API key"
bind:value={deviceApiKey}
/>
<p class="text-sm text-gray-500">API key for device authentication with the sync server</p>
</div>
<Separator />
<div class="space-y-4">
<div class="flex items-center space-x-2">
<Switch id="auto-sync" bind:checked={autoSync} />
<Label for="auto-sync">Enable synchronization</Label>
</div>
{#if autoSync}
<div class="space-y-2">
<Label for="sync-interval">Sync Interval (seconds)</Label>
<Input
id="sync-interval"
type="number"
min="5"
max="86400"
placeholder="1800"
bind:value={syncInterval}
/>
<p class="text-sm text-gray-500">
How often to synchronize data with the server (5-86400 seconds)
</p>
</div>
{/if}
</div>
</CardContent>
</Card>
@@ -0,0 +1,129 @@
<script lang="ts">
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { WifiMode } from '$lib/gen/settings';
interface Props {
wifiMode: WifiMode;
stationSsid: string;
stationPassword: string;
apSsid: string;
apPassword: string;
apChannel: number;
enableFallbackAp: boolean;
}
let {
wifiMode = $bindable(),
stationSsid = $bindable(),
stationPassword = $bindable(),
apSsid = $bindable(),
apPassword = $bindable(),
apChannel = $bindable(),
enableFallbackAp = $bindable()
}: Props = $props();
</script>
<Card>
<CardHeader>
<CardTitle>WiFi Settings</CardTitle>
<CardDescription>Configure WiFi connection modes and access point settings</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="space-y-4">
<div class="space-y-2">
<Label for="wifi-mode">WiFi Mode</Label>
<select
id="wifi-mode"
bind:value={wifiMode}
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={WifiMode.WIFI_MODE_STATION}>Station Mode (Connect to existing WiFi)</option
>
<option value={WifiMode.WIFI_MODE_AP}>Access Point Mode (Create WiFi network)</option>
<option value={WifiMode.WIFI_MODE_AP_STATION}>AP + Station Mode (Both)</option>
</select>
<p class="text-sm text-gray-500">
Choose how the device connects to and provides WiFi networks
</p>
</div>
{#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode)}
<div class="border-t pt-4">
<h4 class="mb-3 text-sm font-medium">Station Settings (Connect to WiFi)</h4>
<div class="space-y-3">
<div class="space-y-2">
<Label for="station-ssid">Network Name (SSID)</Label>
<Input id="station-ssid" placeholder="MyHomeNetwork" bind:value={stationSsid} />
</div>
<div class="space-y-2">
<Label for="station-password">Password</Label>
<Input
id="station-password"
type="password"
placeholder="Enter WiFi password"
bind:value={stationPassword}
/>
</div>
</div>
</div>
{/if}
{#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode)}
<div class="border-t pt-4">
{#if wifiMode === WifiMode.WIFI_MODE_STATION}
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-2">
<Switch id="fallback-ap" bind:checked={enableFallbackAp} />
<Label for="fallback-ap">Enable fallback access point</Label>
</div>
<p class="text-sm text-gray-500">
If station connection fails, create AP for configuration
</p>
</div>
{/if}
<h4 class="mb-3 text-sm font-medium">Access Point Settings (Create WiFi Network)</h4>
{#if [WifiMode.WIFI_MODE_STATION, WifiMode.WIFI_MODE_AP_STATION].includes(wifiMode) || (wifiMode === WifiMode.WIFI_MODE_STATION && enableFallbackAp)}
<div class="space-y-3">
<div class="space-y-2">
<Label for="ap-ssid">Network Name (SSID)</Label>
<Input id="ap-ssid" placeholder="RFID-Master-AP" bind:value={apSsid} />
</div>
<div class="space-y-2">
<Label for="ap-password">Password</Label>
<Input
id="ap-password"
type="password"
placeholder="rfid12345"
bind:value={apPassword}
/>
<p class="text-sm text-gray-500">Password must be 8-63 characters long</p>
</div>
<div class="space-y-2">
<Label for="ap-channel">Channel</Label>
<select
id="ap-channel"
typeof="number"
bind:value={apChannel}
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{#each Array.from({ length: 13 }, (_, i) => i + 1) as channel}
<option value={channel}>{channel}</option>
{/each}
</select>
</div>
</div>
{/if}
</div>
{/if}
</div>
</CardContent>
</Card>
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
{...restProps}
/>
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)}
{...restProps}
/>
@@ -0,0 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
/>
</AlertDialogPrimitive.Portal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
</script>
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
@@ -0,0 +1,39 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>
+14
View File
@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};
@@ -0,0 +1,80 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>
+25
View File
@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};
@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
@@ -0,0 +1,205 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
value: string;
onChange?: (color: string) => void;
size?: number;
class?: string;
}
let {
value = $bindable('#ff0000'),
onChange,
size = 200,
class: className = ''
}: Props = $props();
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let isDragging = false;
// HSV to RGB conversion
function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0,
g = 0,
b = 0;
if (0 <= h && h < 60) {
r = c;
g = x;
b = 0;
} else if (60 <= h && h < 120) {
r = x;
g = c;
b = 0;
} else if (120 <= h && h < 180) {
r = 0;
g = c;
b = x;
} else if (180 <= h && h < 240) {
r = 0;
g = x;
b = c;
} else if (240 <= h && h < 300) {
r = x;
g = 0;
b = c;
} else if (300 <= h && h < 360) {
r = c;
g = 0;
b = x;
}
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255)
};
}
function rgbToHex(r: number, g: number, b: number): string {
return (
'#' +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
})
.join('')
);
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null;
}
function drawColorWheel() {
if (!ctx) return;
const centerX = size / 2;
const centerY = size / 2;
const radius = size / 2 - 10;
// Draw hue/saturation wheel
for (let angle = 0; angle < 360; angle++) {
for (let sat = 0; sat <= 1; sat += 0.01) {
const x = centerX + Math.cos((angle * Math.PI) / 180) * radius * sat;
const y = centerY + Math.sin((angle * Math.PI) / 180) * radius * sat;
const rgb = hsvToRgb(angle, sat, 1);
ctx.fillStyle = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
ctx.fillRect(x, y, 2, 2);
}
}
// Draw value gradient in center
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius * 0.3);
gradient.addColorStop(0, '#ffffff');
gradient.addColorStop(1, '#000000');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(centerX, centerY, radius * 0.3, 0, 2 * Math.PI);
ctx.fill();
}
function getColorFromPosition(x: number, y: number): string {
const centerX = size / 2;
const centerY = size / 2;
const radius = size / 2 - 10;
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= radius * 0.3) {
// Inside value circle - use current hue/saturation with adjusted value
const rgb = hexToRgb(value);
if (rgb) {
const value = 1 - distance / (radius * 0.3);
return rgbToHex(
Math.round(rgb.r * value),
Math.round(rgb.g * value),
Math.round(rgb.b * value)
);
}
} else if (distance <= radius) {
// In hue/saturation area
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
const hue = angle >= 0 ? angle : angle + 360;
const saturation = distance / radius;
const rgb = hsvToRgb(hue, saturation, 1);
return rgbToHex(rgb.r, rgb.g, rgb.b);
}
return value;
}
function handleMouseDown(event: MouseEvent) {
isDragging = true;
handleMouseMove(event);
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newColor = getColorFromPosition(x, y);
if (newColor !== value) {
value = newColor;
onChange?.(newColor);
}
}
function handleMouseUp() {
isDragging = false;
}
onMount(() => {
ctx = canvas.getContext('2d')!;
drawColorWheel();
// Add event listeners
canvas.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
});
</script>
<div class="space-y-2 {className}">
<div class="flex items-center gap-4">
<canvas
bind:this={canvas}
width={size}
height={size}
class="cursor-crosshair rounded border border-gray-300"
></canvas>
<div class="flex flex-col gap-2">
<div
class="h-16 w-16 rounded border-2 border-gray-300"
style="background-color: {value}"
></div>
<div class="font-mono text-sm">{value.toUpperCase()}</div>
</div>
</div>
</div>
@@ -0,0 +1,110 @@
<script lang="ts">
import Slider from './slider.svelte';
interface Props {
value: string;
onChange?: (color: string) => void;
class?: string;
}
let { value = $bindable('#ff0000'), onChange, class: className = '' }: Props = $props();
// Parse RGB from hex
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: { r: 255, g: 0, b: 0 };
}
// Convert RGB to hex
function rgbToHex(r: number, g: number, b: number): string {
return (
'#' +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
})
.join('')
);
}
// Reactive RGB values
let rgb = $derived(hexToRgb(value));
// Update color when RGB changes
$effect(() => {
const newHex = rgbToHex(rgb.r, rgb.g, rgb.b);
if (newHex !== value) {
value = newHex;
onChange?.(newHex);
}
});
// Update RGB when value changes externally
$effect(() => {
rgb = hexToRgb(value);
});
</script>
<div class="space-y-4 {className}">
<div class="flex items-center gap-4">
<div
class="h-16 w-16 flex-shrink-0 rounded border-2 border-gray-300"
style="background-color: {value}"
></div>
<div class="font-mono text-sm">{value.toUpperCase()}</div>
</div>
<div class="space-y-3">
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span>Red</span>
<span>{rgb.r}</span>
</div>
<Slider
min={0}
max={255}
step={1}
value={rgb.r}
onChange={(newValue: number) => (rgb = { ...rgb, r: newValue })}
class="w-full"
/>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span>Green</span>
<span>{rgb.g}</span>
</div>
<Slider
min={0}
max={255}
step={1}
value={rgb.g}
onChange={(newValue: number) => (rgb = { ...rgb, g: newValue })}
class="w-full"
/>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span>Blue</span>
<span>{rgb.b}</span>
</div>
<Slider
min={0}
max={255}
step={1}
value={rgb.b}
onChange={(newValue: number) => (rgb = { ...rgb, b: newValue })}
class="w-full"
/>
</div>
</div>
</div>
@@ -0,0 +1,421 @@
<script lang="ts">
import { onMount } from 'svelte';
import Slider from './slider.svelte';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel
} from '$lib/components/ui/alert-dialog';
interface Props {
value: string;
onChange?: (color: string) => void;
class?: string;
}
let { value = $bindable('#ff0000'), onChange, class: className = '' }: Props = $props();
let canvas = $state<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D;
let isDragging = false;
let canvasSize = $state(300);
// Responsive canvas size
$effect(() => {
const updateSize = () => {
if (typeof window !== 'undefined') {
canvasSize = window.innerWidth < 640 ? 200 : 300;
}
};
updateSize();
if (typeof window !== 'undefined') {
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}
});
// Parse RGB from hex
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: { r: 255, g: 0, b: 0 };
}
// Convert RGB to hex
function rgbToHex(r: number, g: number, b: number): string {
return (
'#' +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? '0' + hex : hex;
})
.join('')
);
}
// RGB to HSV conversion
function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
r = r / 255;
g = g / 255;
b = b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
let h = 0;
let s = max === 0 ? 0 : delta / max;
let v = max;
if (delta !== 0) {
if (max === r) {
h = ((g - b) / delta + (g < b ? 6 : 0)) / 6;
} else if (max === g) {
h = ((b - r) / delta + 2) / 6;
} else {
h = ((r - g) / delta + 4) / 6;
}
}
return { h: h * 360, s, v };
}
// HSV to RGB conversion
function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r = 0,
g = 0,
b = 0;
if (0 <= h && h < 60) {
r = c;
g = x;
b = 0;
} else if (60 <= h && h < 120) {
r = x;
g = c;
b = 0;
} else if (120 <= h && h < 180) {
r = 0;
g = c;
b = x;
} else if (180 <= h && h < 240) {
r = 0;
g = x;
b = c;
} else if (240 <= h && h < 300) {
r = x;
g = 0;
b = c;
} else if (300 <= h && h < 360) {
r = c;
g = 0;
b = x;
}
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255)
};
}
// Reactive RGB values
let rgb = $derived(hexToRgb(value));
// Update color when RGB changes
$effect(() => {
const newHex = rgbToHex(rgb.r, rgb.g, rgb.b);
if (newHex !== value) {
value = newHex;
onChange?.(newHex);
}
});
// Update RGB when value changes externally
$effect(() => {
rgb = hexToRgb(value);
});
function drawColorWheel() {
if (!ctx || !canvas) return;
const centerX = canvasSize / 2;
const centerY = canvasSize / 2;
const radius = canvasSize / 2 - 10;
// Clear canvas
ctx.clearRect(0, 0, canvasSize, canvasSize);
// Create ImageData for better performance
const imageData = ctx.createImageData(canvasSize, canvasSize);
const data = imageData.data;
// Get current hue from the selected color
const currentHsv = rgbToHsv(rgb.r, rgb.g, rgb.b);
const currentHue = currentHsv.h;
for (let x = 0; x < canvasSize; x++) {
for (let y = 0; y < canvasSize; y++) {
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= radius) {
let color = { r: 255, g: 255, b: 255 };
if (distance <= radius * 0.3) {
// Inner circle - brightness/value gradient using current hue
const brightness = 1 - distance / (radius * 0.3);
color = hsvToRgb(currentHue, 1, brightness);
} else {
// Outer ring - hue/saturation
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
const hue = angle >= 0 ? angle : angle + 360;
const saturation = (distance - radius * 0.3) / (radius * 0.7);
color = hsvToRgb(hue, saturation, 1);
}
const index = (y * canvasSize + x) * 4;
data[index] = color.r; // Red
data[index + 1] = color.g; // Green
data[index + 2] = color.b; // Blue
data[index + 3] = 255; // Alpha
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function getColorFromPosition(x: number, y: number): string {
const centerX = canvasSize / 2;
const centerY = canvasSize / 2;
const radius = canvasSize / 2 - 10;
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Get current hue
const currentHsv = rgbToHsv(rgb.r, rgb.g, rgb.b);
const currentHue = currentHsv.h;
if (distance <= radius * 0.3) {
// Inside brightness circle - use current hue with adjusted brightness
const brightness = 1 - distance / (radius * 0.3);
const newRgb = hsvToRgb(currentHue, 1, brightness);
return rgbToHex(newRgb.r, newRgb.g, newRgb.b);
} else if (distance <= radius) {
// In hue/saturation area
const angle = (Math.atan2(dy, dx) * 180) / Math.PI;
const hue = angle >= 0 ? angle : angle + 360;
const saturation = (distance - radius * 0.3) / (radius * 0.7);
const newRgb = hsvToRgb(hue, saturation, 1);
return rgbToHex(newRgb.r, newRgb.g, newRgb.b);
}
return value;
}
function handleMouseDown(event: MouseEvent) {
isDragging = true;
handleMouseMove(event);
}
function handleMouseMove(event: MouseEvent) {
if (!isDragging || !canvas) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newColor = getColorFromPosition(x, y);
if (newColor !== value) {
value = newColor;
onChange?.(newColor);
}
}
function handleMouseUp() {
isDragging = false;
}
// Touch event handlers for mobile
function handleTouchStart(event: TouchEvent) {
event.preventDefault();
isDragging = true;
handleTouchMove(event);
}
function handleTouchMove(event: TouchEvent) {
if (!isDragging || !canvas) return;
event.preventDefault();
const touch = event.touches[0];
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
const newColor = getColorFromPosition(x, y);
if (newColor !== value) {
value = newColor;
onChange?.(newColor);
}
}
function handleTouchEnd() {
isDragging = false;
}
function toggleExpanded() {
// This function is no longer needed with popover
// but keeping for backward compatibility
}
onMount(() => {
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
});
// Initialize canvas and draw when it becomes available
$effect(() => {
if (canvas) {
ctx = canvas.getContext('2d')!;
if (ctx) {
drawColorWheel();
// Add event listeners
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('touchstart', handleTouchStart);
canvas.addEventListener('touchmove', handleTouchMove);
canvas.addEventListener('touchend', handleTouchEnd);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
}
});
// Redraw color wheel when RGB changes (from sliders)
$effect(() => {
if (ctx && canvas) {
// Access rgb to create dependency
(rgb.r, rgb.g, rgb.b);
drawColorWheel();
}
});
</script>
<div class="space-y-2 {className}">
<AlertDialog>
<AlertDialogTrigger>
<button
type="button"
class="h-16 w-16 rounded border-2 border-gray-300 transition-colors hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
style="background-color: {value}"
aria-label="Open color picker"
title="Click to edit color"
></button>
</AlertDialogTrigger>
<AlertDialogContent
class="max-h-[90vh] w-[95vw] max-w-sm overflow-y-auto p-6 sm:w-auto sm:max-w-none"
>
<AlertDialogHeader>
<AlertDialogTitle>Color Picker - {value.toUpperCase()}</AlertDialogTitle>
</AlertDialogHeader>
<div class="flex flex-col gap-6 sm:flex-row">
<!-- 2D Color Wheel -->
<div class="flex flex-col items-center gap-2">
<span class="text-xs text-gray-600">Color Wheel</span>
<canvas
bind:this={canvas}
width={canvasSize}
height={canvasSize}
class="cursor-crosshair touch-none rounded border border-gray-300"
style="width: {canvasSize}px; height: {canvasSize}px;"
></canvas>
</div>
<!-- RGB Sliders -->
<div class="w-full space-y-3 sm:min-w-48 sm:flex-1">
<span class="text-xs text-gray-600">RGB Sliders</span>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span>Red</span>
<span>{rgb.r}</span>
</div>
<Slider
min={0}
max={255}
step={1}
value={rgb.r}
onChange={(newValue: number) => (rgb = { ...rgb, r: newValue })}
class="w-full"
/>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span>Green</span>
<span>{rgb.g}</span>
</div>
<Slider
min={0}
max={255}
step={1}
value={rgb.g}
onChange={(newValue: number) => (rgb = { ...rgb, g: newValue })}
class="w-full"
/>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span>Blue</span>
<span>{rgb.b}</span>
</div>
<Slider
min={0}
max={255}
step={1}
value={rgb.b}
onChange={(newValue: number) => (rgb = { ...rgb, b: newValue })}
class="w-full"
/>
</div>
</div>
</div>
<!-- Color preview -->
<div class="flex items-center justify-center gap-2">
<div class="h-8 w-8 rounded border border-gray-300" style="background-color: {value}"></div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Close</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
+7
View File
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot="input"
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot="input"
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className
)}
{...restProps}
/>
+17
View File
@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn("", className)}
{...restProps}
/>
+37
View File
@@ -0,0 +1,37 @@
import { Select as SelectPrimitive } from "bits-ui";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
const Root = SelectPrimitive.Root;
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
};
@@ -0,0 +1,40 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute right-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot="separator"
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>
+36
View File
@@ -0,0 +1,36 @@
import { Dialog as SheetPrimitive } from "bits-ui";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte";
import Footer from "./sheet-footer.svelte";
import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
@@ -0,0 +1,58 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />
@@ -0,0 +1,6 @@
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -0,0 +1,81 @@
import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
import { getContext, setContext } from "svelte";
import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
type Getter<T> = () => T;
export type SidebarStateProps = {
/**
* A getter function that returns the current open state of the sidebar.
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
* component.
*/
open: Getter<boolean>;
/**
* A function that sets the open state of the sidebar. To support `bind:open`, we need
* a source of truth for changing the open state to ensure it will be synced throughout
* the sub-components and any `bind:` references.
*/
setOpen: (open: boolean) => void;
};
class SidebarState {
readonly props: SidebarStateProps;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
setOpen: SidebarStateProps["setOpen"];
#isMobile: IsMobile;
state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
constructor(props: SidebarStateProps) {
this.setOpen = props.setOpen;
this.#isMobile = new IsMobile();
this.props = props;
}
// Convenience getter for checking if the sidebar is mobile
// without this, we would need to use `sidebar.isMobile.current` everywhere
get isMobile() {
return this.#isMobile.current;
}
// Event handler to apply to the `<svelte:window>`
handleShortcutKeydown = (e: KeyboardEvent) => {
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
}
};
setOpenMobile = (value: boolean) => {
this.openMobile = value;
};
toggle = () => {
return this.#isMobile.current
? (this.openMobile = !this.openMobile)
: this.setOpen(!this.open);
};
}
const SYMBOL_KEY = "scn-sidebar";
/**
* Instantiates a new `SidebarState` instance and sets it in the context.
*
* @param props The constructor props for the `SidebarState` class.
* @returns The `SidebarState` instance.
*/
export function setSidebar(props: SidebarStateProps): SidebarState {
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
}
/**
* Retrieves the `SidebarState` instance from the context. This is a class instance,
* so you cannot destructure it.
* @returns The `SidebarState` instance.
*/
export function useSidebar(): SidebarState {
return getContext(Symbol.for(SYMBOL_KEY));
}
+75
View File
@@ -0,0 +1,75 @@
import { useSidebar } from "./context.svelte.js";
import Content from "./sidebar-content.svelte";
import Footer from "./sidebar-footer.svelte";
import GroupAction from "./sidebar-group-action.svelte";
import GroupContent from "./sidebar-group-content.svelte";
import GroupLabel from "./sidebar-group-label.svelte";
import Group from "./sidebar-group.svelte";
import Header from "./sidebar-header.svelte";
import Input from "./sidebar-input.svelte";
import Inset from "./sidebar-inset.svelte";
import MenuAction from "./sidebar-menu-action.svelte";
import MenuBadge from "./sidebar-menu-badge.svelte";
import MenuButton from "./sidebar-menu-button.svelte";
import MenuItem from "./sidebar-menu-item.svelte";
import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
import MenuSubButton from "./sidebar-menu-sub-button.svelte";
import MenuSubItem from "./sidebar-menu-sub-item.svelte";
import MenuSub from "./sidebar-menu-sub.svelte";
import Menu from "./sidebar-menu.svelte";
import Provider from "./sidebar-provider.svelte";
import Rail from "./sidebar-rail.svelte";
import Separator from "./sidebar-separator.svelte";
import Trigger from "./sidebar-trigger.svelte";
import Root from "./sidebar.svelte";
export {
Content,
Footer,
Group,
GroupAction,
GroupContent,
GroupLabel,
Header,
Input,
Inset,
Menu,
MenuAction,
MenuBadge,
MenuButton,
MenuItem,
MenuSkeleton,
MenuSub,
MenuSubButton,
MenuSubItem,
Provider,
Rail,
Root,
Separator,
//
Root as Sidebar,
Content as SidebarContent,
Footer as SidebarFooter,
Group as SidebarGroup,
GroupAction as SidebarGroupAction,
GroupContent as SidebarGroupContent,
GroupLabel as SidebarGroupLabel,
Header as SidebarHeader,
Input as SidebarInput,
Inset as SidebarInset,
Menu as SidebarMenu,
MenuAction as SidebarMenuAction,
MenuBadge as SidebarMenuBadge,
MenuButton as SidebarMenuButton,
MenuItem as SidebarMenuItem,
MenuSkeleton as SidebarMenuSkeleton,
MenuSub as SidebarMenuSub,
MenuSubButton as SidebarMenuSubButton,
MenuSubItem as SidebarMenuSubItem,
Provider as SidebarProvider,
Rail as SidebarRail,
Separator as SidebarSeparator,
Trigger as SidebarTrigger,
Trigger,
useSidebar,
};
@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-content"
data-sidebar="content"
class={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-footer"
data-sidebar="footer"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
),
"data-slot": "sidebar-group-action",
"data-sidebar": "group-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group-content"
data-sidebar="group-content"
class={cn("w-full text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
),
"data-slot": "sidebar-group-label",
"data-sidebar": "group-label",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render children?.()}
</div>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group"
data-sidebar="group"
class={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-header"
data-sidebar="header"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import { Input } from "$lib/components/ui/input/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: ComponentProps<typeof Input> = $props();
</script>
<Input
bind:ref
bind:value
data-slot="sidebar-input"
data-sidebar="input"
class={cn("bg-background h-8 w-full shadow-none", className)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<main
bind:this={ref}
data-slot="sidebar-inset"
class={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</main>
@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
showOnHover = false,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
showOnHover?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground outline-hidden absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
),
"data-slot": "sidebar-menu-action",
"data-sidebar": "menu-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
class={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,103 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sidebarMenuButtonVariants = tv({
base: "peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "group-data-[collapsible=icon]:p-0! h-12 text-sm",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type SidebarMenuButtonVariant = VariantProps<
typeof sidebarMenuButtonVariants
>["variant"];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>["size"];
</script>
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn, type WithElementRef, type WithoutChildrenOrChild } from "$lib/utils.js";
import { mergeProps } from "bits-ui";
import type { ComponentProps, Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
children,
child,
variant = "default",
size = "default",
isActive = false,
tooltipContent,
tooltipContentProps,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
isActive?: boolean;
variant?: SidebarMenuButtonVariant;
size?: SidebarMenuButtonSize;
tooltipContent?: Snippet | string;
tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const sidebar = useSidebar();
const buttonProps = $derived({
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
"data-slot": "sidebar-menu-button",
"data-sidebar": "menu-button",
"data-size": size,
"data-active": isActive,
...restProps,
});
</script>
{#snippet Button({ props }: { props?: Record<string, unknown> })}
{@const mergedProps = mergeProps(buttonProps, props)}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
{/snippet}
{#if !tooltipContent}
{@render Button({})}
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render Button({ props })}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
side="right"
align="center"
hidden={sidebar.state !== "collapsed" || sidebar.isMobile}
{...tooltipContentProps}
>
{#if typeof tooltipContent === "string"}
{tooltipContent}
{:else if tooltipContent}
{@render tooltipContent()}
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
class={cn("group/menu-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
showIcon = false,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
showIcon?: boolean;
} = $props();
// Random width between 50% and 90%
const width = `${Math.floor(Math.random() * 40) + 50}%`;
</script>
<div
bind:this={ref}
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
class={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...restProps}
>
{#if showIcon}
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
{/if}
<Skeleton
class="max-w-(--skeleton-width) h-4 flex-1"
data-sidebar="menu-skeleton-text"
style="--skeleton-width: {width};"
/>
{@render children?.()}
</div>
@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAnchorAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
size = "md",
isActive = false,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
size?: "sm" | "md";
isActive?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
),
"data-slot": "sidebar-menu-sub-button",
"data-sidebar": "menu-sub-button",
"data-size": size,
"data-active": isActive,
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a bind:this={ref} {...mergedProps}>
{@render children?.()}
</a>
{/if}
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
class={cn("group/menu-sub-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>
@@ -0,0 +1,25 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
class={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...restProps}
>
{@render children?.()}
</ul>
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu"
data-sidebar="menu"
class={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...restProps}
>
{@render children?.()}
</ul>
@@ -0,0 +1,53 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from "./constants.js";
import { setSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
open = $bindable(true),
onOpenChange = () => {},
class: className,
style,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
} = $props();
const sidebar = setSidebar({
open: () => open,
setOpen: (value: boolean) => {
open = value;
onOpenChange(value);
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
});
</script>
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
<Tooltip.Provider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
</Tooltip.Provider>
@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onclick={sidebar.toggle}
title="Toggle Sidebar"
class={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-[calc(1/2*100%-1px)] after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...restProps}
>
{@render children?.()}
</button>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="sidebar-separator"
data-sidebar="separator"
class={cn("bg-sidebar-border", className)}
{...restProps}
/>
@@ -0,0 +1,35 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import PanelLeftIcon from "@lucide/svelte/icons/panel-left";
import type { ComponentProps } from "svelte";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
onclick,
...restProps
}: ComponentProps<typeof Button> & {
onclick?: (e: MouseEvent) => void;
} = $props();
const sidebar = useSidebar();
</script>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
class={cn("size-7", className)}
type="button"
onclick={(e) => {
onclick?.(e);
sidebar.toggle();
}}
{...restProps}
>
<PanelLeftIcon />
<span class="sr-only">Toggle Sidebar</span>
</Button>
@@ -0,0 +1,104 @@
<script lang="ts">
import * as Sheet from "$lib/components/ui/sheet/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { SIDEBAR_WIDTH_MOBILE } from "./constants.js";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
} = $props();
const sidebar = useSidebar();
</script>
{#if collapsible === "none"}
<div
class={cn(
"bg-sidebar text-sidebar-foreground w-(--sidebar-width) flex h-full flex-col",
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
{:else if sidebar.isMobile}
<Sheet.Root
bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
{...restProps}
>
<Sheet.Content
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
</Sheet.Header>
<div class="flex h-full w-full flex-col">
{@render children?.()}
</div>
</Sheet.Content>
</Sheet.Root>
{:else}
<div
bind:this={ref}
class="text-sidebar-foreground group peer hidden md:block"
data-state={sidebar.state}
data-collapsible={sidebar.state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
data-slot="sidebar-gap"
class={cn(
"w-(--sidebar-width) relative bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
></div>
<div
data-slot="sidebar-container"
class={cn(
"w-(--sidebar-width) fixed inset-y-0 z-10 hidden h-svh transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...restProps}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{@render children?.()}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
data-slot="skeleton"
class={cn("bg-accent animate-pulse rounded-md", className)}
{...restProps}
></div>
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
interface Props {
value: number;
min?: number;
max?: number;
step?: number;
label?: string;
disabled?: boolean;
class?: string;
onChange?: (value: number) => void;
}
let {
value = $bindable(0),
min = 0,
max = 100,
step = 1,
label = '',
disabled = false,
class: className = '',
onChange
}: Props = $props();
let sliderRef: HTMLInputElement;
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
value = parseFloat(target.value);
onChange?.(value);
}
</script>
<div class="space-y-2 {className}">
{#if label}
<div class="text-sm font-medium">{label}</div>
{/if}
<div class="relative">
<input
bind:this={sliderRef}
type="range"
{min}
{max}
{step}
{disabled}
value={value.toString()}
oninput={handleInput}
class="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-200"
/>
<div class="mt-1 flex justify-between text-xs text-gray-500">
<span>{min}</span>
<span class="font-medium">{value}</span>
<span>{max}</span>
</div>
</div>
</div>
<style>
.slider::-webkit-slider-thumb {
appearance: none;
height: 20px;
width: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
.slider::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
</style>
+1
View File
@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";
@@ -0,0 +1,13 @@
<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.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
/>
+7
View File
@@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};
@@ -0,0 +1,29 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 shadow-xs peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
+16
View File
@@ -0,0 +1,16 @@
import Root from "./tabs.svelte";
import Content from "./tabs-content.svelte";
import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

Some files were not shown because too many files have changed in this diff Show More