a new 'base app' which gon get deployed on prem
This commit is contained in:
@@ -1,267 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { buttonVariants } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import { filesVM } from "$lib/domains/files/files.vm.svelte";
|
||||
import { mobileVM } from "$lib/domains/mobile/mobile.vm.svelte";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import Smartphone from "@lucide/svelte/icons/smartphone";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import Trash2 from "@lucide/svelte/icons/trash-2";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
breadcrumbs.set([mainNavTree[0]]);
|
||||
|
||||
onMount(async () => {
|
||||
await mobileVM.refreshDevices();
|
||||
mobileVM.startDevicesPolling(5000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
mobileVM.stopDevicesPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper cls="space-y-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={Smartphone} cls="h-5 w-5 text-primary" />
|
||||
<Card.Title>Devices</Card.Title>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{mobileVM.devicesTotal} total
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:flex sm:items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => void filesVM.cleanupDanglingFiles()}
|
||||
disabled={filesVM.cleanupLoading}
|
||||
class="col-span-1"
|
||||
>
|
||||
<Icon
|
||||
icon={Trash2}
|
||||
cls={`h-4 w-4 mr-2 ${filesVM.cleanupLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span class="truncate">Cleanup Storage</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => void mobileVM.refreshDevices()}
|
||||
disabled={mobileVM.devicesLoading}
|
||||
class="col-span-1"
|
||||
>
|
||||
<Icon
|
||||
icon={RefreshCw}
|
||||
cls={`h-4 w-4 mr-2 ${mobileVM.devicesLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span class="hidden sm:inline">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-2 w-full sm:max-w-sm">
|
||||
<Icon
|
||||
icon={Search}
|
||||
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
class="pl-10"
|
||||
placeholder="Search device name/id/model..."
|
||||
bind:value={mobileVM.devicesSearch}
|
||||
oninput={() => {
|
||||
mobileVM.devicesPage = 1;
|
||||
void mobileVM.refreshDevices();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
{#if !mobileVM.devicesLoading && mobileVM.devices.length === 0}
|
||||
<div class="py-10 text-center text-sm text-muted-foreground">
|
||||
No devices registered yet.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3 md:hidden">
|
||||
{#each mobileVM.devices as device (device.id)}
|
||||
<div
|
||||
class="rounded-lg border bg-background p-3"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => goto(`/devices/${device.id}`)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
void goto(`/devices/${device.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">{device.name}</p>
|
||||
<p class="text-muted-foreground truncate text-xs">
|
||||
{device.externalDeviceId}
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={mobileVM.deletingDeviceId === device.id}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon icon={Trash2} cls="h-4 w-4" />
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete device?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This deletes the device and all related SMS/media data.
|
||||
Files in storage linked to this device are also removed.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await mobileVM.deleteDevice(device.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
|
||||
<div>
|
||||
<p class="text-muted-foreground">Manufacturer / Model</p>
|
||||
<p class="truncate">{device.manufacturer} / {device.model}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Android</p>
|
||||
<p>{device.androidVersion}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Created</p>
|
||||
<p class="truncate">
|
||||
{new Date(device.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Last Ping</p>
|
||||
<p class="truncate">
|
||||
{mobileVM.formatLastPing(device.lastPingAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Device</Table.Head>
|
||||
<Table.Head>Manufacturer / Model</Table.Head>
|
||||
<Table.Head>Android</Table.Head>
|
||||
<Table.Head>Created</Table.Head>
|
||||
<Table.Head>Last Ping</Table.Head>
|
||||
<Table.Head>Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each mobileVM.devices as device (device.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer"
|
||||
onclick={() => goto(`/devices/${device.id}`)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<div class="font-medium">{device.name}</div>
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{device.externalDeviceId}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{device.manufacturer} / {device.model}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{device.androidVersion}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{new Date(device.createdAt).toLocaleString()}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{mobileVM.formatLastPing(device.lastPingAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={mobileVM.deletingDeviceId ===
|
||||
device.id}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon
|
||||
icon={Trash2}
|
||||
cls="h-4 w-4"
|
||||
/>
|
||||
Delete
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete device?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This deletes the device and all related SMS/media data.
|
||||
Files in storage linked to this device are also removed.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await mobileVM.deleteDevice(
|
||||
device.id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<span>dunno</span>
|
||||
</MaxWidthWrapper>
|
||||
|
||||
@@ -1 +1,399 @@
|
||||
<span>Show the running devices list here</span>
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Switch } from "$lib/components/ui/switch";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import { deviceVM } from "$lib/domains/device/device.vm.svelte";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import Monitor from "@lucide/svelte/icons/monitor";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import Trash2 from "@lucide/svelte/icons/trash-2";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
breadcrumbs.set([mainNavTree[0], mainNavTree[2]]);
|
||||
|
||||
let title = $state("");
|
||||
let version = $state("");
|
||||
let host = $state("");
|
||||
let containerId = $state("");
|
||||
let wsPort = $state("");
|
||||
let isActive = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await deviceVM.fetchDevices();
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
title = "";
|
||||
version = "";
|
||||
host = "";
|
||||
containerId = "";
|
||||
wsPort = "";
|
||||
isActive = false;
|
||||
}
|
||||
|
||||
async function handleCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
const success = await deviceVM.createDevice({
|
||||
title,
|
||||
version,
|
||||
host,
|
||||
containerId: containerId || undefined,
|
||||
wsPort: wsPort || undefined,
|
||||
isActive,
|
||||
});
|
||||
if (success) resetForm();
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "bg-green-500";
|
||||
case "offline":
|
||||
return "bg-zinc-400";
|
||||
case "busy":
|
||||
return "bg-amber-500";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-zinc-400";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper cls="space-y-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={Monitor} cls="h-5 w-5 text-primary" />
|
||||
<Card.Title>Devices</Card.Title>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{deviceVM.devices.length} total
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => void deviceVM.fetchDevices()}
|
||||
disabled={deviceVM.loading}
|
||||
>
|
||||
<Icon
|
||||
icon={RefreshCw}
|
||||
cls={`h-4 w-4 mr-2 ${deviceVM.loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => (deviceVM.showCreateDialog = true)}
|
||||
>
|
||||
<Icon icon={Plus} cls="h-4 w-4 mr-2" />
|
||||
Add Device
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
{#if !deviceVM.loading && deviceVM.devices.length === 0}
|
||||
<div class="py-10 text-center text-sm text-muted-foreground">
|
||||
No devices registered yet.
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Mobile cards -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
{#each deviceVM.devices as device (device.id)}
|
||||
<div class="rounded-lg border bg-background p-3">
|
||||
<div
|
||||
class="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{device.title}
|
||||
</p>
|
||||
<p
|
||||
class="text-muted-foreground truncate text-xs"
|
||||
>
|
||||
{device.host}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs capitalize"
|
||||
>
|
||||
<span
|
||||
class={`h-2 w-2 rounded-full ${statusColor(device.status)}`}
|
||||
></span>
|
||||
{device.status}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs"
|
||||
>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Version</p>
|
||||
<p>{device.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Active</p>
|
||||
<p>{device.isActive ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">
|
||||
Container
|
||||
</p>
|
||||
<p class="truncate font-mono">
|
||||
{device.containerId || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">
|
||||
WS Port
|
||||
</p>
|
||||
<p>{device.wsPort || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={deviceVM.deletingId ===
|
||||
device.id}
|
||||
>
|
||||
<Icon icon={Trash2} cls="h-4 w-4" />
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete device?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will permanently remove
|
||||
"{device.title}" and unlink it
|
||||
from any associated links.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
deviceVM.deleteDevice(
|
||||
device.id,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden md:block">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head>Host</Table.Head>
|
||||
<Table.Head>Version</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Active</Table.Head>
|
||||
<Table.Head>Container</Table.Head>
|
||||
<Table.Head>Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each deviceVM.devices as device (device.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
{device.title}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span class="font-mono text-xs">
|
||||
{device.host}
|
||||
</span>
|
||||
{#if device.wsPort}
|
||||
<span
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
:{device.wsPort}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{device.version}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs capitalize"
|
||||
>
|
||||
<span
|
||||
class={`h-2 w-2 rounded-full ${statusColor(device.status)}`}
|
||||
></span>
|
||||
{device.status}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if device.isActive}
|
||||
<Badge variant="default"
|
||||
>Active</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge variant="secondary"
|
||||
>Inactive</Badge
|
||||
>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-block max-w-[120px] truncate font-mono text-xs"
|
||||
>
|
||||
{device.containerId || "—"}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={deviceVM.deletingId ===
|
||||
device.id}
|
||||
>
|
||||
<Icon
|
||||
icon={Trash2}
|
||||
cls="h-4 w-4"
|
||||
/>
|
||||
Delete
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete device?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will permanently
|
||||
remove "{device.title}"
|
||||
and unlink it from any
|
||||
associated links.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
deviceVM.deleteDevice(
|
||||
device.id,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</MaxWidthWrapper>
|
||||
|
||||
<!-- Create Device Dialog -->
|
||||
<Dialog.Root bind:open={deviceVM.showCreateDialog}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Add Device</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Register a new Docker-Android instance.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleCreate} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="e.g. Android 14 — Slot 1"
|
||||
bind:value={title}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="host">Host</Label>
|
||||
<Input
|
||||
id="host"
|
||||
placeholder="e.g. 192.168.1.10"
|
||||
bind:value={host}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="version">Version</Label>
|
||||
<Input
|
||||
id="version"
|
||||
placeholder="e.g. android-14"
|
||||
bind:value={version}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="containerId">Container ID</Label>
|
||||
<Input
|
||||
id="containerId"
|
||||
placeholder="Optional"
|
||||
bind:value={containerId}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="wsPort">WS Port</Label>
|
||||
<Input
|
||||
id="wsPort"
|
||||
placeholder="e.g. 8886"
|
||||
bind:value={wsPort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="isActive" bind:checked={isActive} />
|
||||
<Label for="isActive">Active</Label>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onclick={() => (deviceVM.showCreateDialog = false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={deviceVM.creating}>
|
||||
{deviceVM.creating ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
1
apps/main/src/routes/(main)/devices/[id]/+page.svelte
Normal file
1
apps/main/src/routes/(main)/devices/[id]/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<span>device id page</span>
|
||||
@@ -1 +1,418 @@
|
||||
<span>everything related to links here</span>
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import { linkVM } from "$lib/domains/link/link.vm.svelte";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import LinkIcon from "@lucide/svelte/icons/link";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import Trash2 from "@lucide/svelte/icons/trash-2";
|
||||
import Ban from "@lucide/svelte/icons/ban";
|
||||
import Copy from "@lucide/svelte/icons/copy";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
breadcrumbs.set([mainNavTree[0], mainNavTree[1]]);
|
||||
|
||||
let selectedDeviceId = $state("");
|
||||
let expiresAt = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
await linkVM.fetchLinks();
|
||||
await linkVM.fetchDevicesForSelect();
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
selectedDeviceId = "";
|
||||
expiresAt = "";
|
||||
}
|
||||
|
||||
async function handleCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
const success = await linkVM.createLink({
|
||||
linkedDeviceId: selectedDeviceId
|
||||
? Number(selectedDeviceId)
|
||||
: null,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
});
|
||||
if (success) resetForm();
|
||||
}
|
||||
|
||||
async function copyToken(token: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
toast.success("Token copied to clipboard");
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(
|
||||
status: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "default";
|
||||
case "inactive":
|
||||
return "secondary";
|
||||
case "revoked":
|
||||
return "destructive";
|
||||
case "expired":
|
||||
return "outline";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string | null): string {
|
||||
if (!date) return "—";
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper cls="space-y-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={LinkIcon} cls="h-5 w-5 text-primary" />
|
||||
<Card.Title>Links</Card.Title>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{linkVM.links.length} total
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => void linkVM.fetchLinks()}
|
||||
disabled={linkVM.loading}
|
||||
>
|
||||
<Icon
|
||||
icon={RefreshCw}
|
||||
cls={`h-4 w-4 mr-2 ${linkVM.loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
linkVM.showCreateDialog = true;
|
||||
linkVM.fetchDevicesForSelect();
|
||||
}}
|
||||
>
|
||||
<Icon icon={Plus} cls="h-4 w-4 mr-2" />
|
||||
Generate Link
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
{#if !linkVM.loading && linkVM.links.length === 0}
|
||||
<div class="py-10 text-center text-sm text-muted-foreground">
|
||||
No links generated yet.
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Mobile cards -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
{#each linkVM.links as link (link.id)}
|
||||
<div class="rounded-lg border bg-background p-3">
|
||||
<div
|
||||
class="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="truncate text-sm font-medium"
|
||||
>
|
||||
{link.token}
|
||||
</code>
|
||||
<button
|
||||
class="text-muted-foreground hover:text-foreground shrink-0"
|
||||
onclick={() =>
|
||||
copyToken(link.token)}
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusVariant(link.status)}>
|
||||
{link.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs"
|
||||
>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Device</p>
|
||||
<p>
|
||||
{linkVM.getDeviceName(
|
||||
link.linkedDeviceId,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Expires</p>
|
||||
<p>{formatDate(link.expiresAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">
|
||||
Last Accessed
|
||||
</p>
|
||||
<p>{formatDate(link.lastAccessedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Created</p>
|
||||
<p>{formatDate(link.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
{#if link.status === "active"}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
linkVM.revokeLink(link.id)}
|
||||
disabled={linkVM.revokingId === link.id}
|
||||
>
|
||||
<Icon icon={Ban} cls="h-4 w-4" />
|
||||
</Button>
|
||||
{/if}
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={linkVM.deletingId === link.id}
|
||||
>
|
||||
<Icon icon={Trash2} cls="h-4 w-4" />
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete link?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will permanently remove
|
||||
this link. Anyone with this
|
||||
token will no longer be able to
|
||||
access the service.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
linkVM.deleteLink(link.id)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden md:block">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Token</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Device</Table.Head>
|
||||
<Table.Head>Expires</Table.Head>
|
||||
<Table.Head>Last Accessed</Table.Head>
|
||||
<Table.Head>Created</Table.Head>
|
||||
<Table.Head>Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each linkVM.links as link (link.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<div
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<code class="text-sm">
|
||||
{link.token}
|
||||
</code>
|
||||
<button
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
copyToken(link.token)}
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
variant={statusVariant(
|
||||
link.status,
|
||||
)}
|
||||
>
|
||||
{link.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{linkVM.getDeviceName(
|
||||
link.linkedDeviceId,
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs">
|
||||
{formatDate(link.expiresAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs">
|
||||
{formatDate(link.lastAccessedAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs">
|
||||
{formatDate(link.createdAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if link.status === "active"}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
linkVM.revokeLink(
|
||||
link.id,
|
||||
)}
|
||||
disabled={linkVM.revokingId ===
|
||||
link.id}
|
||||
>
|
||||
<Icon
|
||||
icon={Ban}
|
||||
cls="h-4 w-4 mr-1"
|
||||
/>
|
||||
Revoke
|
||||
</Button>
|
||||
{/if}
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={linkVM.deletingId ===
|
||||
link.id}
|
||||
>
|
||||
<Icon
|
||||
icon={Trash2}
|
||||
cls="h-4 w-4"
|
||||
/>
|
||||
Delete
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete link?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will
|
||||
permanently remove
|
||||
this link. Anyone
|
||||
with this token will
|
||||
no longer be able to
|
||||
access the service.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
linkVM.deleteLink(
|
||||
link.id,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</MaxWidthWrapper>
|
||||
|
||||
<!-- Create Link Dialog -->
|
||||
<Dialog.Root bind:open={linkVM.showCreateDialog}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Generate Link</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Create a new access link. The token is auto-generated.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleCreate} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="device">Assign Device</Label>
|
||||
<select
|
||||
id="device"
|
||||
bind:value={selectedDeviceId}
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<option value="">No device</option>
|
||||
{#each linkVM.availableDevices as device}
|
||||
<option value={String(device.id)}>
|
||||
{device.title} — {device.host}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Optional. You can assign a device later.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="expiresAt">Expires At</Label>
|
||||
<Input
|
||||
id="expiresAt"
|
||||
type="datetime-local"
|
||||
bind:value={expiresAt}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Optional. Leave empty for no expiry.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onclick={() => (linkVM.showCreateDialog = false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={linkVM.creating}>
|
||||
{linkVM.creating ? "Generating..." : "Generate"}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -20,41 +20,41 @@
|
||||
--popover: oklch(0.991 0 0);
|
||||
--popover-foreground: oklch(0 0 0);
|
||||
|
||||
/* --- main theme: lavender/royal purple --- */
|
||||
--primary: oklch(0.6 0.2 280); /* medium lavender purple */
|
||||
/* --- main theme: teal --- */
|
||||
--primary: oklch(0.6 0.15 180); /* medium teal */
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
|
||||
--secondary: oklch(0.93 0.05 285); /* soft pale lavender */
|
||||
--secondary-foreground: oklch(0.25 0.03 285);
|
||||
--secondary: oklch(0.93 0.04 178); /* soft pale teal */
|
||||
--secondary-foreground: oklch(0.25 0.03 180);
|
||||
|
||||
--muted: oklch(0.96 0.01 275);
|
||||
--muted-foreground: oklch(0.4 0.01 278);
|
||||
--muted: oklch(0.96 0.01 175);
|
||||
--muted-foreground: oklch(0.4 0.01 178);
|
||||
|
||||
--accent: oklch(0.86 0.08 275); /* lavender accent */
|
||||
--accent-foreground: oklch(0.5 0.15 280);
|
||||
--accent: oklch(0.86 0.07 175); /* teal accent */
|
||||
--accent-foreground: oklch(0.5 0.12 180);
|
||||
|
||||
--destructive: oklch(0.63 0.18 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
|
||||
--border: oklch(0.92 0.02 284);
|
||||
--border: oklch(0.92 0.02 178);
|
||||
--input: oklch(0.94 0 0);
|
||||
--ring: oklch(0.6 0.2 280);
|
||||
--ring: oklch(0.6 0.15 180);
|
||||
|
||||
/* charts — more variety but still within lavender spectrum */
|
||||
--chart-1: oklch(0.7 0.16 275);
|
||||
--chart-2: oklch(0.6 0.2 280);
|
||||
--chart-3: oklch(0.72 0.18 295); /* slightly more magenta */
|
||||
--chart-4: oklch(0.65 0.15 265); /* slightly bluer lavender */
|
||||
--chart-5: oklch(0.76 0.1 285);
|
||||
/* charts — variety within teal spectrum */
|
||||
--chart-1: oklch(0.7 0.13 175);
|
||||
--chart-2: oklch(0.6 0.15 180);
|
||||
--chart-3: oklch(0.72 0.14 165); /* slightly more green-teal */
|
||||
--chart-4: oklch(0.65 0.12 190); /* slightly bluer teal */
|
||||
--chart-5: oklch(0.76 0.09 182);
|
||||
|
||||
--sidebar: oklch(0.97 0.01 280);
|
||||
--sidebar: oklch(0.97 0.01 178);
|
||||
--sidebar-foreground: oklch(0 0 0);
|
||||
--sidebar-primary: oklch(0.6 0.2 280);
|
||||
--sidebar-primary: oklch(0.6 0.15 180);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.92 0.02 284);
|
||||
--sidebar-accent-foreground: oklch(0.2 0.02 280);
|
||||
--sidebar-border: oklch(0.92 0.02 284);
|
||||
--sidebar-ring: oklch(0.6 0.2 280);
|
||||
--sidebar-accent: oklch(0.92 0.02 178);
|
||||
--sidebar-accent-foreground: oklch(0.2 0.02 180);
|
||||
--sidebar-border: oklch(0.92 0.02 178);
|
||||
--sidebar-ring: oklch(0.6 0.15 180);
|
||||
|
||||
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||
--font-serif: Lora, serif;
|
||||
@@ -86,49 +86,48 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.23 0.01 278);
|
||||
--background: oklch(0.23 0.01 178);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
|
||||
--card: oklch(0.25 0.015 278);
|
||||
--card: oklch(0.25 0.015 178);
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
|
||||
--popover: oklch(0.25 0.015 278);
|
||||
--popover: oklch(0.25 0.015 178);
|
||||
--popover-foreground: oklch(0.95 0 0);
|
||||
|
||||
--primary: oklch(0.56 0.17 280);
|
||||
--primary: oklch(0.56 0.13 180);
|
||||
--primary-foreground: oklch(0.97 0 0);
|
||||
|
||||
--secondary: oklch(0.35 0.03 280);
|
||||
--secondary: oklch(0.35 0.03 180);
|
||||
--secondary-foreground: oklch(0.92 0 0);
|
||||
|
||||
--muted: oklch(0.33 0.02 280);
|
||||
--muted-foreground: oklch(0.7 0.01 280);
|
||||
--muted: oklch(0.33 0.02 178);
|
||||
--muted-foreground: oklch(0.7 0.01 178);
|
||||
|
||||
--accent: oklch(0.44 0.1 278);
|
||||
--accent-foreground: oklch(0.88 0.09 280);
|
||||
--accent: oklch(0.44 0.08 178);
|
||||
--accent-foreground: oklch(0.88 0.08 180);
|
||||
|
||||
--destructive: oklch(0.7 0.17 25);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
|
||||
--border: oklch(0.34 0.02 278);
|
||||
--input: oklch(0.34 0.02 278);
|
||||
--ring: oklch(0.65 0.22 280);
|
||||
--ring: oklch(0.56 0.17 280);
|
||||
--border: oklch(0.34 0.02 178);
|
||||
--input: oklch(0.34 0.02 178);
|
||||
--ring: oklch(0.56 0.13 180);
|
||||
|
||||
--chart-1: oklch(0.68 0.15 275);
|
||||
--chart-2: oklch(0.62 0.2 280);
|
||||
--chart-3: oklch(0.7 0.14 292);
|
||||
--chart-4: oklch(0.65 0.16 265);
|
||||
--chart-5: oklch(0.72 0.1 285);
|
||||
--chart-1: oklch(0.68 0.12 175);
|
||||
--chart-2: oklch(0.62 0.15 180);
|
||||
--chart-3: oklch(0.7 0.11 165);
|
||||
--chart-4: oklch(0.65 0.13 190);
|
||||
--chart-5: oklch(0.72 0.09 182);
|
||||
|
||||
--sidebar: oklch(0.2 0.01 278);
|
||||
--sidebar: oklch(0.2 0.01 178);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-primary: oklch(0.56 0.17 280);
|
||||
--sidebar-primary: oklch(0.56 0.13 180);
|
||||
--sidebar-primary-foreground: oklch(0.97 0 0);
|
||||
--sidebar-accent: oklch(0.35 0.03 280);
|
||||
--sidebar-accent-foreground: oklch(0.65 0.22 280);
|
||||
--sidebar-border: oklch(0.34 0.02 278);
|
||||
--sidebar-ring: oklch(0.65 0.22 280);
|
||||
--sidebar-accent: oklch(0.35 0.03 180);
|
||||
--sidebar-accent-foreground: oklch(0.65 0.15 180);
|
||||
--sidebar-border: oklch(0.34 0.02 178);
|
||||
--sidebar-ring: oklch(0.65 0.15 180);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
||||
Reference in New Issue
Block a user