v1.0.0
This commit is contained in:
444
frontend/src/routes/(app)/+layout.svelte
Normal file
444
frontend/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
61
frontend/src/routes/(app)/+page.svelte
Normal file
61
frontend/src/routes/(app)/+page.svelte
Normal 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>
|
||||
32
frontend/src/routes/(app)/+page.ts
Normal file
32
frontend/src/routes/(app)/+page.ts
Normal 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 };
|
||||
};
|
||||
478
frontend/src/routes/(app)/settings/+page.svelte
Normal file
478
frontend/src/routes/(app)/settings/+page.svelte
Normal 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>
|
||||
26
frontend/src/routes/(app)/settings/+page.ts
Normal file
26
frontend/src/routes/(app)/settings/+page.ts
Normal 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;
|
||||
Reference in New Issue
Block a user