a new 'base app' which gon get deployed on prem

This commit is contained in:
user
2026-03-28 11:31:16 +02:00
parent c7c303a934
commit 94af2a2065
17 changed files with 2525 additions and 322 deletions

150
apps/front/old.server.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Mobile Proxy Test — Bun Reverse Proxy Server
*
* Sits on port 3000 (what ngrok tunnels) and:
* GET / -> serves our clean mobile viewer (view.html)
* Everything else -> reverse-proxied to ws-scrcpy on port 8000
*
* WebSocket upgrades are proxied transparently so the ws-scrcpy
* player running inside our iframe can talk to the scrcpy server.
*/
const WS_SCRCPY_ORIGIN = "http://localhost:8000";
const PROXY_PORT = 3000;
const viewHtml = await Bun.file(
new URL("./view.html", import.meta.url).pathname,
).text();
const server = Bun.serve({
port: PROXY_PORT,
hostname: "0.0.0.0",
async fetch(req, server) {
const url = new URL(req.url);
// Serve our clean mobile viewer at root only
// (the iframe loads ws-scrcpy's own page via /?scrcpy=1)
if (
(url.pathname === "/" || url.pathname === "/index.html") &&
!url.searchParams.has("scrcpy")
) {
return new Response(viewHtml, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// Upgrade WebSocket connections — proxy to ws-scrcpy
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const targetUrl = `${WS_SCRCPY_ORIGIN.replace("http", "ws")}${url.pathname}${url.search}`;
// Use Bun's WebSocket upgrade to establish a client-side WS to ws-scrcpy
// and bridge the two connections
const success = server.upgrade(req, {
data: { targetUrl },
});
if (success) return undefined;
return new Response("WebSocket upgrade failed", { status: 400 });
}
// Proxy all other HTTP requests to ws-scrcpy
const targetUrl = `${WS_SCRCPY_ORIGIN}${url.pathname}${url.search}`;
try {
const proxyRes = await fetch(targetUrl, {
method: req.method,
headers: req.headers,
body:
req.method !== "GET" && req.method !== "HEAD"
? req.body
: undefined,
});
// Clone response with CORS headers stripped / adjusted
const headers = new Headers(proxyRes.headers);
headers.delete("content-encoding"); // Bun handles this
return new Response(proxyRes.body, {
status: proxyRes.status,
statusText: proxyRes.statusText,
headers,
});
} catch (err) {
console.error(`Proxy error for ${url.pathname}:`, err);
return new Response(
"ws-scrcpy not reachable — is it running on port 8000?",
{
status: 502,
},
);
}
},
websocket: {
async open(ws) {
const { targetUrl } = ws.data as { targetUrl: string };
// Open a WebSocket connection to ws-scrcpy
const upstream = new WebSocket(targetUrl);
// Store upstream reference on ws data for cleanup
(ws.data as any).upstream = upstream;
upstream.binaryType = "arraybuffer";
upstream.onopen = () => {
// Connection established, nothing extra needed
};
upstream.onmessage = (event) => {
try {
if (event.data instanceof ArrayBuffer) {
ws.sendBinary(new Uint8Array(event.data));
} else {
ws.sendText(event.data);
}
} catch {
// Client disconnected
}
};
upstream.onclose = () => {
ws.close();
};
upstream.onerror = (err) => {
console.error("Upstream WS error:", err);
ws.close();
};
},
message(ws, message) {
const upstream = (ws.data as any).upstream as WebSocket | undefined;
if (!upstream || upstream.readyState !== WebSocket.OPEN) return;
try {
if (typeof message === "string") {
upstream.send(message);
} else {
upstream.send(message);
}
} catch {
// Upstream disconnected
}
},
close(ws) {
const upstream = (ws.data as any).upstream as WebSocket | undefined;
if (upstream && upstream.readyState === WebSocket.OPEN) {
upstream.close();
}
},
},
});
console.log(`\n Mobile Proxy running on http://localhost:${server.port}`);
console.log(` Proxying to ws-scrcpy at ${WS_SCRCPY_ORIGIN}`);
console.log(
`\n Point ngrok at port ${server.port}, then open the ngrok URL on the user's phone.\n`,
);

View File

@@ -163,9 +163,9 @@
`;
function injectCSS(doc) {
if (doc.querySelector("#maitm-hide-css")) return;
if (doc.querySelector("#iotam-hide-css")) return;
const style = doc.createElement("style");
style.id = "maitm-hide-css";
style.id = "iotam-hide-css";
style.textContent = HIDE_CSS;
doc.head.appendChild(style);
}

View File

@@ -22,8 +22,7 @@ export const first: Handle = async ({ event, resolve }) => {
event.url.pathname.includes("/api/auth") ||
event.url.pathname.includes("/api/debug") ||
event.url.pathname.includes("/api/chat") ||
event.url.pathname.includes("/auth") ||
event.url.pathname.includes("/link")
event.url.pathname.includes("/auth")
) {
return await resolve(event);
}

View File

@@ -78,7 +78,9 @@ class DeviceViewModel {
}
toast.success("Device created");
this.showCreateDialog = false;
await this.fetchDevices();
if (result.data) {
this.devices = [...this.devices, result.data as Device];
}
return true;
} catch (error) {
toast.error("Failed to create device", {
@@ -108,7 +110,7 @@ class DeviceViewModel {
return;
}
toast.success("Device deleted");
await this.fetchDevices();
this.devices = this.devices.filter((d) => d.id !== id);
} catch (error) {
toast.error("Failed to delete device", {
description:

View File

@@ -0,0 +1,196 @@
import {
listLinksSQ,
createLinkSC,
revokeLinkSC,
deleteLinkSC,
assignDeviceSC,
} from "./link.remote";
import { listDevicesSQ } from "../device/device.remote";
import { toast } from "svelte-sonner";
type Link = {
id: number;
token: string;
status: string;
linkedDeviceId: number | null;
expiresAt: Date | null;
lastAccessedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type DeviceOption = {
id: number;
title: string;
host: string;
status: string;
};
class LinkViewModel {
links = $state<Link[]>([]);
availableDevices = $state<DeviceOption[]>([]);
loading = $state(false);
creating = $state(false);
deletingId = $state<number | null>(null);
revokingId = $state<number | null>(null);
showCreateDialog = $state(false);
async fetchLinks() {
this.loading = true;
try {
const result = await listLinksSQ();
if (result?.error || !result?.data) {
toast.error(
result?.error?.message || "Failed to fetch links",
{
description:
result?.error?.description || "Please try again",
},
);
return;
}
this.links = result.data as Link[];
} catch (error) {
toast.error("Failed to fetch links", {
description:
error instanceof Error
? error.message
: "Please try again",
});
} finally {
this.loading = false;
}
}
async fetchDevicesForSelect() {
try {
const result = await listDevicesSQ();
if (result?.data) {
this.availableDevices = (result.data as any[]).map((d) => ({
id: d.id,
title: d.title,
host: d.host,
status: d.status,
}));
}
} catch {
// Non-critical — select will just be empty
}
}
async createLink(data: {
linkedDeviceId?: number | null;
expiresAt?: Date | null;
}): Promise<boolean> {
this.creating = true;
try {
const result = await createLinkSC(data);
if (result?.error) {
toast.error(result.error.message || "Failed to create link", {
description:
result.error.description || "Please try again",
});
return false;
}
toast.success("Link created");
this.showCreateDialog = false;
if (result.data) {
this.links = [...this.links, result.data as Link];
}
return true;
} catch (error) {
toast.error("Failed to create link", {
description:
error instanceof Error
? error.message
: "Please try again",
});
return false;
} finally {
this.creating = false;
}
}
async revokeLink(id: number) {
this.revokingId = id;
try {
const result = await revokeLinkSC({ id });
if (result?.error) {
toast.error(result.error.message || "Failed to revoke link", {
description:
result.error.description || "Please try again",
});
return;
}
toast.success("Link revoked");
await this.fetchLinks();
} catch (error) {
toast.error("Failed to revoke link", {
description:
error instanceof Error
? error.message
: "Please try again",
});
} finally {
this.revokingId = null;
}
}
async deleteLink(id: number) {
this.deletingId = id;
try {
const result = await deleteLinkSC({ id });
if (result?.error) {
toast.error(result.error.message || "Failed to delete link", {
description:
result.error.description || "Please try again",
});
return;
}
toast.success("Link deleted");
this.links = this.links.filter((l) => l.id !== id);
} catch (error) {
toast.error("Failed to delete link", {
description:
error instanceof Error
? error.message
: "Please try again",
});
} finally {
this.deletingId = null;
}
}
async assignDevice(linkId: number, deviceId: number | null) {
try {
const result = await assignDeviceSC({ id: linkId, deviceId });
if (result?.error) {
toast.error(
result.error.message || "Failed to assign device",
{
description:
result.error.description || "Please try again",
},
);
return;
}
toast.success(deviceId ? "Device assigned" : "Device unassigned");
await this.fetchLinks();
} catch (error) {
toast.error("Failed to assign device", {
description:
error instanceof Error
? error.message
: "Please try again",
});
}
}
getDeviceName(deviceId: number | null): string {
if (!deviceId) return "—";
const device = this.availableDevices.find((d) => d.id === deviceId);
return device ? device.title : `Device #${deviceId}`;
}
}
export const linkVM = new LinkViewModel();

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1 @@
<span>device id page</span>

View File

@@ -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>

View File

@@ -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 {

1
apps/ws-scrcpy Submodule

Submodule apps/ws-scrcpy added at ef273d97c6