design: upgrade global design of the app

This commit is contained in:
2026-04-23 11:21:38 +02:00
parent a397c86bc3
commit fe108a3b61
9 changed files with 208 additions and 10 deletions

View File

@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main", "image-viewer"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
@ -21,6 +21,7 @@
"core:window:allow-set-cursor-visible", "core:window:allow-set-cursor-visible",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-is-minimized", "core:window:allow-is-minimized",
"core:window:allow-is-focused" "core:window:allow-is-focused",
"core:window:allow-start-resize-dragging"
] ]
} }

View File

@ -1,4 +1,5 @@
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
use tauri::window::Color;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Mutex; use std::sync::Mutex;
use rusqlite::Connection; use rusqlite::Connection;
@ -494,6 +495,61 @@ pub fn toggle_quest_step(
db::toggle_quest_step(&conn, &profile_id, &quest_name, step_index).map_err(|e| e.to_string()) db::toggle_quest_step(&conn, &profile_id, &quest_name, step_index).map_err(|e| e.to_string())
} }
fn percent_encode(s: &str) -> String {
let mut result = String::new();
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(byte as char);
}
b => result.push_str(&format!("%{:02X}", b)),
}
}
result
}
#[tauri::command]
pub async fn open_image_viewer(
app: AppHandle,
state: State<'_, DbState>,
image_url: String,
) -> Result<(), String> {
if let Some(win) = app.get_webview_window("image-viewer") {
win.emit("set-viewer-image", &image_url).map_err(|e| e.to_string())?;
win.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
let (w, h, x, y) = {
let conn = state.0.lock().map_err(|e| e.to_string())?;
let w: f64 = db::get_setting(&conn, "viewer_width").and_then(|v| v.parse().ok()).unwrap_or(600.0);
let h: f64 = db::get_setting(&conn, "viewer_height").and_then(|v| v.parse().ok()).unwrap_or(500.0);
let x: Option<f64> = db::get_setting(&conn, "viewer_x").and_then(|v| v.parse().ok());
let y: Option<f64> = db::get_setting(&conn, "viewer_y").and_then(|v| v.parse().ok());
(w, h, x, y)
};
let path = format!("/?viewer=1&imageUrl={}", percent_encode(&image_url));
let mut builder = tauri::WebviewWindowBuilder::new(
&app,
"image-viewer",
tauri::WebviewUrl::App(path.into()),
)
.title("Image")
.decorations(false)
.resizable(true)
.always_on_top(true)
.background_color(Color(13, 17, 23, 255))
.inner_size(w, h);
if let (Some(x), Some(y)) = (x, y) {
builder = builder.position(x, y);
}
builder.build().map_err(|e| e.to_string())?;
Ok(())
}
fn collect_quest_names(data: &parser::GuideData) -> Vec<String> { fn collect_quest_names(data: &parser::GuideData) -> Vec<String> {
let mut names = Vec::new(); let mut names = Vec::new();
for section in &data.sections { for section in &data.sections {

View File

@ -53,6 +53,7 @@ pub fn run() {
commands::toggle_quest_step, commands::toggle_quest_step,
commands::get_resource_inventory, commands::get_resource_inventory,
commands::set_resource_quantity, commands::set_resource_quantity,
commands::open_image_viewer,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -1,8 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useStore } from "./store"; import { useStore } from "./store";
import TitleBar from "./components/TitleBar"; import TitleBar from "./components/TitleBar";
import ResizeHandles from "./components/ResizeHandles";
import HomeView from "./components/HomeView"; import HomeView from "./components/HomeView";
import GuideView from "./components/GuideView"; import GuideView from "./components/GuideView";
import SettingsPanel from "./components/SettingsPanel"; import SettingsPanel from "./components/SettingsPanel";
@ -58,7 +60,27 @@ export default function App() {
}, 500); }, 500);
}); });
return () => { unlisten.then(f => f()); }; const unlistenFocus = win.listen("tauri://focus", async () => {
const viewer = await WebviewWindow.getByLabel("image-viewer");
if (viewer) {
const isMin = await viewer.isMinimized();
if (isMin) await viewer.unminimize();
}
});
const unlistenBlur = win.listen("tauri://blur", async () => {
const isMin = await win.isMinimized();
if (isMin) {
const viewer = await WebviewWindow.getByLabel("image-viewer");
if (viewer) await viewer.minimize();
}
});
return () => {
unlisten.then(f => f());
unlistenFocus.then(f => f());
unlistenBlur.then(f => f());
};
}, []); }, []);
async function handleInitialSync() { async function handleInitialSync() {
@ -68,6 +90,7 @@ export default function App() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<ResizeHandles />
<TitleBar onOpenSettings={() => setShowSettings(s => !s)} /> <TitleBar onOpenSettings={() => setShowSettings(s => !s)} />
<div className="app-body"> <div className="app-body">
<main className="app-main"> <main className="app-main">

View File

@ -0,0 +1,74 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import ResizeHandles from "./ResizeHandles";
export default function ImageViewerWindow() {
const params = new URLSearchParams(window.location.search);
const [imageUrl, setImageUrl] = useState(decodeURIComponent(params.get("imageUrl") ?? ""));
const win = getCurrentWindow();
useEffect(() => {
let debounce: ReturnType<typeof setTimeout> | null = null;
const unlistenImage = win.listen<string>("set-viewer-image", (event) => {
setImageUrl(event.payload);
});
const unlistenResize = win.onResized(async () => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(async () => {
const [size, factor] = await Promise.all([win.innerSize(), win.scaleFactor()]);
await invoke("set_setting", { key: "viewer_width", value: String(Math.round(size.width / factor)) });
await invoke("set_setting", { key: "viewer_height", value: String(Math.round(size.height / factor)) });
}, 500);
});
const unlistenMove = win.onMoved(async () => {
if (debounce) clearTimeout(debounce);
debounce = setTimeout(async () => {
const [pos, factor] = await Promise.all([win.outerPosition(), win.scaleFactor()]);
await invoke("set_setting", { key: "viewer_x", value: String(Math.round(pos.x / factor)) });
await invoke("set_setting", { key: "viewer_y", value: String(Math.round(pos.y / factor)) });
}, 500);
});
return () => {
unlistenImage.then(f => f());
unlistenResize.then(f => f());
unlistenMove.then(f => f());
};
}, []);
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column", background: "#0d1117", overflow: "hidden" }}>
<ResizeHandles />
<div
onMouseDown={e => { if (e.button === 0) win.startDragging(); }}
style={{
height: "28px",
background: "#161b22",
borderBottom: "1px solid #2d3748",
cursor: "grab",
flexShrink: 0,
display: "flex",
alignItems: "center",
paddingLeft: "12px",
userSelect: "none",
}}
>
<span style={{ fontSize: "11px", color: "#4a5568", pointerEvents: "none" }}> Image</span>
</div>
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}>
{imageUrl && (
<img
src={imageUrl}
style={{ width: "100%", display: "block" }}
draggable={false}
/>
)}
</div>
</div>
);
}

View File

@ -155,19 +155,21 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
if (done) { if (done) {
const firstLine = step.text.split('\n').find(l => l.trim().length > 0) ?? step.text; const firstLine = step.text.split('\n').find(l => l.trim().length > 0) ?? step.text;
return ( return (
<div key={step.index} style={{ <div key={step.index} onClick={() => toggleStep(step.index)} style={{
marginBottom: "4px", marginBottom: "4px",
padding: "6px 12px", padding: "6px 12px",
border: "1px solid rgba(74,222,128,0.1)", border: "1px solid rgba(74,222,128,0.1)",
borderRadius: "7px", borderRadius: "7px",
background: "rgba(74,222,128,0.03)", background: "rgba(74,222,128,0.03)",
opacity: 0.5, opacity: 0.5,
cursor: "pointer",
}}> }}>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}> <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<input <input
type="checkbox" type="checkbox"
checked={true} checked={true}
onChange={() => toggleStep(step.index)} onChange={() => toggleStep(step.index)}
onClick={e => e.stopPropagation()}
style={{ flexShrink: 0, cursor: "pointer" }} style={{ flexShrink: 0, cursor: "pointer" }}
/> />
<span style={{ <span style={{
@ -190,18 +192,20 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
: step.text; : step.text;
return ( return (
<div key={step.index} style={{ <div key={step.index} onClick={() => toggleStep(step.index)} style={{
marginBottom: "8px", marginBottom: "8px",
background: "rgba(255,255,255,0.02)", background: "rgba(255,255,255,0.02)",
border: "1px solid #2d3748", border: "1px solid #2d3748",
borderRadius: "7px", padding: "10px 12px", borderRadius: "7px", padding: "10px 12px",
transition: "all 0.15s", transition: "all 0.15s",
cursor: "pointer",
}}> }}>
<div style={{ display: "flex", gap: "10px", alignItems: "flex-start" }}> <div style={{ display: "flex", gap: "10px", alignItems: "flex-start" }}>
<input <input
type="checkbox" type="checkbox"
checked={false} checked={false}
onChange={() => toggleStep(step.index)} onChange={() => toggleStep(step.index)}
onClick={e => e.stopPropagation()}
style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }} style={{ marginTop: "2px", flexShrink: 0, cursor: "pointer" }}
/> />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@ -214,7 +218,7 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
{needsTruncate && ( {needsTruncate && (
<button <button
onClick={() => toggleExpanded(step.index)} onClick={e => { e.stopPropagation(); toggleExpanded(step.index); }}
style={{ style={{
marginTop: "4px", background: "transparent", border: "none", marginTop: "4px", background: "transparent", border: "none",
color: "#4a9eff", fontSize: "11px", cursor: "pointer", color: "#4a9eff", fontSize: "11px", cursor: "pointer",
@ -231,7 +235,7 @@ export default function QuestDetailPanel({ questName, questUrl, profileId, onClo
<img <img
key={j} key={j}
src={src} src={src}
onClick={() => openUrl(src)} onClick={e => { e.stopPropagation(); invoke("open_image_viewer", { imageUrl: src }); }}
style={{ style={{
maxWidth: "100%", height: "auto", maxWidth: "100%", height: "auto",
borderRadius: "6px", display: "block", borderRadius: "6px", display: "block",
@ -279,7 +283,7 @@ function QuestHeader({ step }: { step: QuestStep }) {
<img <img
key={j} key={j}
src={src} src={src}
onClick={() => openUrl(src)} onClick={() => invoke("open_image_viewer", { imageUrl: src })}
style={{ style={{
maxWidth: "100%", height: "auto", maxWidth: "100%", height: "auto",
borderRadius: "6px", display: "block", borderRadius: "6px", display: "block",

View File

@ -0,0 +1,31 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { ResizeEdge } from "@tauri-apps/api/window";
const S = 8; // grab zone size in px
const handles: { edge: ResizeEdge; style: React.CSSProperties }[] = [
{ edge: "North", style: { top: 0, left: S, right: S, height: S, cursor: "n-resize" } },
{ edge: "South", style: { bottom: 0, left: S, right: S, height: S, cursor: "s-resize" } },
{ edge: "West", style: { top: S, left: 0, bottom: S, width: S, cursor: "w-resize" } },
{ edge: "East", style: { top: S, right: 0, bottom: S, width: S, cursor: "e-resize" } },
{ edge: "NorthWest", style: { top: 0, left: 0, width: S, height: S, cursor: "nw-resize" } },
{ edge: "NorthEast", style: { top: 0, right: 0, width: S, height: S, cursor: "ne-resize" } },
{ edge: "SouthWest", style: { bottom: 0, left: 0, width: S, height: S, cursor: "sw-resize" } },
{ edge: "SouthEast", style: { bottom: 0, right: 0,width: S, height: S, cursor: "se-resize" } },
];
export default function ResizeHandles() {
const win = getCurrentWindow();
return (
<>
{handles.map(({ edge, style }) => (
<div
key={edge}
onMouseDown={e => { if (e.button === 0) win.startResizeDragging(edge); }}
style={{ position: "fixed", zIndex: 9999, ...style }}
/>
))}
</>
);
}

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useStore } from "../store"; import { useStore } from "../store";
function useWindowWidth() { function useWindowWidth() {
@ -27,6 +28,10 @@ export default function TitleBar({ onOpenSettings }: Props) {
} }
async function handleClose() { async function handleClose() {
const viewer = await WebviewWindow.getByLabel("image-viewer");
if (viewer) {
try { await viewer.close(); } catch (_) {}
}
await getCurrentWindow().close(); await getCurrentWindow().close();
} }

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import ImageViewerWindow from "./components/ImageViewerWindow";
// Block all document-level scrolling // Block all document-level scrolling
document.addEventListener("wheel", (e) => { document.addEventListener("wheel", (e) => {
@ -17,8 +18,10 @@ document.addEventListener("scroll", () => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, { passive: true }); }, { passive: true });
const isViewer = new URLSearchParams(window.location.search).get("viewer") === "1";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> {isViewer ? <ImageViewerWindow /> : <App />}
</React.StrictMode>, </React.StrictMode>,
); );