This commit is contained in:
user
2026-03-27 20:06:38 +02:00
commit 8c45efc92e
544 changed files with 33060 additions and 0 deletions

View 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;

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

View 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;

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

View 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}

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

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

View File

@@ -0,0 +1 @@
<span>Show the running devices list here</span>

View File

@@ -0,0 +1 @@
<span>everything related to links here</span>

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