This commit is contained in:
Lyz Coote
2026-02-02 18:41:13 +01:00
commit d6a5cb8a67
161 changed files with 8630 additions and 0 deletions

View File

@@ -0,0 +1,444 @@
<script lang="ts">
import { browser } from "$app/environment";
import { page, navigating } from "$app/state";
import { beforeNavigate, goto } from "$app/navigation";
import { locales, localizeHref } from "$lib/paraglide/runtime";
import { unsavedChanges, sidebarOpen } from "$lib/stores/app";
import "../layout.css";
import { onMount } from "svelte";
import * as m from "$lib/paraglide/messages.js";
import { GetConfig } from "$lib/wailsjs/go/main/App";
import type { utils } from "$lib/wailsjs/go/models";
import { Toaster } from "$lib/components/ui/sonner/index.js";
import AppSidebar from "$lib/components/SidebarApp.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import {
PanelRightClose,
PanelRightOpen,
House,
Settings,
} from "@lucide/svelte";
import { Separator } from "$lib/components/ui/separator/index.js";
import { toast } from "svelte-sonner";
import {
WindowMinimise,
WindowMaximise,
WindowUnmaximise,
WindowIsMaximised,
Quit,
} from "$lib/wailsjs/runtime/runtime";
let versionInfo: utils.Config | null = $state(null);
let isMaximized = $state(false);
async function syncMaxState() {
isMaximized = await WindowIsMaximised();
}
beforeNavigate(({ cancel }) => {
if ($unsavedChanges) {
toast.warning(m.unsaved_changes_warning());
cancel();
}
});
async function toggleMaximize() {
if (isMaximized) {
WindowUnmaximise();
} else {
WindowMaximise();
}
isMaximized = !isMaximized;
}
function minimize() {
WindowMinimise();
}
function closeWindow() {
Quit();
}
function onTitlebarDblClick() {
toggleMaximize();
}
onMount(async () => {
versionInfo = await GetConfig();
});
let { children } = $props();
const THEME_KEY = "emly_theme";
let theme = $state<"dark" | "light">("dark");
function applyTheme(next: "dark" | "light") {
theme = next;
if (!browser) return;
document.documentElement.classList.toggle("dark", next === "dark");
try {
localStorage.setItem(THEME_KEY, next);
} catch {
// ignore
}
}
$effect(() => {
if (!browser) return;
let stored: string | null = null;
try {
stored = localStorage.getItem(THEME_KEY);
} catch {
stored = null;
}
applyTheme(stored === "light" ? "light" : "dark");
});
syncMaxState();
</script>
<div class="app">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="titlebar"
ondblclick={onTitlebarDblClick}
style="--wails-draggable:drag"
>
<div class="title">
<bold>EMLy</bold>
<div class="version-wrapper">
<version
>v{versionInfo?.EMLy.GUISemver}_{versionInfo?.EMLy
.GUIReleaseChannel}</version
>
{#if versionInfo}
<div class="version-tooltip">
<div class="tooltip-item">
<span class="label">GUI:</span>
<span class="value">v{versionInfo.EMLy.GUISemver}</span>
<span class="channel">({versionInfo.EMLy.GUIReleaseChannel})</span
>
</div>
<div class="tooltip-item">
<span class="label">SDK:</span>
<span class="value">v{versionInfo.EMLy.SDKDecoderSemver}</span>
<span class="channel"
>({versionInfo.EMLy.SDKDecoderReleaseChannel})</span
>
</div>
</div>
{/if}
</div>
</div>
<div class="controls">
<button class="btn" onclick={minimize}>─</button>
<button class="btn" onclick={toggleMaximize}>
{#if isMaximized}
{:else}
{/if}
</button>
<button class="btn close" onclick={closeWindow}>✕</button>
</div>
</div>
<div class="content">
<Sidebar.Provider>
{#if $sidebarOpen}
<AppSidebar />
{/if}
<main>
<!-- <Sidebar.Trigger /> -->
<Toaster />
{#await navigating?.complete}
<div class="loading-overlay">
<div class="spinner"></div>
<span style="opacity: 0.5; font-size: 13px">Loading...</span>
</div>
{:then}
{@render children()}
{/await}
</main>
</Sidebar.Provider>
</div>
<div class="footerbar">
{#if !$sidebarOpen}
<PanelRightClose
size="17"
onclick={() => {
$sidebarOpen = !$sidebarOpen;
}}
style="cursor: pointer;"
/>
{:else}
<PanelRightOpen
size="17"
onclick={() => {
$sidebarOpen = !$sidebarOpen;
}}
style="cursor: pointer;"
/>
{/if}
<Separator orientation="vertical" />
<House
size="16"
onclick={() => {
if(page.url.pathname !== "/") goto("/");
}}
style="cursor: pointer; opacity: 0.7;"
class="hover:opacity-100 transition-opacity"
/>
<Settings
size="16"
onclick={() => {
if (page.url.pathname !== "/settings" && page.url.pathname !== "/settings/") goto("/settings");
}}
style="cursor: pointer; opacity: 0.7;"
class="hover:opacity-100 transition-opacity"
/>
</div>
<div style="display:none">
{#each locales as locale}
<a href={localizeHref(page.url.pathname, { locale })}>
{locale}
</a>
{/each}
</div>
</div>
<style>
:global(body) {
margin: 0;
background: oklch(0 0 0);
color: #eaeaea;
font-family: system-ui, sans-serif;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.titlebar {
height: 32px;
background: oklch(0 0 0);
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 12px;
-webkit-app-region: drag;
user-select: none;
flex: 0 0 32px;
z-index: 50;
position: relative;
}
.footerbar {
height: 32px;
background: oklch(0 0 0);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 0 12px;
user-select: none;
flex: 0 0 32px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.title {
font-size: 13px;
opacity: 0.9;
color: gray;
}
.title bold {
font-weight: 600;
color: white;
opacity: 0.7;
}
.title version {
color: rgb(228, 221, 221);
opacity: 0.4;
}
.version-wrapper {
position: relative;
display: inline-block;
cursor: default;
}
.version-tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
top: 100%;
left: 0;
background-color: #111;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 8px 12px;
z-index: 1000;
margin-top: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
transition: all 0.2s ease-in-out;
transform: translateY(-5px);
pointer-events: none;
-webkit-app-region: no-drag;
}
.version-wrapper:hover .version-tooltip {
visibility: visible;
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.tooltip-item {
display: grid;
grid-template-columns: 40px auto auto;
gap: 8px;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
align-items: center;
}
.tooltip-item .label {
color: #9ca3af;
}
.tooltip-item .value {
color: #f3f4f6;
font-family: monospace;
}
.tooltip-item .channel {
color: #6b7280;
font-size: 10px;
}
.controls {
display: flex;
height: 100%;
opacity: 0.5;
}
.btn {
width: 46px;
height: 100%;
border: none;
background: transparent;
color: white;
font-size: 14px;
cursor: pointer;
-webkit-app-region: no-drag;
}
.btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.02);
}
.close:hover {
background: #e81123;
}
.content {
flex: 1 1 auto;
min-height: 0;
display: flex;
background: oklch(0 0 0);
overflow: hidden;
position: relative;
}
main {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
position: relative;
}
/* Override Shadcn Sidebar defaults to fit in content area */
:global(.content .group\/sidebar-wrapper) {
min-height: 0 !important;
height: 100% !important;
}
/* Target the fixed container of the sidebar */
:global(.content [data-slot="sidebar-container"]) {
position: absolute !important;
height: 100% !important;
/* Ensure it doesn't take viewport height */
max-height: 100% !important;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
::-webkit-scrollbar-corner {
background: transparent;
}
.loading-overlay {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
background: oklch(0 0 0);
}
.spinner {
width: 32px;
height: 32px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-top-color: rgba(255, 255, 255, 0.8);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import MailViewer from "$lib/components/dashboard/MailViewer.svelte";
import { mailState } from "$lib/stores/mail-state.svelte";
let { data } = $props();
$effect(() => {
if (data.email) {
mailState.setParams(data.email);
}
});
</script>
<div class="page">
<section class="center" aria-label="Overview">
<MailViewer />
</section>
</div>
<style>
.page {
height: 100%;
min-height: 0;
display: flex;
gap: 12px;
padding: 12px;
box-sizing: border-box;
overflow: hidden;
}
.center {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
::-webkit-scrollbar-corner {
background: transparent;
}
</style>

View File

@@ -0,0 +1,32 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { GetImageViewerData, GetStartupFile, ReadEML } from '$lib/wailsjs/go/main/App';
import DOMPurify from 'dompurify';
export const load: PageLoad = async () => {
try {
// Check if we are in viewer mode
const viewerData = await GetImageViewerData();
if (viewerData && viewerData.data) {
throw redirect(302, "/image-viewer");
}
// Check if opened with a file
const startupFile = await GetStartupFile();
if (startupFile) {
const emlContent = await ReadEML(startupFile);
if (emlContent) {
emlContent.body = DOMPurify.sanitize(emlContent.body || "");
return { email: emlContent };
}
}
} catch (e) {
// If it's a redirect, re-throw it so SvelteKit handles it
if ((e as any)?.status === 302 || (e as any)?.status === 307 || (e as any)?.status === 303 || (e as any)?.location) {
throw e;
}
console.error("Error in load function:", e);
}
return { email: null };
};

View File

@@ -0,0 +1,478 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Label } from "$lib/components/ui/label";
import { Separator } from "$lib/components/ui/separator";
import { Switch } from "$lib/components/ui/switch";
import { ChevronLeft, Command, Option, Flame } from "@lucide/svelte";
import type { EMLy_GUI_Settings } from "$lib/types";
import { toast } from "svelte-sonner";
import { It, Us } from "svelte-flags";
import * as RadioGroup from "$lib/components/ui/radio-group/index.js";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { Checkbox } from "$lib/components/ui/checkbox/index.js";
import {
dismissUnsavedChangesToast,
showUnsavedChangesToast,
} from "$lib/utils/unsaved-changes-toast";
import { dangerZoneEnabled, unsavedChanges } from "$lib/stores/app";
import { LogDebug } from "$lib/wailsjs/runtime/runtime";
import { settingsStore } from "$lib/stores/settings.svelte";
import { GetMachineData } from "$lib/wailsjs/go/main/App";
import * as m from "$lib/paraglide/messages";
import { setLocale } from "$lib/paraglide/runtime";
import { mailState } from "$lib/stores/mail-state.svelte.js";
let { data } = $props();
let machineData = $derived(data.machineData);
let config = $derived(data.config);
const defaults: EMLy_GUI_Settings = {
selectedLanguage: "it",
useBuiltinPreview: true,
previewFileSupportedTypes: ["jpg", "jpeg", "png"],
};
async function setLanguage(lang: EMLy_GUI_Settings["selectedLanguage"] | null) {
if (!browser) return;
try {
await setLocale(lang || "en", {reload: false});
toast.success(m.settings_toast_language_changed());
} catch {
toast.error(m.settings_toast_language_change_failed());
}
}
// Clone store state for form editing
let form = $state<EMLy_GUI_Settings>({ ...settingsStore.settings });
let lastSaved = $state<EMLy_GUI_Settings>({
...settingsStore.settings,
});
let dangerWarningOpen = $state(false);
function normalizeSettings(
s: EMLy_GUI_Settings,
): EMLy_GUI_Settings {
return {
selectedLanguage: s.selectedLanguage || defaults.selectedLanguage || "en",
useBuiltinPreview: !!s.useBuiltinPreview,
previewFileSupportedTypes:
s.previewFileSupportedTypes || defaults.previewFileSupportedTypes || [],
};
}
function isSameSettings(
a: EMLy_GUI_Settings,
b: EMLy_GUI_Settings,
) {
return (
(a.selectedLanguage ?? "") === (b.selectedLanguage ?? "") &&
!!a.useBuiltinPreview === !!b.useBuiltinPreview &&
JSON.stringify(a.previewFileSupportedTypes?.sort()) ===
JSON.stringify(b.previewFileSupportedTypes?.sort())
);
}
function resetToLastSaved() {
form = { ...lastSaved };
toast.info(m.settings_toast_reverted());
}
async function saveToStorage() {
if (!browser) return;
const settings = normalizeSettings(form);
const languageChanged =
settings.selectedLanguage !== lastSaved.selectedLanguage;
try {
settingsStore.update(settings);
} catch {
toast.error(m.settings_toast_save_failed());
return;
}
lastSaved = settings;
form = settings;
if (languageChanged) {
await setLanguage(settings.selectedLanguage);
location.reload();
} else {
toast.success(m.settings_toast_saved());
}
}
async function resetToDefaults() {
form = normalizeSettings(defaults);
lastSaved = normalizeSettings(defaults);
// Save to storage
if (browser) {
try {
settingsStore.reset();
settingsStore.update(form); // Ensure local form state is persisted
sessionStorage.removeItem("debugWindowInSettings");
dangerZoneEnabled.set(false);
LogDebug("Reset danger zone setting to false.");
} catch {
toast.error(m.settings_toast_reset_failed());
return;
}
}
await setLanguage(form.selectedLanguage);
mailState.clear();
toast.info(m.settings_toast_reset_success());
location.reload();
}
$effect(() => {
if (!browser) return;
const dirty = !isSameSettings(normalizeSettings(form), lastSaved);
unsavedChanges.set(dirty);
if (dirty) {
showUnsavedChangesToast({
onSave: saveToStorage,
onReset: resetToLastSaved,
});
} else {
dismissUnsavedChangesToast();
}
});
$effect(() => {
// Sync initial state from store when hydrated
// Ensure we don't update if the values are already practically identical to avoid loops
if (
settingsStore.hasHydrated &&
isSameSettings(lastSaved, defaults) &&
!isSameSettings(lastSaved, settingsStore.settings)
) {
form = { ...settingsStore.settings };
lastSaved = { ...settingsStore.settings };
}
});
let previousDangerZoneEnabled = $dangerZoneEnabled;
$effect(() => {
(async () => {
if ($dangerZoneEnabled && !previousDangerZoneEnabled) {
if (!data.machineData || data.machineData === undefined) {
data.machineData = await GetMachineData();
}
dangerWarningOpen = true;
toast.info("Here be dragons!", { icon: Flame });
}
previousDangerZoneEnabled = $dangerZoneEnabled;
})();
});
</script>
<div class="min-h-[calc(100vh-1rem)] from-background to-muted/30">
<div
class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6 sm:px-6 sm:py-10 opacity-80"
>
<header class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h1
class="text-balance text-2xl font-semibold tracking-tight sm:text-3xl"
>
{m.settings_title()}
</h1>
<p class="mt-2 text-sm text-muted-foreground">
{m.settings_description()}
</p>
</div>
<Button
class="cursor-pointer hover:cursor-pointer"
variant="ghost"
onclick={() => goto("/")}><ChevronLeft class="size-4" /> {m.settings_back()}</Button
>
</header>
<Card.Root>
<Card.Header class="space-y-1">
<Card.Title>{m.settings_language_title()}</Card.Title>
<Card.Description
>{m.settings_language_description()}</Card.Description
>
</Card.Header>
<Card.Content>
<RadioGroup.Root
bind:value={form.selectedLanguage}
class="flex flex-col gap-3"
>
<div class="flex items-center space-x-2">
<RadioGroup.Item
value="en"
id="en"
class="cursor-pointer hover:cursor-pointer"
/>
<Label
for="en"
class="flex items-center gap-2 cursor-pointer hover:cursor-pointer"
>
<Us class="size-4 rounded-sm shadow-sm" />
{m.settings_language_english()}
</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item
value="it"
id="it"
class="cursor-pointer hover:cursor-pointer"
/>
<Label
for="it"
class="flex items-center gap-2 cursor-pointer hover:cursor-pointer"
>
<It class="size-4 rounded-sm shadow-sm" />
{m.settings_language_italian()}
</Label>
</div>
</RadioGroup.Root>
<div class="text-xs text-muted-foreground mt-4">
<strong>Info:</strong> {m.settings_language_info()}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="space-y-1">
<Card.Title>{m.settings_preview_files_title()}</Card.Title>
<Card.Description
>{m.settings_preview_files_description()}</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-4">
<Label>{m.settings_preview_images_label()}</Label>
<div class="flex flex-col gap-3">
<div class="flex items-center space-x-2">
<Checkbox
id="preview-jpg"
checked={form.previewFileSupportedTypes?.includes("jpg")}
onCheckedChange={(checked) => {
const types = new Set(form.previewFileSupportedTypes || []);
if (checked) types.add("jpg");
else types.delete("jpg");
form.previewFileSupportedTypes = Array.from(
types,
).sort() as any[];
}}
/>
<Label
for="preview-jpg"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
JPG (.jpg)
</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="preview-jpeg"
checked={form.previewFileSupportedTypes?.includes("jpeg")}
onCheckedChange={(checked) => {
const types = new Set(form.previewFileSupportedTypes || []);
if (checked) types.add("jpeg");
else types.delete("jpeg");
form.previewFileSupportedTypes = Array.from(
types,
).sort() as any[];
}}
/>
<Label
for="preview-jpeg"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
JPEG (.jpeg)
</Label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="preview-png"
checked={form.previewFileSupportedTypes?.includes("png")}
onCheckedChange={(checked) => {
const types = new Set(form.previewFileSupportedTypes || []);
if (checked) types.add("png");
else types.delete("png");
form.previewFileSupportedTypes = Array.from(
types,
).sort() as any[];
}}
/>
<Label
for="preview-png"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
PNG (.png)
</Label>
</div>
</div>
<p class="text-xs text-muted-foreground mt-2">
{m.settings_preview_images_hint()}
</p>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="space-y-1">
<Card.Title>{m.settings_preview_page_title()}</Card.Title>
<Card.Description
>{m.settings_preview_page_description()}</Card.Description
>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-3">
<div
class="flex items-center justify-between gap-4 rounded-lg border bg-card p-4"
>
<div>
<div class="font-medium">{m.settings_preview_builtin_label()}</div>
<div class="text-sm text-muted-foreground">
{m.settings_preview_builtin_hint()}
</div>
</div>
<Switch
bind:checked={form.useBuiltinPreview}
class="cursor-pointer hover:cursor-pointer"
/>
</div>
<p class="text-xs text-muted-foreground mt-2">
{m.settings_preview_builtin_info()}
</p>
</div>
<Separator />
</Card.Content>
</Card.Root>
{#if $dangerZoneEnabled}
<Card.Root class="border-destructive/50 bg-destructive/15">
<Card.Header class="space-y-1">
<Card.Title class="text-destructive">{m.settings_danger_zone_title()}</Card.Title>
<Card.Description
>{m.settings_danger_zone_description()}</Card.Description
>
</Card.Header>
<Card.Content class="space-y-3">
<div
class="flex items-center justify-between gap-4 rounded-lg border border-destructive/30 bg-card p-4"
>
<div class="space-y-1">
<Label class="text-sm">{m.settings_danger_devtools_label()}</Label>
<div class="text-sm text-muted-foreground">
{m.settings_danger_devtools_hint()}
</div>
</div>
</div>
<Separator />
<div
class="flex items-center justify-between gap-4 rounded-lg border border-destructive/30 bg-card p-4"
>
<div class="space-y-1">
<Label class="text-sm">{m.settings_danger_reset_label()}</Label>
<div class="text-sm text-muted-foreground">
{m.settings_danger_reset_hint()}
</div>
</div>
<AlertDialog.Root>
<AlertDialog.Trigger
class={`${buttonVariants({ variant: "destructive" })} cursor-pointer hover:cursor-pointer`}
>
{m.settings_danger_reset_button()}
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title
style="color: var(--destructive); opacity: 0.7;"
>
<u>{m.settings_danger_reset_dialog_title()}</u>
</AlertDialog.Title>
<AlertDialog.Description>
{m.settings_danger_reset_dialog_description_part1()}
<br/>
{m.settings_danger_reset_dialog_description_part2()}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel
class="cursor-pointer hover:cursor-pointer"
>{m.settings_danger_reset_dialog_cancel()}</AlertDialog.Cancel
>
<AlertDialog.Action
onclick={() => {
resetToDefaults();
goto("/");
}}
class="cursor-pointer hover:cursor-pointer"
>{m.settings_danger_reset_dialog_continue()}</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
<div class="text-xs text-muted-foreground">
<strong>{m.settings_danger_warning() }</strong>
</div>
<Separator />
<div class="text-xs text-muted-foreground">
OS: {machineData?.Version} ({machineData?.OS})
<br />
Hostname: {machineData?.Hostname}
<br />
ID: {machineData?.HWID}
<br />
CPU: {machineData?.CPU.processors[0].model.trim()} ({machineData
?.CPU.total_hardware_threads} cores)
<br />
RAM: {Math.round(
(machineData?.RAM.total_physical_bytes ?? 0) /
(1024 * 1024 * 1024),
)} GB
<br />
GPU: {machineData?.GPU.cards?.find(
(c) => !(c.pci?.product?.name ?? "").includes("Virtual"),
)?.pci?.product?.name ?? "N/A"}
<br />
<br />
GUI: {config
? `${config.GUISemver} (${config.GUIReleaseChannel})`
: "N/A"}
<br />
SDK: {config
? `${config.SDKDecoderSemver} (${config.SDKDecoderReleaseChannel})`
: "N/A"}
</div>
</Card.Content>
</Card.Root>
{/if}
<AlertDialog.Root bind:open={dangerWarningOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{m.settings_danger_alert_title()}</AlertDialog.Title>
<AlertDialog.Description>
{m.settings_danger_alert_description()}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Action onclick={() => (dangerWarningOpen = false)}
>{m.settings_danger_alert_understood()}</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</div>

View File

@@ -0,0 +1,26 @@
import type { PageLoad } from './$types';
import { GetMachineData, GetConfig } from "$lib/wailsjs/go/main/App";
import { browser } from '$app/environment';
import { dangerZoneEnabled } from "$lib/stores/app";
import { get } from "svelte/store";
export const load = (async () => {
if (!browser) return { machineData: null, config: null };
try {
const [machineData, configRoot] = await Promise.all([
get(dangerZoneEnabled) ? GetMachineData() : Promise.resolve(null),
GetConfig()
]);
return {
machineData,
config: configRoot.EMLy
};
} catch (e) {
console.error("Failed to load settings data", e);
return {
machineData: null,
config: null
};
}
}) satisfies PageLoad;

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
</script>
<div class="page">
<div class="error-container">
<h1 class="error-code">{page.status}</h1>
<p class="error-message">{page.error?.message || m.error_unexpected()}</p>
</div>
</div>
<style>
.page {
height: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
box-sizing: border-box;
overflow: hidden;
}
.error-container {
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 400px;
width: 100%;
}
.error-code {
font-size: 64px;
font-weight: 700;
margin: 0 0 8px 0;
opacity: 0.9;
line-height: 1;
}
.error-message {
font-size: 16px;
opacity: 0.6;
margin: 0 0 32px 0;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
let { children } = $props();
onMount(() => {
const loader = document.getElementById('app-loading');
if (loader) {
loader.style.opacity = '0';
setTimeout(() => {
loader.remove();
}, 300);
}
});
</script>
{@render children()}

View File

@@ -0,0 +1,3 @@
export const prerender = true;
export const ssr = false;
export const trailingSlash = 'always';

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import {
WindowMinimise,
WindowMaximise,
WindowUnmaximise,
WindowIsMaximised,
Quit,
} from "$lib/wailsjs/runtime/runtime";
import type { LayoutProps } from "./$types";
let { data, children }: LayoutProps = $props();
let isMaximized = $state(false);
async function syncMaxState() {
isMaximized = await WindowIsMaximised();
}
async function toggleMaximize() {
if (isMaximized) {
WindowUnmaximise();
} else {
WindowMaximise();
}
isMaximized = !isMaximized;
}
function minimize() {
WindowMinimise();
}
function closeWindow() {
Quit();
}
function onTitlebarDblClick() {
toggleMaximize();
}
syncMaxState();
</script>
<div class="app-layout">
<!-- Titlebar -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="titlebar"
ondblclick={onTitlebarDblClick}
style="--wails-draggable:drag"
>
<div class="title">EMLy Viewer</div>
<div class="controls">
<button class="btn" onclick={minimize}>─</button>
<button class="btn" onclick={toggleMaximize}>
{#if isMaximized}
{:else}
{/if}
</button>
<button class="btn close" onclick={closeWindow}>✕</button>
</div>
</div>
<!-- Content -->
<main class="content">
{@render children()}
</main>
</div>
<style>
:global(body) {
margin: 0;
background: #000;
overflow: hidden;
}
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.titlebar {
height: 32px;
background: #000;
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 12px;
-webkit-app-region: drag;
user-select: none;
flex: 0 0 32px;
z-index: 50;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.title {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.controls {
display: flex;
height: 100%;
opacity: 0.5;
}
.btn {
width: 46px;
height: 100%;
border: none;
background: transparent;
color: white;
font-size: 14px;
cursor: pointer;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
}
.btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.close:hover {
background: #e81123;
}
.content {
flex: 1;
overflow: hidden;
position: relative;
}
</style>

View File

@@ -0,0 +1,290 @@
<script lang="ts">
import { onMount } from "svelte";
import type { PageData } from './$types';
import {
RotateCcw,
RotateCw,
ZoomIn,
ZoomOut,
Square,
} from "@lucide/svelte";
import { sidebarOpen } from "$lib/stores/app";
import { toast } from "svelte-sonner";
let { data }: { data: PageData } = $props();
let imageData = $state("");
let filename = $state("");
let rotation = $state(0);
let scale = $state(1);
let error = $state("");
let loading = $state(true);
let translateX = $state(0);
let translateY = $state(0);
let imgElement = $state<HTMLImageElement>();
let containerElement = $state<HTMLDivElement>();
// Non-reactive state for drag calculations
let isDragging = false;
let startX = 0;
let startY = 0;
onMount(async () => {
try {
const result = data?.data
if (result) {
imageData = result.data;
filename = result.filename;
// Adjust title
document.title = filename + " - EMLy Viewer";
sidebarOpen.set(false);
} else {
toast.error("No image data provided");
error = "No image data provided";
}
} catch (e) {
error = "Failed to load image: " + e;
} finally {
loading = false;
}
});
function fitToScreen() {
if (!imgElement || !containerElement) return;
const padding = 60;
const cw = containerElement.clientWidth - padding;
const ch = containerElement.clientHeight - padding;
const iw = imgElement.naturalWidth;
const ih = imgElement.naturalHeight;
if (!iw || !ih || !cw || !ch) return;
const scaleW = cw / iw;
const scaleH = ch / ih;
scale = Math.min(scaleW, scaleH);
// Ensure we don't end up with an invalid scale
if (!Number.isFinite(scale) || scale <= 0) scale = 0.1;
translateX = 0;
translateY = 0;
}
function rotate(deg: number) {
rotation += deg;
}
function zoom(factor: number) {
scale = Math.max(0.01, scale + factor);
}
function reset() {
rotation = 0;
fitToScreen();
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
const delta = -e.deltaY * 0.001;
scale = Math.max(0.01, Math.min(50, scale + delta));
}
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return; // Only left click
e.preventDefault();
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
e.preventDefault();
translateX = e.clientX - startX;
translateY = e.clientY - startY;
}
function handleMouseUp() {
isDragging = false;
}
</script>
<div class="page-container">
<!-- Toolbar -->
<div class="toolbar">
<h1 class="title" title={filename}>{filename || "Image Viewer"}</h1>
<div class="controls">
<button class="btn" onclick={() => zoom(0.1)} title="Zoom In">
<ZoomIn size="16" />
</button>
<button class="btn" onclick={() => zoom(-0.1)} title="Zoom Out">
<ZoomOut size="16" />
</button>
<div class="separator"></div>
<button class="btn" onclick={() => rotate(-90)} title="Rotate Left">
<RotateCcw size="16" />
</button>
<button class="btn" onclick={() => rotate(90)} title="Rotate Right">
<RotateCw size="16" />
</button>
<div class="separator"></div>
<button class="btn" onclick={reset} title="Reset">
<Square size="16" />
</button>
</div>
</div>
<!-- Image Area -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={containerElement}
class="image-area"
onwheel={handleWheel}
onmousedown={handleMouseDown}
onmousemove={handleMouseMove}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
role="region"
aria-label="Image View"
>
{#if loading}
<div class="loading">Loading...</div>
{:else if error}
<div class="error-message">
{error}
</div>
{:else if imageData}
<div
class="transform-layer"
style="transform: translate({translateX}px, {translateY}px) scale({scale}) rotate({rotation}deg);"
>
<!-- svelte-ignore a11y_img_redundant_alt -->
<img
bind:this={imgElement}
onload={fitToScreen}
src={`data:image/png;base64,${imageData}`}
alt={filename}
class="viewer-img"
draggable="false"
/>
</div>
{/if}
</div>
</div>
<style>
:global(body) {
margin: 0;
background: #000;
}
.page-container {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #000;
color: white;
font-family: system-ui, -apple-system, sans-serif;
}
.toolbar {
height: 50px;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
flex-shrink: 0;
z-index: 10;
}
.title {
font-size: 14px;
font-weight: 500;
opacity: 0.9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 50%;
}
.controls {
display: flex;
align-items: center;
gap: 6px;
}
.separator {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.15);
margin: 0 4px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
transition: all 0.2s;
}
.btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.image-area {
flex: 1;
background: rgba(0, 0, 0, 0.5);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
}
.image-area:active {
cursor: grabbing;
}
.transform-layer {
transition: transform 0.05s linear;
transform-origin: center center;
will-change: transform;
display: flex; /* Ensures content centers */
}
.viewer-img {
max-width: none;
pointer-events: none;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border-radius: 2px;
}
.loading {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
}
.error-message {
color: #f87171;
background: rgba(248, 113, 113, 0.1);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid rgba(248, 113, 113, 0.2);
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,12 @@
import type { PageLoad } from './$types';
import { GetImageViewerData } from "$lib/wailsjs/go/main/App";
export const load = (async () => {
try {
const data = await GetImageViewerData();
return { data };
} catch (error) {
console.error("Error fetching image viewer data:", error);
return { data: null };
}
}) satisfies PageLoad;

View File

@@ -0,0 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
.titlebar {
-webkit-app-region: drag;
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}