supported apps domain + some refactor of data types redundancy
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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">
|
||||
|
||||
358
apps/main/src/routes/(main)/supported-apps/+page.svelte
Normal file
358
apps/main/src/routes/(main)/supported-apps/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user