init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const BASE_URL = window.location.origin;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user