supported apps domain + some refactor of data types redundancy

This commit is contained in:
user
2026-03-28 16:19:24 +02:00
parent 6639bcd799
commit 671a712b08
26 changed files with 2052 additions and 169 deletions

View File

@@ -1,4 +1,5 @@
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
import AppWindow from "@lucide/svelte/icons/app-window";
import { BellRingIcon, Link } from "@lucide/svelte";
import UserCircle from "~icons/lucide/user-circle";
@@ -25,6 +26,11 @@ export const mainNavTree = [
url: "/links",
icon: Link,
},
{
title: "Supported Apps",
url: "/supported-apps",
icon: AppWindow,
},
] as AppSidebarItem[];
export const secondaryNavTree = [
@@ -40,17 +46,7 @@ export const secondaryNavTree = [
},
] as AppSidebarItem[];
export const SUPPORTED_APPS = [
{
title: "Gmail",
packageName: "com.google.android.gm",
},
{
title: "Outlook",
packageName: "com.microsoft.outlook",
},
// will add more here when support increases
];
export const WS_SCRCPY_URL = "https://iotam-ws-scrcpy.snapyra.com";
export const COMPANY_NAME = "SaaS Template";
export const WEBSITE_URL = "https://company.com";

View File

@@ -1,53 +1,48 @@
import { WS_SCRCPY_URL } from "$lib/core/constants";
import type { Device } from "@pkg/logic/domains/device/data";
import { getDeviceByIdSQ } from "./device.remote";
import { toast } from "svelte-sonner";
type Device = {
id: number;
title: string;
version: string;
status: string;
isActive: boolean;
inUse: boolean;
containerId: string;
host: string;
wsPort: string;
type DeviceForUI = Omit<Device, "createdAt" | "updatedAt"> & {
createdAt: Date | string;
updatedAt: Date | string;
};
function normalizeViewerUrl(host: string, wsPort: string): string | null {
/** Port the scrcpy server listens on inside the Android container. */
const SCRCPY_SERVER_PORT = 8886;
/**
* Builds the ws-scrcpy hash-based stream URL for a device.
*
* Example output:
* https://iotam-ws-scrcpy.snapyra.com/#!action=stream&udid=172.17.0.1:5555
* &player=mse&ws=wss://iotam-ws-scrcpy.snapyra.com/?action=proxy-adb
* &remote=tcp:8886&udid=172.17.0.1:5555
*/
function buildStreamUrl(host: string, wsPort: string): string | null {
const trimmedHost = host.trim();
if (!trimmedHost) return null;
if (trimmedHost.startsWith("http://") || trimmedHost.startsWith("https://")) {
try {
const url = new URL(trimmedHost);
if (!url.port) url.port = wsPort;
return url.toString();
} catch {
return trimmedHost;
}
}
const udid = `${trimmedHost}:${wsPort}`;
const baseWss = WS_SCRCPY_URL.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
const hostWithProtocol = `https://${trimmedHost}`;
const wsParam =
`${baseWss}/?action=proxy-adb&remote=tcp:${SCRCPY_SERVER_PORT}&udid=${encodeURIComponent(udid)}`;
try {
const url = new URL(hostWithProtocol);
if (!url.port) url.port = wsPort;
return url.toString();
} catch {
return null;
}
const hash =
`#!action=stream&udid=${encodeURIComponent(udid)}&player=mse&ws=${encodeURIComponent(wsParam)}`;
return `${WS_SCRCPY_URL}/${hash}`;
}
class DeviceDetailsViewModel {
device = $state<Device | null>(null);
device = $state<DeviceForUI | null>(null);
loading = $state(false);
currentId = $state<number | null>(null);
get streamUrl(): string | null {
if (!this.device) return null;
return normalizeViewerUrl(this.device.host, this.device.wsPort);
return buildStreamUrl(this.device.host, this.device.wsPort);
}
async fetchDevice(id: number) {
@@ -64,7 +59,7 @@ class DeviceDetailsViewModel {
return;
}
this.device = result.data as Device;
this.device = result.data as DeviceForUI;
} catch (error) {
this.device = null;
toast.error("Failed to fetch device", {

View File

@@ -3,8 +3,7 @@
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Switch } from "$lib/components/ui/switch";
type DeviceStatus = "online" | "offline" | "busy" | "error";
import type { DeviceStatusValue } from "@pkg/logic/domains/device/data";
let {
fieldPrefix = "device-form",
@@ -15,7 +14,7 @@
wsPort = $bindable(""),
isActive = $bindable(false),
inUse = $bindable(false),
status = $bindable("offline" as DeviceStatus),
status = $bindable("offline" as DeviceStatusValue),
submitLabel = "Save",
submitting = false,
showAdvanced = false,
@@ -30,7 +29,7 @@
wsPort?: string;
isActive?: boolean;
inUse?: boolean;
status?: DeviceStatus;
status?: DeviceStatusValue;
submitLabel?: string;
submitting?: boolean;
showAdvanced?: boolean;

View File

@@ -1,5 +1,9 @@
import { getDeviceController } from "@pkg/logic/domains/device/controller";
import { createDeviceSchema, updateDeviceSchema } from "@pkg/logic/domains/device/data";
import {
createDeviceSchema,
deviceStatusSchema,
updateDeviceSchema,
} from "@pkg/logic/domains/device/data";
import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
@@ -73,14 +77,14 @@ export const deleteDeviceSC = command(
export const setDeviceStatusSC = command(
v.object({
id: v.number(),
status: v.picklist(["online", "offline", "busy", "error"]),
status: deviceStatusSchema,
}),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await dc.setStatus(fctx, payload.id, payload.status as any);
const res = await dc.setStatus(fctx, payload.id, payload.status);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };

View File

@@ -1,3 +1,9 @@
import {
type CreateDevice,
type Device,
type DeviceStatusValue,
type UpdateDevice,
} from "@pkg/logic/domains/device/data";
import {
listDevicesSQ,
createDeviceSC,
@@ -7,34 +13,6 @@ import {
} from "./device.remote";
import { toast } from "svelte-sonner";
type Device = {
id: number;
title: string;
version: string;
status: string;
isActive: boolean;
inUse: boolean;
containerId: string;
host: string;
wsPort: string;
createdAt: Date;
updatedAt: Date;
};
type CreateDeviceInput = {
title: string;
version: string;
host: string;
containerId: string;
wsPort: string;
isActive?: boolean;
};
type UpdateDeviceInput = CreateDeviceInput & {
status: "online" | "offline" | "busy" | "error";
inUse: boolean;
};
class DeviceViewModel {
devices = $state<Device[]>([]);
loading = $state(false);
@@ -71,7 +49,7 @@ class DeviceViewModel {
}
}
async createDevice(data: CreateDeviceInput): Promise<boolean> {
async createDevice(data: CreateDevice): Promise<boolean> {
this.creating = true;
try {
const result = await createDeviceSC(data);
@@ -132,7 +110,7 @@ class DeviceViewModel {
}
}
async updateDevice(id: number, data: UpdateDeviceInput): Promise<boolean> {
async updateDevice(id: number, data: UpdateDevice): Promise<boolean> {
this.updating = true;
this.editingId = id;
try {
@@ -169,11 +147,11 @@ class DeviceViewModel {
}
}
async setStatus(id: number, status: string) {
async setStatus(id: number, status: DeviceStatusValue) {
try {
const result = await setDeviceStatusSC({
id,
status: status as any,
status,
});
if (result?.error) {
toast.error(

View File

@@ -1,3 +1,6 @@
import type { Device } from "@pkg/logic/domains/device/data";
import type { CreateLink, LinkWithDevice } from "@pkg/logic/domains/link/data";
import type { SupportedApp } from "@pkg/logic/domains/supported-app/data";
import {
listLinksSQ,
createLinkSC,
@@ -6,32 +9,17 @@ import {
assignDeviceSC,
} from "./link.remote";
import { listDevicesSQ } from "../device/device.remote";
import { listSupportedAppsSQ } from "../supported-app/supported-app.remote";
import { toast } from "svelte-sonner";
type Link = {
id: number;
token: string;
status: string;
appName: string;
appPackage: string;
linkedDeviceId: number | null;
expiresAt: Date | null;
lastAccessedAt: Date | null;
createdAt: Date;
updatedAt: Date;
};
type DeviceOption = {
id: number;
title: string;
host: string;
status: string;
inUse: boolean;
};
type DeviceOption = Pick<Device, "id" | "title" | "host" | "status" | "inUse">;
type SupportedAppOption = Pick<SupportedApp, "id" | "title" | "packageName">;
type CreateLinkInput = Omit<CreateLink, "token">;
class LinkViewModel {
links = $state<Link[]>([]);
links = $state<LinkWithDevice[]>([]);
availableDevices = $state<DeviceOption[]>([]);
availableSupportedApps = $state<SupportedAppOption[]>([]);
loading = $state(false);
creating = $state(false);
deletingId = $state<number | null>(null);
@@ -52,7 +40,7 @@ class LinkViewModel {
);
return;
}
this.links = result.data as Link[];
this.links = result.data as LinkWithDevice[];
} catch (error) {
toast.error("Failed to fetch links", {
description:
@@ -69,7 +57,7 @@ class LinkViewModel {
try {
const result = await listDevicesSQ();
if (result?.data) {
this.availableDevices = (result.data as any[]).map((d) => ({
this.availableDevices = (result.data as Device[]).map((d) => ({
id: d.id,
title: d.title,
host: d.host,
@@ -78,16 +66,22 @@ class LinkViewModel {
}));
}
} catch {
// Non-critical select will just be empty
// Non-critical - select will just be empty
}
}
async createLink(data: {
linkedDeviceId: number;
appName: string;
appPackage: string;
expiresAt?: Date | null;
}): Promise<boolean> {
async fetchSupportedAppsForSelect() {
try {
const result = await listSupportedAppsSQ();
if (result?.data) {
this.availableSupportedApps = result.data as SupportedAppOption[];
}
} catch {
// Non-critical - select will just be empty
}
}
async createLink(data: CreateLinkInput): Promise<boolean> {
this.creating = true;
try {
const result = await createLinkSC(data);
@@ -100,9 +94,7 @@ class LinkViewModel {
}
toast.success("Link created");
this.showCreateDialog = false;
if (result.data) {
this.links = [...this.links, result.data as Link];
}
await this.fetchLinks();
return true;
} catch (error) {
toast.error("Failed to create link", {
@@ -197,6 +189,11 @@ class LinkViewModel {
const device = this.availableDevices.find((d) => d.id === deviceId);
return device ? device.title : `Device #${deviceId}`;
}
getSupportedAppName(supportedAppId: number): string {
const app = this.availableSupportedApps.find((d) => d.id === supportedAppId);
return app ? app.title : `App #${supportedAppId}`;
}
}
export const linkVM = new LinkViewModel();

View File

@@ -0,0 +1,63 @@
import { getSupportedAppController } from "@pkg/logic/domains/supported-app/controller";
import {
createSupportedAppSchema,
updateSupportedAppSchema,
} from "@pkg/logic/domains/supported-app/data";
import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
const sac = getSupportedAppController();
export const listSupportedAppsSQ = query(async () => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await sac.list(fctx);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
});
export const createSupportedAppSC = command(
createSupportedAppSchema,
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await sac.create(fctx, payload);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const updateSupportedAppSC = command(
v.object({ id: v.number(), data: updateSupportedAppSchema }),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await sac.update(fctx, payload.id, payload.data);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);
export const deleteSupportedAppSC = command(
v.object({ id: v.number() }),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
if (!fctx.userId) return unauthorized(fctx);
const res = await sac.delete(fctx, payload.id);
return res.isOk()
? { data: res.value, error: null }
: { data: null, error: res.error };
},
);

View File

@@ -0,0 +1,152 @@
import type {
CreateSupportedApp,
SupportedApp,
UpdateSupportedApp,
} from "@pkg/logic/domains/supported-app/data";
import {
createSupportedAppSC,
deleteSupportedAppSC,
listSupportedAppsSQ,
updateSupportedAppSC,
} from "./supported-app.remote";
import { toast } from "svelte-sonner";
class SupportedAppViewModel {
supportedApps = $state<SupportedApp[]>([]);
loading = $state(false);
creating = $state(false);
updating = $state(false);
deletingId = $state<number | null>(null);
showCreateDialog = $state(false);
async fetchSupportedApps() {
this.loading = true;
try {
const result = await listSupportedAppsSQ();
if (result?.error || !result?.data) {
toast.error(
result?.error?.message || "Failed to fetch supported apps",
{
description:
result?.error?.description || "Please try again",
},
);
return;
}
this.supportedApps = result.data as SupportedApp[];
} catch (error) {
toast.error("Failed to fetch supported apps", {
description:
error instanceof Error
? error.message
: "Please try again",
});
} finally {
this.loading = false;
}
}
async createSupportedApp(data: CreateSupportedApp): Promise<boolean> {
this.creating = true;
try {
const result = await createSupportedAppSC(data);
if (result?.error) {
toast.error(
result.error.message || "Failed to create supported app",
{
description:
result.error.description || "Please try again",
},
);
return false;
}
toast.success("Supported app created");
this.showCreateDialog = false;
if (result.data) {
this.supportedApps = [
...this.supportedApps,
result.data as SupportedApp,
];
}
return true;
} catch (error) {
toast.error("Failed to create supported app", {
description:
error instanceof Error
? error.message
: "Please try again",
});
return false;
} finally {
this.creating = false;
}
}
async updateSupportedApp(
id: number,
data: UpdateSupportedApp,
): Promise<boolean> {
this.updating = true;
try {
const result = await updateSupportedAppSC({ id, data });
if (result?.error) {
toast.error(
result.error.message || "Failed to update supported app",
{
description:
result.error.description || "Please try again",
},
);
return false;
}
toast.success("Supported app updated");
if (result.data) {
const updated = result.data as SupportedApp;
this.supportedApps = this.supportedApps.map((app) =>
app.id === id ? updated : app,
);
}
return true;
} catch (error) {
toast.error("Failed to update supported app", {
description:
error instanceof Error
? error.message
: "Please try again",
});
return false;
} finally {
this.updating = false;
}
}
async deleteSupportedApp(id: number) {
this.deletingId = id;
try {
const result = await deleteSupportedAppSC({ id });
if (result?.error) {
toast.error(
result.error.message || "Failed to delete supported app",
{
description:
result.error.description || "Please try again",
},
);
return;
}
toast.success("Supported app deleted");
this.supportedApps = this.supportedApps.filter((app) => app.id !== id);
} catch (error) {
toast.error("Failed to delete supported app", {
description:
error instanceof Error
? error.message
: "Please try again",
});
} finally {
this.deletingId = null;
}
}
}
export const supportedAppVM = new SupportedAppViewModel();

View File

@@ -9,7 +9,7 @@
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, SUPPORTED_APPS } from "$lib/core/constants";
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";
@@ -24,35 +24,26 @@
breadcrumbs.set([mainNavTree[0], mainNavTree[1]]);
let selectedDeviceId = $state("");
let selectedAppPackage = $state("");
let selectedSupportedAppId = $state("");
let expiresAt = $state("");
onMount(async () => {
await linkVM.fetchLinks();
await linkVM.fetchDevicesForSelect();
await linkVM.fetchSupportedAppsForSelect();
});
function resetForm() {
selectedDeviceId = "";
selectedAppPackage = "";
selectedSupportedAppId = "";
expiresAt = "";
}
function getSupportedApp(packageName: string) {
return SUPPORTED_APPS.find((app) => app.packageName === packageName);
}
async function handleCreate(e: Event) {
e.preventDefault();
const selectedApp = getSupportedApp(selectedAppPackage);
if (!selectedApp) {
toast.error("Select a supported app");
return;
}
const success = await linkVM.createLink({
linkedDeviceId: Number(selectedDeviceId),
appName: selectedApp.title,
appPackage: selectedApp.packageName,
supportedAppId: Number(selectedSupportedAppId),
expiresAt: expiresAt ? new Date(expiresAt) : null,
});
if (success) resetForm();
@@ -121,6 +112,7 @@
onclick={() => {
linkVM.showCreateDialog = true;
linkVM.fetchDevicesForSelect();
linkVM.fetchSupportedAppsForSelect();
}}
>
<Icon icon={Plus} cls="h-4 w-4 mr-2" />
@@ -176,11 +168,11 @@
</div>
<div>
<p class="text-muted-foreground">App</p>
<p>{link.appName}</p>
<p
class="text-muted-foreground break-all font-mono text-[11px]"
>
{link.appPackage}
<p>
{link.supportedApp?.title ||
linkVM.getSupportedAppName(
link.supportedAppId,
)}
</p>
</div>
<div>
@@ -301,12 +293,10 @@
<Table.Cell>
<div class="space-y-0.5">
<p class="text-sm font-medium">
{link.appName}
</p>
<p
class="text-muted-foreground font-mono text-[11px]"
>
{link.appPackage}
{link.supportedApp?.title ||
linkVM.getSupportedAppName(
link.supportedAppId,
)}
</p>
</div>
</Table.Cell>
@@ -430,20 +420,19 @@
<Label for="supportedApp">Supported App</Label>
<select
id="supportedApp"
bind:value={selectedAppPackage}
bind:value={selectedSupportedAppId}
required
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="" disabled>Select a supported app</option>
{#each SUPPORTED_APPS as app}
<option value={app.packageName}>
{app.title}{app.packageName}
{#each linkVM.availableSupportedApps as app}
<option value={String(app.id)}>
{app.title}
</option>
{/each}
</select>
<p class="text-muted-foreground text-xs">
Links can only target apps from the current supported app
list.
Supported apps are managed from the dedicated catalogue page.
</p>
</div>
<div class="space-y-2">

View File

@@ -0,0 +1,358 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
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 { supportedAppVM } from "$lib/domains/supported-app/supported-app.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import AppWindow from "@lucide/svelte/icons/app-window";
import Pencil from "@lucide/svelte/icons/pencil";
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 packageName = $state("");
let editId = $state<number | null>(null);
let editTitle = $state("");
let editPackageName = $state("");
let showEditDialog = $state(false);
onMount(async () => {
await supportedAppVM.fetchSupportedApps();
});
function resetCreateForm() {
title = "";
packageName = "";
}
function resetEditForm() {
editId = null;
editTitle = "";
editPackageName = "";
}
async function handleCreate(e: Event) {
e.preventDefault();
const success = await supportedAppVM.createSupportedApp({
title,
packageName,
});
if (success) resetCreateForm();
}
function openEditDialog(app: (typeof supportedAppVM.supportedApps)[number]) {
editId = app.id;
editTitle = app.title;
editPackageName = app.packageName;
showEditDialog = true;
}
async function handleUpdate(e: Event) {
e.preventDefault();
if (!editId) return;
const success = await supportedAppVM.updateSupportedApp(editId, {
title: editTitle,
packageName: editPackageName,
});
if (success) {
showEditDialog = false;
resetEditForm();
}
}
function formatDate(value: Date | string): string {
return new Date(value).toLocaleString();
}
$effect(() => {
if (showEditDialog) return;
resetEditForm();
});
</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={AppWindow} cls="h-5 w-5 text-primary" />
<Card.Title>Supported Apps</Card.Title>
<span class="text-muted-foreground text-xs">
{supportedAppVM.supportedApps.length} total
</span>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => void supportedAppVM.fetchSupportedApps()}
disabled={supportedAppVM.loading}
>
<Icon
icon={RefreshCw}
cls={`mr-2 h-4 w-4 ${supportedAppVM.loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
size="sm"
onclick={() => (supportedAppVM.showCreateDialog = true)}
>
<Icon icon={Plus} cls="mr-2 h-4 w-4" />
Add Supported App
</Button>
</div>
</div>
</Card.Header>
<Card.Content>
{#if !supportedAppVM.loading && supportedAppVM.supportedApps.length === 0}
<div class="py-10 text-center text-sm text-muted-foreground">
No supported apps configured yet.
</div>
{:else}
<div class="space-y-3 md:hidden">
{#each supportedAppVM.supportedApps as app (app.id)}
<div class="rounded-lg border bg-background p-3">
<div class="space-y-1">
<p class="text-sm font-medium">{app.title}</p>
<p
class="text-muted-foreground break-all font-mono text-xs"
>
{app.packageName}
</p>
</div>
<div class="text-muted-foreground mt-3 text-xs">
Updated {formatDate(app.updatedAt)}
</div>
<div class="mt-3 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={() => openEditDialog(app)}
>
<Icon icon={Pencil} cls="h-4 w-4" />
</Button>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={supportedAppVM.deletingId ===
app.id}
>
<Icon icon={Trash2} cls="h-4 w-4" />
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete supported app?
</AlertDialog.Title>
<AlertDialog.Description>
This removes "{app.title}" from
the supported app catalogue.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() =>
supportedAppVM.deleteSupportedApp(
app.id,
)}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</div>
{/each}
</div>
<div class="hidden md:block">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Package Name</Table.Head>
<Table.Head>Updated</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each supportedAppVM.supportedApps as app (app.id)}
<Table.Row>
<Table.Cell class="font-medium">
{app.title}
</Table.Cell>
<Table.Cell class="font-mono text-xs">
{app.packageName}
</Table.Cell>
<Table.Cell class="text-xs">
{formatDate(app.updatedAt)}
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onclick={() =>
openEditDialog(app)}
>
<Icon
icon={Pencil}
cls="h-4 w-4"
/>
Edit
</Button>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={supportedAppVM.deletingId ===
app.id}
>
<Icon
icon={Trash2}
cls="h-4 w-4"
/>
Delete
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete supported
app?
</AlertDialog.Title>
<AlertDialog.Description>
This removes
"{app.title}" from
the supported app
catalogue.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() =>
supportedAppVM.deleteSupportedApp(
app.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>
<Dialog.Root bind:open={supportedAppVM.showCreateDialog}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Add Supported App</Dialog.Title>
<Dialog.Description>
Register an app that links are allowed to lease.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleCreate} class="space-y-4">
<div class="space-y-2">
<Label for="title">Title</Label>
<Input id="title" bind:value={title} required />
</div>
<div class="space-y-2">
<Label for="packageName">Package Name</Label>
<Input
id="packageName"
bind:value={packageName}
placeholder="com.example.app"
required
/>
</div>
<Dialog.Footer>
<Button
variant="outline"
type="button"
onclick={() => (supportedAppVM.showCreateDialog = false)}
>
Cancel
</Button>
<Button type="submit" disabled={supportedAppVM.creating}>
{supportedAppVM.creating ? "Creating..." : "Create"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={showEditDialog}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Edit Supported App</Dialog.Title>
<Dialog.Description>
Update the supported app record used by the links flow.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleUpdate} class="space-y-4">
<div class="space-y-2">
<Label for="edit-title">Title</Label>
<Input id="edit-title" bind:value={editTitle} required />
</div>
<div class="space-y-2">
<Label for="edit-packageName">Package Name</Label>
<Input
id="edit-packageName"
bind:value={editPackageName}
placeholder="com.example.app"
required
/>
</div>
<Dialog.Footer>
<Button
variant="outline"
type="button"
onclick={() => {
showEditDialog = false;
resetEditForm();
}}
>
Cancel
</Button>
<Button type="submit" disabled={supportedAppVM.updating}>
{supportedAppVM.updating ? "Saving..." : "Save Changes"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>