initttt
This commit is contained in:
13
apps/main/src/routes/(main)/+layout.server.ts
Normal file
13
apps/main/src/routes/(main)/+layout.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { auth } from "@pkg/logic/domains/auth/config.base";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { LayoutServerLoad } from "./$types";
|
||||
|
||||
export const load = (async (c) => {
|
||||
const sess = await auth.api.getSession({
|
||||
headers: c.request.headers,
|
||||
});
|
||||
if ((!sess?.user || !sess?.session) && c.url.pathname !== "/auth/login") {
|
||||
return redirect(302, "/auth/login");
|
||||
}
|
||||
return { user: c.locals.user, session: c.locals.session };
|
||||
}) satisfies LayoutServerLoad;
|
||||
58
apps/main/src/routes/(main)/+layout.svelte
Normal file
58
apps/main/src/routes/(main)/+layout.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import AppSidebar from "$lib/components/app-sidebar.svelte";
|
||||
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import { breadcrumbs, session, user } from "$lib/global.stores";
|
||||
import { onMount } from "svelte";
|
||||
import type { LayoutData } from "./$types";
|
||||
|
||||
breadcrumbs.set([mainNavTree[0]]);
|
||||
|
||||
let { children, data }: { children: any; data: LayoutData } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (data.user) {
|
||||
user.set(data.user);
|
||||
}
|
||||
if (data.session) {
|
||||
session.set(data.session);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider>
|
||||
<AppSidebar />
|
||||
<Sidebar.Inset>
|
||||
<header
|
||||
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-16"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-4">
|
||||
<Sidebar.Trigger class="-ml-1" />
|
||||
<Separator orientation="vertical" class="mr-2 h-4" />
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
{#if $breadcrumbs.length > 0}
|
||||
{#each $breadcrumbs as breadcrumb, i}
|
||||
<Breadcrumb.Item class="hidden md:block">
|
||||
<Breadcrumb.Link href={breadcrumb.url}>
|
||||
{breadcrumb.title}
|
||||
</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
{#if i < $breadcrumbs.length - 1}
|
||||
<Breadcrumb.Separator
|
||||
class="hidden md:block"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
</div>
|
||||
</header>
|
||||
<main class="p-8 md:p-4">
|
||||
{@render children()}
|
||||
</main>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
6
apps/main/src/routes/(main)/+page.server.ts
Normal file
6
apps/main/src/routes/(main)/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load = (async (c) => {
|
||||
throw redirect(302, "/dashboard");
|
||||
}) satisfies PageServerLoad;
|
||||
12
apps/main/src/routes/(main)/+page.svelte
Normal file
12
apps/main/src/routes/(main)/+page.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
breadcrumbs.set([mainNavTree[0]]);
|
||||
|
||||
onMount(() => {
|
||||
goto("/dashboard");
|
||||
});
|
||||
</script>
|
||||
134
apps/main/src/routes/(main)/account/+layout.svelte
Normal file
134
apps/main/src/routes/(main)/account/+layout.svelte
Normal file
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { goto, onNavigate } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import * as Sheet from "$lib/components/ui/sheet/index.js";
|
||||
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||
import { secondaryNavTree } from "$lib/core/constants";
|
||||
import Menu from "@lucide/svelte/icons/menu";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let chosen = $state(secondaryNavTree[0].url);
|
||||
let sheetOpen = $state(false);
|
||||
let isMobile = $state(false);
|
||||
|
||||
// Check if mobile on initial load and set up resize listener
|
||||
function updateIsMobile() {
|
||||
if (browser) {
|
||||
isMobile = window.innerWidth < 768;
|
||||
}
|
||||
}
|
||||
|
||||
onNavigate((info) => {
|
||||
if (!info || !info.to) return;
|
||||
chosen = info.to.url.pathname;
|
||||
// Close sheet after navigation on mobile
|
||||
if (isMobile) {
|
||||
sheetOpen = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial state and add resize listener
|
||||
$effect(() => {
|
||||
updateIsMobile();
|
||||
|
||||
if (browser) {
|
||||
const handleResize = () => {
|
||||
updateIsMobile();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Function to handle navigation from tabs
|
||||
function handleNavigation(url: string) {
|
||||
goto(url);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (page.url.pathname !== secondaryNavTree[0].url) {
|
||||
const found = secondaryNavTree.find(
|
||||
(each) => each.url === page.url.pathname,
|
||||
);
|
||||
if (found) {
|
||||
chosen = found.url;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper
|
||||
cls="grid grid-cols-1 gap-4 md:grid-cols-5 lg:grid-cols-4 max-w-5xl pt-20"
|
||||
>
|
||||
<!-- Desktop sidebar - hidden on mobile -->
|
||||
<div class="hidden w-full md:col-span-2 md:block lg:col-span-1">
|
||||
<Tabs.Root value={chosen}>
|
||||
<Tabs.List class="flex h-full w-full flex-col gap-2">
|
||||
{#each secondaryNavTree as each}
|
||||
<Tabs.Trigger
|
||||
class="flex w-full items-start gap-2 text-start"
|
||||
value={each.url}
|
||||
onclick={() => {
|
||||
handleNavigation(each.url);
|
||||
}}
|
||||
>
|
||||
<Icon icon={each.icon} cls="w-4 h-4" />
|
||||
<p class="w-full">
|
||||
{each.title}
|
||||
</p>
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full md:col-span-3">
|
||||
{@render children()}
|
||||
</div>
|
||||
</MaxWidthWrapper>
|
||||
|
||||
<!-- FAB for mobile - fixed position -->
|
||||
{#if isMobile}
|
||||
<button
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring fixed right-6 bottom-6 z-40 flex h-14 w-14 items-center justify-center rounded-full shadow-lg focus:ring-2 focus:ring-offset-2 focus:outline-none"
|
||||
onclick={() => (sheetOpen = true)}
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu class="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<!-- Sheet for mobile navigation -->
|
||||
<Sheet.Root bind:open={sheetOpen}>
|
||||
<Sheet.Content side="bottom">
|
||||
<div class="py-6">
|
||||
<Tabs.Root value={chosen}>
|
||||
<Tabs.List class="flex h-full w-full flex-col gap-4">
|
||||
{#each secondaryNavTree as each}
|
||||
<Tabs.Trigger
|
||||
class="flex w-full items-center gap-3 text-start"
|
||||
value={each.url}
|
||||
onclick={() => {
|
||||
handleNavigation(each.url);
|
||||
}}
|
||||
>
|
||||
<Icon icon={each.icon} cls="w-5 h-5" />
|
||||
<p class="w-full text-base">
|
||||
{each.title}
|
||||
</p>
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
{/if}
|
||||
261
apps/main/src/routes/(main)/account/+page.svelte
Normal file
261
apps/main/src/routes/(main)/account/+page.svelte
Normal file
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import * as Avatar from "$lib/components/ui/avatar";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Separator } from "$lib/components/ui/separator";
|
||||
import { secondaryNavTree } from "$lib/core/constants";
|
||||
import { accountVM } from "$lib/domains/account/account.vm.svelte";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import AtSign from "@lucide/svelte/icons/at-sign";
|
||||
import KeyRound from "@lucide/svelte/icons/key-round";
|
||||
import Save from "@lucide/svelte/icons/save";
|
||||
import Upload from "@lucide/svelte/icons/upload";
|
||||
import User from "@lucide/svelte/icons/user";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
breadcrumbs.set([secondaryNavTree[0]]);
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const user = $state(data.user!);
|
||||
|
||||
// Separate form state for profile and password
|
||||
let profileData = $state({
|
||||
name: user.name ?? "",
|
||||
username: user.username ?? "",
|
||||
});
|
||||
|
||||
let passwordData = $state({
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
// Handle profile form submission (name, username)
|
||||
async function handleProfileSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
await accountVM.updateProfile(profileData);
|
||||
}
|
||||
|
||||
// Handle password form submission
|
||||
async function handlePasswordSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (
|
||||
passwordData.password.length >= 6 &&
|
||||
passwordData.password === passwordData.confirmPassword
|
||||
) {
|
||||
const didChange = await accountVM.changePassword(
|
||||
passwordData.password,
|
||||
);
|
||||
if (didChange) {
|
||||
passwordData.password = "";
|
||||
passwordData.confirmPassword = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image upload - would connect to your storage service
|
||||
function handleImageUpload() {
|
||||
// In a real implementation, this would trigger a file picker
|
||||
console.log("Image upload triggered");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Profile Information -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="text-center">
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<div class="relative">
|
||||
<Avatar.Root class="border-muted h-24 w-24 border-2">
|
||||
{#if user.image}
|
||||
<Avatar.Image
|
||||
src={user.image}
|
||||
alt={user.name || "User"}
|
||||
/>
|
||||
{:else}
|
||||
<Avatar.Fallback class="bg-primary/10 text-xl">
|
||||
{(user.name || "User")
|
||||
.substring(0, 2)
|
||||
.toUpperCase()}
|
||||
</Avatar.Fallback>
|
||||
{/if}
|
||||
</Avatar.Root>
|
||||
<button
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-ring absolute right-0 bottom-0 rounded-full p-1.5 shadow focus:ring-2 focus:ring-offset-2 focus:outline-none"
|
||||
onclick={handleImageUpload}
|
||||
aria-label="Upload new profile image"
|
||||
type="button"
|
||||
>
|
||||
<Icon icon={Upload} cls="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-foreground text-xl font-semibold">
|
||||
{user.name}
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Member since {new Date(
|
||||
user.createdAt.toString(),
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="mt-4 mb-8" />
|
||||
|
||||
<Card.Title>Personal Information</Card.Title>
|
||||
<Card.Description>
|
||||
Update your personal information and how others see you on the
|
||||
platform.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-6">
|
||||
<!-- Profile Form (Name, Username) -->
|
||||
<form onsubmit={handleProfileSubmit} class="space-y-6">
|
||||
<!-- Full Name -->
|
||||
<div class="grid grid-cols-1 gap-1.5">
|
||||
<Label for="name" class="flex items-center gap-1.5">
|
||||
<Icon
|
||||
icon={User}
|
||||
cls="h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
Full Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
bind:value={profileData.name}
|
||||
placeholder="Your name"
|
||||
minlength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Username -->
|
||||
<div class="grid grid-cols-1 gap-1.5">
|
||||
<Label for="username" class="flex items-center gap-1.5">
|
||||
<Icon
|
||||
icon={AtSign}
|
||||
cls="h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
bind:value={profileData.username}
|
||||
placeholder="username"
|
||||
minlength={6}
|
||||
maxlength={32}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
This is your public username visible to other users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={accountVM.loading}
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
{#if accountVM.loading}
|
||||
<Icon icon={Save} cls="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{:else}
|
||||
<Icon icon={Save} cls="h-4 w-4 mr-2" />
|
||||
Save Profile
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Last updated: {new Date(
|
||||
user.updatedAt.toString(),
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Password Settings -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Password Settings</Card.Title>
|
||||
<Card.Description>
|
||||
Update your account password.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form onsubmit={handlePasswordSubmit} class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-1.5">
|
||||
<Label for="password" class="flex items-center gap-1.5">
|
||||
<Icon
|
||||
icon={KeyRound}
|
||||
cls="h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
New Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={passwordData.password}
|
||||
placeholder="Enter new password"
|
||||
minlength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-1.5">
|
||||
<Label
|
||||
for="confirm-password"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<Icon
|
||||
icon={KeyRound}
|
||||
cls="h-3.5 w-3.5 text-muted-foreground"
|
||||
/>
|
||||
Confirm Password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={passwordData.confirmPassword}
|
||||
placeholder="Re-enter new password"
|
||||
minlength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={accountVM.passwordLoading ||
|
||||
passwordData.password.length < 6 ||
|
||||
passwordData.password !==
|
||||
passwordData.confirmPassword}
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
{#if accountVM.passwordLoading}
|
||||
<Icon
|
||||
icon={KeyRound}
|
||||
cls="h-4 w-4 mr-2 animate-spin"
|
||||
/>
|
||||
Updating...
|
||||
{:else}
|
||||
<Icon icon={KeyRound} cls="h-4 w-4 mr-2" />
|
||||
Update Password
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Choose a strong password with at least 6 characters.
|
||||
</p>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
267
apps/main/src/routes/(main)/dashboard/+page.svelte
Normal file
267
apps/main/src/routes/(main)/dashboard/+page.svelte
Normal file
@@ -0,0 +1,267 @@
|
||||
<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>
|
||||
</MaxWidthWrapper>
|
||||
1
apps/main/src/routes/(main)/devices/+page.svelte
Normal file
1
apps/main/src/routes/(main)/devices/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<span>Show the running devices list here</span>
|
||||
1
apps/main/src/routes/(main)/links/+page.svelte
Normal file
1
apps/main/src/routes/(main)/links/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<span>everything related to links here</span>
|
||||
27
apps/main/src/routes/(main)/notifications/+page.svelte
Normal file
27
apps/main/src/routes/(main)/notifications/+page.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { secondaryNavTree } from "$lib/core/constants";
|
||||
import { notificationViewModel } from "$lib/domains/notifications/notification.vm.svelte";
|
||||
import NotificationsTable from "$lib/domains/notifications/notifications-table.svelte";
|
||||
import { breadcrumbs, user } from "$lib/global.stores";
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
// Set breadcrumb to notifications
|
||||
breadcrumbs.set([secondaryNavTree[1]]);
|
||||
|
||||
onMount(() => {
|
||||
// Ensure user ID is set in the view model
|
||||
const currentUser = get(user);
|
||||
if (currentUser?.id) {
|
||||
notificationViewModel.filters = {
|
||||
...notificationViewModel.filters,
|
||||
userId: currentUser.id,
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper cls="space-y-8 pt-12">
|
||||
<NotificationsTable />
|
||||
</MaxWidthWrapper>
|
||||
Reference in New Issue
Block a user