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;
|
||||
49
frontend/src/routes/+error.svelte
Normal file
49
frontend/src/routes/+error.svelte
Normal 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>
|
||||
17
frontend/src/routes/+layout.svelte
Normal file
17
frontend/src/routes/+layout.svelte
Normal 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()}
|
||||
3
frontend/src/routes/+layout.ts
Normal file
3
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
export const trailingSlash = 'always';
|
||||
139
frontend/src/routes/image-viewer/+layout.svelte
Normal file
139
frontend/src/routes/image-viewer/+layout.svelte
Normal 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>
|
||||
290
frontend/src/routes/image-viewer/+page.svelte
Normal file
290
frontend/src/routes/image-viewer/+page.svelte
Normal 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>
|
||||
12
frontend/src/routes/image-viewer/+page.ts
Normal file
12
frontend/src/routes/image-viewer/+page.ts
Normal 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;
|
||||
125
frontend/src/routes/layout.css
Normal file
125
frontend/src/routes/layout.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user