Migrate bos-settings from GTK4 to Tauri + Svelte 5
Ports every remaining view (Keybinds, the last one — dual Flat/MultiLayout schema detection, per-action dedicated fields with a raw-JSON fallback for anything not modeled) and removes the now-fully-dead GTK4 crate (root Cargo.toml + src/), leaving src-tauri/ as the single Rust binary crate. Also folds in the UX pass done alongside the migration: responsive multi-column layout instead of a single centered column, chip/dropdown pickers replacing free-typed fields (Wi-Fi networks, Bar style, keyboard layout, tag lists), consistent pill-switch toggles and boxed-list card styling across every page, and a sidebar scroll-chaining fix.
This commit is contained in:
parent
62365ebf10
commit
93c3b88b10
147 changed files with 15804 additions and 7516 deletions
10
frontend/.gitignore
vendored
Normal file
10
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
7
frontend/README.md
Normal file
7
frontend/README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Tauri + SvelteKit + TypeScript
|
||||
|
||||
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||
1942
frontend/package-lock.json
generated
Normal file
1942
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "bos-settings-frontend",
|
||||
"version": "0.8.0",
|
||||
"description": "Frontend for BOS Settings",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
13
frontend/src/app.html
Normal file
13
frontend/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>BOS Settings</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
126
frontend/src/lib/components/ChipPickerField.svelte
Normal file
126
frontend/src/lib/components/ChipPickerField.svelte
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<script lang="ts">
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
options,
|
||||
emptyOptionsHint = "Nothing to pick from yet.",
|
||||
}: { label: string; value: string[]; options: string[]; emptyOptionsHint?: string } = $props();
|
||||
|
||||
let adding = $state(false);
|
||||
|
||||
let available = $derived(options.filter((o) => !value.includes(o)));
|
||||
|
||||
function add(name: string) {
|
||||
if (!name) return;
|
||||
value = [...value, name];
|
||||
adding = false;
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
value = value.filter((v) => v !== name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="header">
|
||||
<span class="label">{label}</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each value as name (name)}
|
||||
<span class="chip">
|
||||
{name}
|
||||
<button type="button" class="remove" onclick={() => remove(name)} aria-label={`Remove ${name}`}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
{#if adding}
|
||||
{#if available.length > 0}
|
||||
<!-- svelte-ignore a11y_autofocus -- responds to the user's own
|
||||
"+" click just now, not a page-load focus grab -->
|
||||
<select autofocus onchange={(e) => add(e.currentTarget.value)}>
|
||||
<option value="" selected disabled>Choose…</option>
|
||||
{#each available as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span class="empty-hint">{emptyOptionsHint}</span>
|
||||
{/if}
|
||||
{:else if available.length > 0}
|
||||
<button type="button" class="chip add" onclick={() => (adding = true)}>+</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: var(--overlay);
|
||||
color: var(--on-overlay);
|
||||
border-radius: 999px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.chip.add {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
font-weight: bold;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.chip.add:hover {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: 4px var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
opacity: 0.5;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
35
frontend/src/lib/components/CsvField.svelte
Normal file
35
frontend/src/lib/components/CsvField.svelte
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let { label, value = $bindable(), placeholder = "" }: { label: string; value: string[]; placeholder?: string } =
|
||||
$props();
|
||||
|
||||
let text = $state(value.join(", "));
|
||||
|
||||
function onInput() {
|
||||
value = text
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<input type="text" bind:value={text} oninput={onInput} {placeholder} />
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
input {
|
||||
width: 28ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
32
frontend/src/lib/components/EmptyState.svelte
Normal file
32
frontend/src/lib/components/EmptyState.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
|
||||
let { icon: Icon, title, hint }: { icon: Component; title: string; hint: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="empty">
|
||||
<Icon size={40} />
|
||||
<span class="title">{title}</span>
|
||||
<span class="hint">{hint}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-xl, 20px) 0;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
max-width: 40ch;
|
||||
}
|
||||
</style>
|
||||
67
frontend/src/lib/components/FileField.svelte
Normal file
67
frontend/src/lib/components/FileField.svelte
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<script lang="ts">
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import Row from "./Row.svelte";
|
||||
import FolderOpen from "@lucide/svelte/icons/folder-open";
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
placeholder = "",
|
||||
mode = "file",
|
||||
extensions,
|
||||
}: { label: string; value: string; placeholder?: string; mode?: "file" | "folder"; extensions?: string[] } =
|
||||
$props();
|
||||
|
||||
async function browse() {
|
||||
const picked = await open({
|
||||
directory: mode === "folder",
|
||||
filters: extensions ? [{ name: "Files", extensions }] : undefined,
|
||||
});
|
||||
if (typeof picked === "string") value = picked;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<div class="wrap">
|
||||
<input type="text" bind:value {placeholder} />
|
||||
<button type="button" onclick={browse} aria-label="Browse…">
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
gap: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 22ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: color-mix(in srgb, var(--on-surface) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
60
frontend/src/lib/components/Group.svelte
Normal file
60
frontend/src/lib/components/Group.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
title,
|
||||
hint,
|
||||
wide = false,
|
||||
children,
|
||||
}: { title: string; hint?: string; wide?: boolean; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="group" class:wide>
|
||||
<h2 class="title">{title}</h2>
|
||||
{#if hint}<p class="hint">{hint}</p>{/if}
|
||||
<div class="rows">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1.05em;
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.hint {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
line-height: 1.4;
|
||||
margin: 0 0 var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Flex children default to refusing to shrink below their content's
|
||||
natural width (min-width: auto) — without this, a button/input
|
||||
with enough text overflows past the grid column's actual pixel
|
||||
width instead of wrapping, visually spilling into the next
|
||||
column. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rows > :global(*) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
18
frontend/src/lib/components/Hint.svelte
Normal file
18
frontend/src/lib/components/Hint.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
let { text }: { text: string } = $props();
|
||||
</script>
|
||||
|
||||
<p class="hint">{text}</p>
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
margin: 0 0 var(--space-xs, 4px);
|
||||
/* No-op unless this Hint is a direct child of ViewScaffold's CSS
|
||||
grid (i.e. used standalone between Groups, not nested inside
|
||||
one) — in that case it spans full width instead of getting
|
||||
squeezed into whichever grid column it happened to land in. */
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
||||
56
frontend/src/lib/components/HyprColorField.svelte
Normal file
56
frontend/src/lib/components/HyprColorField.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
// Hyprland's own color format: "rgba(RRGGBBAA)" — a plain hex color input
|
||||
// has no alpha channel, so this pairs one with an alpha slider and
|
||||
// recombines them into that exact string on every change.
|
||||
let { label, value = $bindable() }: { label: string; value: string } = $props();
|
||||
|
||||
function parse(v: string): { hex: string; alpha: number } {
|
||||
const inner = v.match(/^rgba\(([0-9a-fA-F]{8})\)$/)?.[1];
|
||||
if (!inner) return { hex: "#808080", alpha: 255 };
|
||||
return { hex: `#${inner.slice(0, 6)}`, alpha: parseInt(inner.slice(6, 8), 16) };
|
||||
}
|
||||
|
||||
let parsed = $derived(parse(value));
|
||||
|
||||
function update(hex: string, alpha: number) {
|
||||
const a = alpha.toString(16).padStart(2, "0");
|
||||
value = `rgba(${hex.slice(1)}${a})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<div class="wrap">
|
||||
<input type="color" value={parsed.hex} oninput={(e) => update(e.currentTarget.value, parsed.alpha)} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="255"
|
||||
value={parsed.alpha}
|
||||
oninput={(e) => update(parsed.hex, Number(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 36px;
|
||||
height: 26px;
|
||||
border: 1px solid color-mix(in srgb, var(--on-surface) 20%, transparent);
|
||||
border-radius: var(--radius-tertiary, 4px);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100px;
|
||||
}
|
||||
</style>
|
||||
16
frontend/src/lib/components/InfoRow.svelte
Normal file
16
frontend/src/lib/components/InfoRow.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let { label, value }: { label: string; value: string } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<span class="value">{value}</span>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
.value {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
24
frontend/src/lib/components/LogView.svelte
Normal file
24
frontend/src/lib/components/LogView.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
let { lines }: { lines: string[] } = $props();
|
||||
</script>
|
||||
|
||||
{#if lines.length > 0}
|
||||
<pre class="log">{lines.join("\n")}</pre>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.log {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
</style>
|
||||
31
frontend/src/lib/components/NumberField.svelte
Normal file
31
frontend/src/lib/components/NumberField.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
}: { label: string; value: number; min: number; max: number; step?: number } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<input type="number" bind:value {min} {max} {step} />
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
input {
|
||||
width: 10ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
60
frontend/src/lib/components/PasswordField.svelte
Normal file
60
frontend/src/lib/components/PasswordField.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
import Eye from "@lucide/svelte/icons/eye";
|
||||
import EyeOff from "@lucide/svelte/icons/eye-off";
|
||||
|
||||
let { label, value = $bindable() }: { label: string; value: string } = $props();
|
||||
|
||||
let reveal = $state(false);
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<div class="wrap">
|
||||
<input type={reveal ? "text" : "password"} bind:value />
|
||||
<button type="button" class="peek" onclick={() => (reveal = !reveal)}>
|
||||
{#if reveal}<EyeOff size={14} />{:else}<Eye size={14} />{/if}
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 28ch;
|
||||
background-color: var(--bg);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding-inline-end: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.wrap:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.peek {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--on-surface);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.peek:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
135
frontend/src/lib/components/PathListField.svelte
Normal file
135
frontend/src/lib/components/PathListField.svelte
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<script lang="ts">
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
|
||||
let { label, value = $bindable(), hint }: { label: string; value: string[]; hint?: string } = $props();
|
||||
|
||||
async function addFolder() {
|
||||
const picked = await open({ directory: true });
|
||||
if (typeof picked === "string" && !value.includes(picked)) {
|
||||
value = [...value, picked];
|
||||
}
|
||||
}
|
||||
|
||||
function remove(path: string) {
|
||||
value = value.filter((p) => p !== path);
|
||||
}
|
||||
|
||||
function shorten(path: string): string {
|
||||
const home = "/home/";
|
||||
const idx = path.indexOf(home);
|
||||
if (idx === -1) return path;
|
||||
const afterHome = path.slice(idx + home.length);
|
||||
const slash = afterHome.indexOf("/");
|
||||
return slash === -1 ? path : `~/${afterHome.slice(slash + 1)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="header">
|
||||
<span class="label">{label}</span>
|
||||
<button type="button" class="add" onclick={addFolder}>
|
||||
<Plus size={13} />
|
||||
Add folder…
|
||||
</button>
|
||||
</div>
|
||||
{#if hint}<p class="hint">{hint}</p>{/if}
|
||||
{#if value.length === 0}
|
||||
<p class="empty">None added.</p>
|
||||
{:else}
|
||||
<div class="chips">
|
||||
{#each value as path (path)}
|
||||
<span class="chip" title={path}>
|
||||
{shorten(path)}
|
||||
<button type="button" class="remove" onclick={() => remove(path)} aria-label={`Remove ${path}`}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.5;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
margin: var(--space-sm, 8px) 0 0;
|
||||
}
|
||||
|
||||
.add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: none;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.add:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: var(--overlay);
|
||||
color: var(--on-overlay);
|
||||
border-radius: 999px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
background-color: color-mix(in srgb, var(--on-overlay) 15%, transparent);
|
||||
}
|
||||
</style>
|
||||
17
frontend/src/lib/components/Placeholder.svelte
Normal file
17
frontend/src/lib/components/Placeholder.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
let { page }: { page: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="placeholder">
|
||||
<p>"{page}" hasn't been migrated to Tauri yet.</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
57
frontend/src/lib/components/Row.svelte
Normal file
57
frontend/src/lib/components/Row.svelte
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { label, children }: { label: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="field-row">
|
||||
<span class="label">{label}</span>
|
||||
<div class="control">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* GNOME Settings' "boxed list" look: consecutive rows merge into one
|
||||
card with a thin divider between them, rounding only the outer
|
||||
corners of the run — not each row individually. `.field-row` is a
|
||||
distinctive name (not just `.row`) so the :global() adjacency rules
|
||||
below can't collide with an unrelated `.row` class elsewhere (e.g.
|
||||
Sidebar's own nav rows) — every Row instance (and everything that
|
||||
wraps it — InfoRow, the field components) renders this same element,
|
||||
so a run of them is just plain DOM adjacency; no parent wrapper
|
||||
component needed. */
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg, 16px);
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
:global(.field-row + .field-row) {
|
||||
margin-top: calc(var(--space-sm, 8px) * -1);
|
||||
border-top: 1px solid var(--bg);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
:global(.field-row:has(+ .field-row)) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
55
frontend/src/lib/components/SaveButton.svelte
Normal file
55
frontend/src/lib/components/SaveButton.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
let { onSave }: { onSave: () => Promise<void> } = $props();
|
||||
|
||||
let status = $state("");
|
||||
let saving = $state(false);
|
||||
|
||||
async function save() {
|
||||
saving = true;
|
||||
try {
|
||||
await onSave();
|
||||
status = "Saved";
|
||||
setTimeout(() => (status = ""), 3000);
|
||||
} catch (e) {
|
||||
status = `Error: ${e}`;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="row">
|
||||
<button disabled={saving} onclick={save}>Save</button>
|
||||
<span class="status">{status}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
margin-top: var(--space-lg, 16px);
|
||||
/* Always spans the full grid width, regardless of how many
|
||||
Group columns the rest of the page laid out above it. */
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
34
frontend/src/lib/components/SelectField.svelte
Normal file
34
frontend/src/lib/components/SelectField.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let { label, value = $bindable(), options }: { label: string; value: string; options: string[] } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<select bind:value>
|
||||
{#each options as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
93
frontend/src/lib/components/ServiceControl.svelte
Normal file
93
frontend/src/lib/components/ServiceControl.svelte
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import Row from "./Row.svelte";
|
||||
import Group from "./Group.svelte";
|
||||
import Hint from "./Hint.svelte";
|
||||
|
||||
let { unit, critical = false, hasConfig = false }: { unit: string; critical?: boolean; hasConfig?: boolean } =
|
||||
$props();
|
||||
|
||||
let active = $state(false);
|
||||
let enabled = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
const status = await invoke<{ active: boolean; enabled: boolean }>("get_service_status", { unit });
|
||||
active = status.active;
|
||||
enabled = status.enabled;
|
||||
}
|
||||
|
||||
async function run(action: "Start" | "Stop" | "Restart") {
|
||||
busy = true;
|
||||
try {
|
||||
await invoke("service_action", { unit, action });
|
||||
} finally {
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (active && critical) {
|
||||
if (!confirm(`Stop ${unit}? This is a core part of the desktop's event handling — stopping it may affect other bread apps until it's restarted.`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
run(active ? "Stop" : "Start");
|
||||
}
|
||||
|
||||
function openLogs() {
|
||||
invoke("open_logs", { unit });
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
</script>
|
||||
|
||||
<Group title="Service">
|
||||
<Row label={unit}>
|
||||
<span class="dim">{active ? "Running" : "Stopped"}</span>
|
||||
</Row>
|
||||
<Row label="Starts at login">
|
||||
<span class="dim">{enabled ? "Yes" : "No"}</span>
|
||||
</Row>
|
||||
<div class="buttons">
|
||||
<button disabled={busy} onclick={toggle}>{active ? "Stop" : "Start"}</button>
|
||||
<button disabled={busy} onclick={() => run("Restart")}>Restart</button>
|
||||
<button onclick={openLogs}>View logs</button>
|
||||
</div>
|
||||
{#if hasConfig}
|
||||
<Hint text="Save (below) only writes the config file — click Restart above for the running service to pick up the change." />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<style>
|
||||
.dim {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: color-mix(in srgb, var(--on-surface) 14%, transparent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/lib/components/Sidebar.svelte
Normal file
84
frontend/src/lib/components/Sidebar.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import { SIDEBAR_SECTIONS } from "$lib/sidebar";
|
||||
|
||||
let { activePage = $bindable() }: { activePage: string } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="sidebar">
|
||||
{#each SIDEBAR_SECTIONS as section (section.title ?? "untitled")}
|
||||
{#if section.title}
|
||||
<div class="section-header">{section.title}</div>
|
||||
{/if}
|
||||
{#each section.items as item (item.id)}
|
||||
<button
|
||||
class="row"
|
||||
class:selected={activePage === item.id}
|
||||
onclick={() => (activePage = item.id)}
|
||||
>
|
||||
<item.icon size={16} />
|
||||
<span class="label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--space-sm, 8px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: var(--space-lg, 16px) var(--space-sm, 8px) var(--space-xs, 4px);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.section-header:first-child {
|
||||
padding-top: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 9px var(--space-md, 12px);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: color-mix(in srgb, var(--on-surface) 8%, transparent);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
43
frontend/src/lib/components/Switch.svelte
Normal file
43
frontend/src/lib/components/Switch.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
let { value = $bindable(), ariaLabel }: { value: boolean; ariaLabel?: string } = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
class:on={value}
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
aria-label={ariaLabel}
|
||||
onclick={() => (value = !value)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
10
frontend/src/lib/components/SwitchField.svelte
Normal file
10
frontend/src/lib/components/SwitchField.svelte
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
import Switch from "./Switch.svelte";
|
||||
|
||||
let { label, value = $bindable() }: { label: string; value: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<Switch bind:value ariaLabel={label} />
|
||||
</Row>
|
||||
104
frontend/src/lib/components/TagsField.svelte
Normal file
104
frontend/src/lib/components/TagsField.svelte
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script lang="ts">
|
||||
let { label, value = $bindable() }: { label: string; value: string[] } = $props();
|
||||
|
||||
let text = $state("");
|
||||
|
||||
function commit() {
|
||||
const v = text.trim();
|
||||
if (v && !value.includes(v)) {
|
||||
value = [...value, v];
|
||||
}
|
||||
text = "";
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Backspace" && text === "" && value.length > 0) {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
value = value.filter((v) => v !== name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="field">
|
||||
<div class="header">
|
||||
<span class="label">{label}</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each value as name (name)}
|
||||
<span class="chip">
|
||||
{name}
|
||||
<button type="button" class="remove" onclick={() => remove(name)} aria-label={`Remove ${name}`}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
<input type="text" bind:value={text} onkeydown={onKeydown} placeholder="Type and press Enter…" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px) var(--space-lg, 16px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: var(--overlay);
|
||||
color: var(--on-overlay);
|
||||
border-radius: 999px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 12ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: 4px var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
29
frontend/src/lib/components/TextField.svelte
Normal file
29
frontend/src/lib/components/TextField.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import Row from "./Row.svelte";
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(),
|
||||
placeholder = "",
|
||||
}: { label: string; value: string; placeholder?: string } = $props();
|
||||
</script>
|
||||
|
||||
<Row {label}>
|
||||
<input type="text" bind:value {placeholder} />
|
||||
</Row>
|
||||
|
||||
<style>
|
||||
input {
|
||||
width: 28ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
40
frontend/src/lib/components/ViewScaffold.svelte
Normal file
40
frontend/src/lib/components/ViewScaffold.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { title, children }: { title: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<h1 class="title">{title}</h1>
|
||||
<div class="content">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view {
|
||||
padding: var(--space-xl, 20px) var(--space-xl, 20px) 48px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.6em;
|
||||
font-weight: bold;
|
||||
margin: 0 0 var(--space-xl, 20px);
|
||||
}
|
||||
|
||||
/* Groups lay out as a responsive grid rather than a single narrow
|
||||
column — on a wide (tiled/maximized) window that means multiple
|
||||
independent setting groups sit side by side instead of one column
|
||||
with a wall of empty space either side. `.group.wide` (device
|
||||
lists, thumbnail grids, log views — anything that reads badly
|
||||
squeezed into a card) opts out via `grid-column: 1 / -1`. */
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: var(--space-xl, 20px) 32px;
|
||||
align-items: start;
|
||||
max-width: 1400px;
|
||||
}
|
||||
</style>
|
||||
87
frontend/src/lib/sidebar.ts
Normal file
87
frontend/src/lib/sidebar.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Ports src/ui/sidebar.rs's declarative item lists verbatim. Grouped by task,
|
||||
// not "app vs system internals" — a user thinks "I want to change my Wi-Fi",
|
||||
// not "which of these is a bread-ecosystem app" (why breadcrumbs/Wi-Fi
|
||||
// Profiles lives in System, not Personalization).
|
||||
|
||||
import type { Component } from "svelte";
|
||||
import Wifi from "@lucide/svelte/icons/wifi";
|
||||
import Network from "@lucide/svelte/icons/network";
|
||||
import Bluetooth from "@lucide/svelte/icons/bluetooth";
|
||||
import Shield from "@lucide/svelte/icons/shield";
|
||||
import Volume2 from "@lucide/svelte/icons/volume-2";
|
||||
import BatteryFull from "@lucide/svelte/icons/battery-full";
|
||||
import Clock from "@lucide/svelte/icons/clock";
|
||||
import Monitor from "@lucide/svelte/icons/monitor";
|
||||
import Keyboard from "@lucide/svelte/icons/keyboard";
|
||||
import Rocket from "@lucide/svelte/icons/rocket";
|
||||
import Users from "@lucide/svelte/icons/users";
|
||||
import Palette from "@lucide/svelte/icons/palette";
|
||||
import Image from "@lucide/svelte/icons/image";
|
||||
import LayoutGrid from "@lucide/svelte/icons/layout-grid";
|
||||
import Grid3x3 from "@lucide/svelte/icons/grid-3x3";
|
||||
import Clipboard from "@lucide/svelte/icons/clipboard";
|
||||
import NotebookPen from "@lucide/svelte/icons/notebook-pen";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
import Cog from "@lucide/svelte/icons/cog";
|
||||
import Package from "@lucide/svelte/icons/package";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import History from "@lucide/svelte/icons/history";
|
||||
import Info from "@lucide/svelte/icons/info";
|
||||
|
||||
export interface SidebarItem {
|
||||
/** Must match a key in the view component map (see routing in +page.svelte). */
|
||||
id: string;
|
||||
label: string;
|
||||
/** Dim second line — the underlying binary/config name, for items whose
|
||||
* human label doesn't already make that obvious. */
|
||||
sublabel?: string;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
export const SYSTEM_ITEMS: SidebarItem[] = [
|
||||
{ id: "network", label: "Network", icon: Wifi },
|
||||
{ id: "breadcrumbs", label: "Wi-Fi Profiles", sublabel: "breadcrumbs", icon: Network },
|
||||
{ id: "bluetooth", label: "Bluetooth", icon: Bluetooth },
|
||||
{ id: "firewall", label: "Firewall", icon: Shield },
|
||||
{ id: "sound", label: "Sound", icon: Volume2 },
|
||||
{ id: "power", label: "Power", icon: BatteryFull },
|
||||
{ id: "datetime", label: "Date & Time", icon: Clock },
|
||||
{ id: "hyprland", label: "Display", sublabel: "monitors.json", icon: Monitor },
|
||||
{ id: "keybinds", label: "Keybinds", sublabel: "binds.json", icon: Keyboard },
|
||||
{ id: "autostart", label: "Startup Apps", sublabel: "autostart.json", icon: Rocket },
|
||||
{ id: "users", label: "Users", icon: Users },
|
||||
];
|
||||
|
||||
export const PERSONALIZATION_ITEMS: SidebarItem[] = [
|
||||
{ id: "appearance", label: "Appearance", sublabel: "settings.json", icon: Palette },
|
||||
{ id: "breadpaper", label: "Wallpaper", sublabel: "breadpaper", icon: Image },
|
||||
{ id: "breadbar", label: "Bar", sublabel: "breadbar", icon: LayoutGrid },
|
||||
{ id: "breadbox", label: "Launcher", sublabel: "breadbox", icon: Grid3x3 },
|
||||
{ id: "breadclip", label: "Clipboard", sublabel: "breadclipd", icon: Clipboard },
|
||||
{ id: "breadpad", label: "Notes", sublabel: "breadpad", icon: NotebookPen },
|
||||
{ id: "breadsearch", label: "File Search", sublabel: "breadsearch", icon: Search },
|
||||
{ id: "bread", label: "Daemon", sublabel: "breadd", icon: Cog },
|
||||
];
|
||||
|
||||
export const MAINTENANCE_ITEMS: SidebarItem[] = [
|
||||
{ id: "packages", label: "Packages", icon: Package },
|
||||
{ id: "aur", label: "AUR", icon: Search },
|
||||
{ id: "firmware", label: "Firmware", icon: RefreshCw },
|
||||
{ id: "snapshots", label: "Snapshots", icon: History },
|
||||
];
|
||||
|
||||
export const ABOUT_ITEMS: SidebarItem[] = [{ id: "about", label: "About", icon: Info }];
|
||||
|
||||
export interface SidebarSection {
|
||||
title: string | null;
|
||||
items: SidebarItem[];
|
||||
}
|
||||
|
||||
export const SIDEBAR_SECTIONS: SidebarSection[] = [
|
||||
{ title: "System", items: SYSTEM_ITEMS },
|
||||
{ title: "Personalization", items: PERSONALIZATION_ITEMS },
|
||||
{ title: "Maintenance", items: MAINTENANCE_ITEMS },
|
||||
{ title: null, items: ABOUT_ITEMS },
|
||||
];
|
||||
|
||||
export const DEFAULT_PAGE = "about";
|
||||
25
frontend/src/lib/streaming.ts
Normal file
25
frontend/src/lib/streaming.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Frontend half of the event-streaming command pattern (see
|
||||
// src-tauri/src/commands/streaming.rs) — runs a command, appends each
|
||||
// stdout/stderr line to a reactive log as it arrives, and resolves once the
|
||||
// process exits with whether it succeeded.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
export async function runStreamingCommand(
|
||||
program: string,
|
||||
args: string[],
|
||||
onLine: (line: string) => void,
|
||||
): Promise<boolean> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
const unlisten = await listen<{ session_id: string; line: string }>("cmd-output", (event) => {
|
||||
if (event.payload.session_id === sessionId) onLine(event.payload.line);
|
||||
});
|
||||
|
||||
try {
|
||||
return await invoke<boolean>("run_streaming_command", { sessionId, program, args });
|
||||
} finally {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
29
frontend/src/lib/theme/index.ts
Normal file
29
frontend/src/lib/theme/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Bridges bread-theme's pywal-derived palette into the webview. Mirrors
|
||||
// bread_theme::gtk::apply_shared()'s two-phase pattern: fetch once at
|
||||
// startup, then keep it live via a backend-pushed event — see
|
||||
// src-tauri/src/commands/theme.rs for the file-watch side of this.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
const STYLE_ELEMENT_ID = "bread-theme";
|
||||
|
||||
function applyThemeCss(css: string) {
|
||||
let style = document.getElementById(STYLE_ELEMENT_ID);
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ELEMENT_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = css;
|
||||
}
|
||||
|
||||
/** Call once at startup (e.g. from a root `$effect`/`onMount`). */
|
||||
export async function initTheme(): Promise<void> {
|
||||
const css = await invoke<string>("get_theme_css");
|
||||
applyThemeCss(css);
|
||||
|
||||
await listen<string>("theme-changed", (event) => {
|
||||
applyThemeCss(event.payload);
|
||||
});
|
||||
}
|
||||
132
frontend/src/lib/views/About.svelte
Normal file
132
frontend/src/lib/views/About.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
|
||||
interface SystemInfo {
|
||||
os: string;
|
||||
kernel: string;
|
||||
cpu: string;
|
||||
gpu: string;
|
||||
memory: string;
|
||||
disk: string;
|
||||
uptime: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
let info = $state<SystemInfo | null>(null);
|
||||
let hostnameInput = $state("");
|
||||
let status = $state("");
|
||||
let applying = $state(false);
|
||||
|
||||
// The backend reports the GPU as a raw lspci device string, e.g.
|
||||
// "Advanced Micro Devices, Inc. [AMD/ATI] Krackan [Radeon 840M / 860M Graphics] (rev c2)".
|
||||
// That's too technical for a general settings page, so extract just the
|
||||
// marketing model name. Heuristic (not exhaustive — just needs to read
|
||||
// well for common vendors): drop the trailing "(rev ..)", prefer the
|
||||
// text inside the last [...] bracket group (usually the model name),
|
||||
// collapse "840M / 860M" style multi-model lists to the last variant,
|
||||
// and drop a trailing generic word like "Graphics"/"Controller".
|
||||
function friendlyGpu(raw: string): string {
|
||||
if (!raw) return raw;
|
||||
let s = raw.replace(/\s*\(rev [^)]*\)\s*$/i, "").trim();
|
||||
const brackets = [...s.matchAll(/\[([^\]]+)\]/g)];
|
||||
if (brackets.length > 0) {
|
||||
s = brackets[brackets.length - 1][1];
|
||||
} else {
|
||||
s = s.replace(/^.*?,\s*Inc\.\s*/i, "").trim();
|
||||
}
|
||||
s = s.replace(/\b(\w+)\s+[\w.]+\s*\/\s*(\w+)\b/, "$1 $2");
|
||||
s = s.replace(/\s+(Graphics|Controller|Series)\s*$/i, "");
|
||||
return s.trim() || raw;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
info = await invoke<SystemInfo>("get_system_info");
|
||||
hostnameInput = info.hostname;
|
||||
});
|
||||
|
||||
async function applyHostname() {
|
||||
if (!hostnameInput.trim()) {
|
||||
status = "Hostname can't be empty";
|
||||
return;
|
||||
}
|
||||
applying = true;
|
||||
status = "Applying…";
|
||||
try {
|
||||
await invoke("set_hostname", { name: hostnameInput.trim() });
|
||||
status = "Applied";
|
||||
} catch (e) {
|
||||
status = `Error: ${e}`;
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="About">
|
||||
{#if info}
|
||||
<Group title="This machine">
|
||||
<InfoRow label="Operating system" value={info.os} />
|
||||
<InfoRow label="Kernel" value={info.kernel} />
|
||||
<InfoRow label="CPU" value={info.cpu} />
|
||||
<InfoRow label="GPU" value={friendlyGpu(info.gpu)} />
|
||||
<InfoRow label="Memory" value={info.memory} />
|
||||
<InfoRow label="Disk (/)" value={info.disk} />
|
||||
<InfoRow label="Uptime" value={info.uptime} />
|
||||
</Group>
|
||||
|
||||
<Group title="Hostname" hint="Changes the machine's network name. Takes effect immediately; needs your password (polkit).">
|
||||
<div class="hostname-row">
|
||||
<input type="text" bind:value={hostnameInput} disabled={applying} />
|
||||
<button disabled={applying} onclick={applyHostname}>Apply</button>
|
||||
</div>
|
||||
{#if status}
|
||||
<span class="status">{status}</span>
|
||||
{/if}
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.hostname-row {
|
||||
display: flex;
|
||||
gap: var(--space-md, 12px);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
128
frontend/src/lib/views/Appearance.svelte
Normal file
128
frontend/src/lib/views/Appearance.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import HyprColorField from "$lib/components/HyprColorField.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
// Common XKB layout codes. Not exhaustive — if the config holds
|
||||
// something else, it's merged in below so it's never dropped from the
|
||||
// dropdown.
|
||||
const COMMON_KB_LAYOUTS = [
|
||||
"us", "gb", "de", "fr", "es", "it", "pt", "nl", "se", "no", "dk", "fi",
|
||||
"pl", "cz", "sk", "hu", "ro", "gr", "tr", "ru", "ua", "jp", "kr", "cn",
|
||||
"br", "ca", "ch", "be", "at", "ie",
|
||||
];
|
||||
|
||||
// Hyprland's follow_mouse variable (see the wiki's Variables page):
|
||||
// 0 = cursor movement never changes focus; 1 = focus always follows the
|
||||
// window under the cursor; 2 = focus follows the cursor, but clicking a
|
||||
// window keeps keyboard focus there until the mouse moves again; 3 =
|
||||
// cursor and keyboard focus are fully independent.
|
||||
const FOLLOW_MOUSE_MODES: { value: number; label: string }[] = [
|
||||
{ value: 0, label: "Off — click to focus" },
|
||||
{ value: 1, label: "Follow mouse" },
|
||||
{ value: 2, label: "Follow mouse, detach on click" },
|
||||
{ value: 3, label: "Fully detached from keyboard focus" },
|
||||
];
|
||||
|
||||
interface Appearance {
|
||||
gaps_in: number;
|
||||
gaps_out: number;
|
||||
border_size: number;
|
||||
active_border: string;
|
||||
inactive_border: string;
|
||||
layout: string;
|
||||
resize_on_border: boolean;
|
||||
rounding: number;
|
||||
blur_enabled: boolean;
|
||||
blur_size: number;
|
||||
blur_passes: number;
|
||||
shadow_enabled: boolean;
|
||||
shadow_range: number;
|
||||
shadow_render_power: number;
|
||||
kb_layout: string;
|
||||
follow_mouse: number;
|
||||
natural_scroll: boolean;
|
||||
}
|
||||
|
||||
let cfg = $state<Appearance | null>(null);
|
||||
|
||||
let kbLayoutOptions = $derived(
|
||||
cfg && !COMMON_KB_LAYOUTS.includes(cfg.kb_layout) ? [...COMMON_KB_LAYOUTS, cfg.kb_layout] : COMMON_KB_LAYOUTS,
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<Appearance>("get_appearance");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_appearance", { appearance: cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Appearance">
|
||||
{#if cfg}
|
||||
<Group title="Windows & borders" hint="Gaps, borders, and tiling — the same settings.json Hyprland reads at login.">
|
||||
<NumberField label="Gaps between windows" bind:value={cfg.gaps_in} min={0} max={50} />
|
||||
<NumberField label="Gaps around screen edge" bind:value={cfg.gaps_out} min={0} max={50} />
|
||||
<NumberField label="Border width" bind:value={cfg.border_size} min={0} max={10} />
|
||||
<HyprColorField label="Active border color" bind:value={cfg.active_border} />
|
||||
<HyprColorField label="Inactive border color" bind:value={cfg.inactive_border} />
|
||||
<SelectField label="Tiling layout" bind:value={cfg.layout} options={["dwindle", "master"]} />
|
||||
<SwitchField label="Resize by dragging borders" bind:value={cfg.resize_on_border} />
|
||||
</Group>
|
||||
|
||||
<Group title="Effects">
|
||||
<NumberField label="Corner rounding" bind:value={cfg.rounding} min={0} max={30} />
|
||||
<SwitchField label="Blur" bind:value={cfg.blur_enabled} />
|
||||
<NumberField label="Blur size" bind:value={cfg.blur_size} min={0} max={20} />
|
||||
<NumberField label="Blur passes" bind:value={cfg.blur_passes} min={1} max={5} />
|
||||
<SwitchField label="Window shadows" bind:value={cfg.shadow_enabled} />
|
||||
<NumberField label="Shadow range" bind:value={cfg.shadow_range} min={0} max={40} />
|
||||
<NumberField label="Shadow render power" bind:value={cfg.shadow_render_power} min={1} max={4} />
|
||||
</Group>
|
||||
|
||||
<Group title="Input">
|
||||
<SelectField label="Keyboard layout" bind:value={cfg.kb_layout} options={kbLayoutOptions} />
|
||||
<Row label="Focus-follows-mouse mode">
|
||||
<select bind:value={cfg.follow_mouse}>
|
||||
{#each FOLLOW_MOUSE_MODES as mode (mode.value)}
|
||||
<option value={mode.value}>{mode.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
<SwitchField label="Natural scrolling (touchpad)" bind:value={cfg.natural_scroll} />
|
||||
</Group>
|
||||
|
||||
<Hint text="Changes apply on next login or Hyprland reload — this saves settings.json, it doesn't reload Hyprland live." />
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
151
frontend/src/lib/views/Aur.svelte
Normal file
151
frontend/src/lib/views/Aur.svelte
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import Search from "@lucide/svelte/icons/search";
|
||||
|
||||
interface AurResult {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
let query = $state("");
|
||||
let results = $state<AurResult[] | null>(null);
|
||||
let status = $state("Search for a package to see results here.");
|
||||
let searching = $state(false);
|
||||
|
||||
async function search() {
|
||||
if (!query.trim()) return;
|
||||
searching = true;
|
||||
status = "Searching…";
|
||||
results = await invoke<AurResult[]>("search_aur", { query });
|
||||
status = results.length === 0 ? "No results." : `${results.length} result(s)`;
|
||||
searching = false;
|
||||
}
|
||||
|
||||
function install(pkg: string) {
|
||||
invoke("install_aur_package", { pkg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="AUR">
|
||||
<Group
|
||||
title="Search"
|
||||
hint="Search the Arch User Repository via yay. Installing opens a terminal — AUR packages run arbitrary build scripts, and reviewing what yay is about to do (and entering your password) is a real safety step."
|
||||
wide
|
||||
>
|
||||
<div class="search-row">
|
||||
<input type="text" bind:value={query} placeholder="Search the AUR…" onkeydown={(e) => e.key === "Enter" && search()} />
|
||||
<button disabled={searching} onclick={search}>Search</button>
|
||||
</div>
|
||||
|
||||
<Hint text={status} />
|
||||
|
||||
<div class="results">
|
||||
{#if results && results.length === 0}
|
||||
<EmptyState icon={Search} title="No results" hint="Try a different search term." />
|
||||
{:else if results}
|
||||
{#each results as r (r.name)}
|
||||
<div class="card">
|
||||
<div class="top">
|
||||
<span class="name" title={r.name}>{r.name}</span>
|
||||
<span class="version" title={r.version}>{r.version}</span>
|
||||
<button class="install" onclick={() => install(r.name)}>Install</button>
|
||||
</div>
|
||||
<span class="desc">{r.description}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.search-row input {
|
||||
flex: 1;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.search-row button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-row button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.results {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desc {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.install {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
168
frontend/src/lib/views/Autostart.svelte
Normal file
168
frontend/src/lib/views/Autostart.svelte
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import FolderOpen from "@lucide/svelte/icons/folder-open";
|
||||
|
||||
interface AutostartEntry {
|
||||
command: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
let entries = $state<AutostartEntry[] | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
entries = await invoke<AutostartEntry[]>("get_autostart_entries");
|
||||
});
|
||||
|
||||
function addEntry() {
|
||||
entries = [...(entries ?? []), { command: "", label: "", enabled: true }];
|
||||
}
|
||||
|
||||
function removeEntry(i: number) {
|
||||
entries = entries!.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
async function browseFor(entry: AutostartEntry) {
|
||||
const picked = await open({ directory: false, defaultPath: "/usr/bin" });
|
||||
if (typeof picked === "string") entry.command = picked;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await invoke("save_autostart_entries", { entries });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Startup Apps">
|
||||
{#if entries}
|
||||
<Group
|
||||
title="Extra autostart apps"
|
||||
hint="What launches after login, beyond the core desktop (bar, theme, clipboard, etc. always start regardless). Toggle off, edit, or add your own."
|
||||
wide
|
||||
>
|
||||
{#each entries as entry, i (i)}
|
||||
<div class="row">
|
||||
<button
|
||||
class="switch"
|
||||
class:on={entry.enabled}
|
||||
role="switch"
|
||||
aria-checked={entry.enabled}
|
||||
aria-label="Enabled"
|
||||
onclick={() => (entry.enabled = !entry.enabled)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
<div class="fields">
|
||||
<input type="text" bind:value={entry.label} placeholder="Label" class="label" title={entry.label} />
|
||||
<input type="text" bind:value={entry.command} placeholder="command" class="command" title={entry.command} />
|
||||
</div>
|
||||
<button class="browse" onclick={() => browseFor(entry)} aria-label="Browse for program">
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<button class="remove" onclick={() => removeEntry(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addEntry}>Add app</button>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.fields {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.label,
|
||||
.command {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command {
|
||||
opacity: 0.65;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browse {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
188
frontend/src/lib/views/Bluetooth.svelte
Normal file
188
frontend/src/lib/views/Bluetooth.svelte
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import BluetoothIcon from "@lucide/svelte/icons/bluetooth";
|
||||
import BluetoothOff from "@lucide/svelte/icons/bluetooth-off";
|
||||
|
||||
interface BtDevice {
|
||||
address: string;
|
||||
name: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
let powered = $state<boolean | null>(null);
|
||||
let paired = $state<BtDevice[] | null>(null);
|
||||
let scanResults = $state<BtDevice[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let log = $state<string[]>([]);
|
||||
|
||||
async function refreshPaired() {
|
||||
paired = await invoke<BtDevice[]>("get_paired_devices");
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
powered = await invoke<boolean | null>("get_adapter_powered");
|
||||
if (powered !== null) await refreshPaired();
|
||||
});
|
||||
|
||||
async function togglePower(on: boolean) {
|
||||
powered = on;
|
||||
await invoke("set_adapter_powered", { on });
|
||||
}
|
||||
|
||||
async function toggleConnect(dev: BtDevice) {
|
||||
log = [];
|
||||
try {
|
||||
await invoke(dev.connected ? "bt_disconnect" : "bt_connect", { address: dev.address });
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refreshPaired();
|
||||
}
|
||||
|
||||
async function forget(dev: BtDevice) {
|
||||
if (!confirm(`Forget "${dev.name}"? You'll need to pair it again to reconnect.`)) return;
|
||||
log = [];
|
||||
try {
|
||||
await invoke("bt_forget", { address: dev.address });
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refreshPaired();
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanning = true;
|
||||
scanResults = await invoke<BtDevice[]>("scan_bluetooth");
|
||||
scanning = false;
|
||||
}
|
||||
|
||||
async function pair(dev: BtDevice) {
|
||||
log = [];
|
||||
try {
|
||||
await invoke("bt_pair", { address: dev.address });
|
||||
await refreshPaired();
|
||||
scanResults = scanResults!.filter((d) => d.address !== dev.address);
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Bluetooth">
|
||||
{#if powered === null}
|
||||
<EmptyState
|
||||
icon={BluetoothOff}
|
||||
title="No Bluetooth adapter found"
|
||||
hint="This machine doesn't have Bluetooth hardware, or the kernel module isn't loaded."
|
||||
/>
|
||||
{:else}
|
||||
<Group title="Adapter">
|
||||
<SwitchField label="Bluetooth" bind:value={() => powered ?? false, (v) => togglePower(v)} />
|
||||
</Group>
|
||||
|
||||
<Group title="Paired devices">
|
||||
<div class="list">
|
||||
{#if paired === null}
|
||||
<Hint text="Loading…" />
|
||||
{:else if paired.length === 0}
|
||||
<EmptyState icon={BluetoothIcon} title="No paired devices" hint="Scan and pair a device to see it here." />
|
||||
{:else}
|
||||
{#each paired as dev (dev.address)}
|
||||
<div class="row">
|
||||
<span class="name" class:active={dev.connected}>{dev.name}{dev.connected ? " (connected)" : ""}</span>
|
||||
<button class="action" onclick={() => toggleConnect(dev)}>{dev.connected ? "Disconnect" : "Connect"}</button>
|
||||
<button class="remove" onclick={() => forget(dev)}>Forget</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Available devices"
|
||||
hint="Scanning takes a few seconds. Devices needing a PIN aren't supported — only "just works" pairing (most headphones, speakers, keyboards, and mice)."
|
||||
>
|
||||
<div class="list">
|
||||
{#if scanResults === null}
|
||||
<EmptyState icon={BluetoothIcon} title="Not scanned yet" hint="Press Scan to look for nearby devices." />
|
||||
{:else if scanResults.length === 0}
|
||||
<EmptyState icon={BluetoothIcon} title="No new devices found" hint="Make sure the device is powered on and in pairing mode, then Scan again." />
|
||||
{:else}
|
||||
{#each scanResults as dev (dev.address)}
|
||||
<div class="row">
|
||||
<span class="name">{dev.name}</span>
|
||||
<button class="action" onclick={() => pair(dev)}>Pair</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<button class="scan" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
min-height: 60px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.name.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.scan {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.scan:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
86
frontend/src/lib/views/Bread.svelte
Normal file
86
frontend/src/lib/views/Bread.svelte
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import TagsField from "$lib/components/TagsField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface BreadConfig {
|
||||
log_level: string;
|
||||
socket_path: string;
|
||||
lua_entry_point: string;
|
||||
lua_module_path: string;
|
||||
modules_builtin: boolean;
|
||||
modules_disable: string[];
|
||||
adapter_hyprland: boolean;
|
||||
adapter_udev: boolean;
|
||||
udev_subsystems: string[];
|
||||
adapter_power: boolean;
|
||||
power_poll_interval_secs: number;
|
||||
adapter_network: boolean;
|
||||
adapter_bluetooth: boolean;
|
||||
dedup_window_ms: number;
|
||||
notif_default_timeout_ms: number;
|
||||
notif_default_urgency: string;
|
||||
notif_notify_send_path: string;
|
||||
}
|
||||
|
||||
let cfg = $state<BreadConfig | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<BreadConfig>("get_bread_config");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_bread_config", { cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Daemon">
|
||||
<ServiceControl unit="breadd.service" critical hasConfig />
|
||||
|
||||
{#if cfg}
|
||||
<Group title="Daemon">
|
||||
<SelectField label="Log level" bind:value={cfg.log_level} options={["error", "warn", "info", "debug", "trace"]} />
|
||||
<TextField label="Socket path" bind:value={cfg.socket_path} placeholder="default (XDG runtime dir)" />
|
||||
</Group>
|
||||
|
||||
<Group title="Lua">
|
||||
<TextField label="Entry point" bind:value={cfg.lua_entry_point} placeholder="~/.config/bread/init.lua" />
|
||||
<TextField label="Module path" bind:value={cfg.lua_module_path} placeholder="~/.config/bread/modules" />
|
||||
</Group>
|
||||
|
||||
<Group title="Modules">
|
||||
<SwitchField label="Load built-in modules" bind:value={cfg.modules_builtin} />
|
||||
<TagsField label="Disabled modules" bind:value={cfg.modules_disable} />
|
||||
</Group>
|
||||
|
||||
<Group title="Adapters" hint="Sources breadd normalises into events. Disable any you don't use.">
|
||||
<SwitchField label="Hyprland" bind:value={cfg.adapter_hyprland} />
|
||||
<SwitchField label="udev (devices)" bind:value={cfg.adapter_udev} />
|
||||
<TagsField label="udev subsystems" bind:value={cfg.udev_subsystems} />
|
||||
<SwitchField label="Power" bind:value={cfg.adapter_power} />
|
||||
<NumberField label="Power poll interval (s)" bind:value={cfg.power_poll_interval_secs} min={1} max={3600} />
|
||||
<SwitchField label="Network" bind:value={cfg.adapter_network} />
|
||||
<SwitchField label="Bluetooth" bind:value={cfg.adapter_bluetooth} />
|
||||
</Group>
|
||||
|
||||
<Group title="Events">
|
||||
<NumberField label="Dedup window (ms)" bind:value={cfg.dedup_window_ms} min={0} max={10000} step={50} />
|
||||
</Group>
|
||||
|
||||
<Group title="Notifications">
|
||||
<NumberField label="Default timeout (ms)" bind:value={cfg.notif_default_timeout_ms} min={0} max={60000} step={500} />
|
||||
<SelectField label="Default urgency" bind:value={cfg.notif_default_urgency} options={["low", "normal", "critical"]} />
|
||||
<TextField label="notify-send path" bind:value={cfg.notif_notify_send_path} placeholder="auto-detected" />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
178
frontend/src/lib/views/Breadbar.svelte
Normal file
178
frontend/src/lib/views/Breadbar.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
|
||||
interface BreadbarStyle {
|
||||
font_family: string;
|
||||
font_size: number;
|
||||
bar_border_radius: number;
|
||||
bar_padding: number;
|
||||
workspace_inactive_opacity: number;
|
||||
workspace_font_size: number;
|
||||
stat_gap: number;
|
||||
tray_icon_size: number;
|
||||
notification_border_radius: number;
|
||||
}
|
||||
|
||||
let style = $state<BreadbarStyle | null>(null);
|
||||
let advancedOpen = $state(false);
|
||||
let css = $state("");
|
||||
let cssStatus = $state("");
|
||||
let cssSaving = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
style = await invoke<BreadbarStyle>("get_breadbar_style");
|
||||
css = await invoke<string>("get_breadbar_css");
|
||||
});
|
||||
|
||||
async function saveStyle() {
|
||||
await invoke("save_breadbar_style", { style });
|
||||
css = await invoke<string>("get_breadbar_css");
|
||||
}
|
||||
|
||||
async function saveCss() {
|
||||
cssSaving = true;
|
||||
try {
|
||||
const reloaded = await invoke<boolean>("save_breadbar_css", { css });
|
||||
cssStatus = reloaded ? "Saved & reloaded" : "Saved";
|
||||
style = await invoke<BreadbarStyle>("get_breadbar_style");
|
||||
setTimeout(() => (cssStatus = ""), 3000);
|
||||
} catch (e) {
|
||||
cssStatus = `Error: ${e}`;
|
||||
} finally {
|
||||
cssSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Bar">
|
||||
{#if style}
|
||||
<Group title="Text" hint="Applies to the clock, workspace numbers, and stat labels.">
|
||||
<TextField label="Font" bind:value={style.font_family} placeholder="Varela Round" />
|
||||
<NumberField label="Font size" bind:value={style.font_size} min={8} max={32} />
|
||||
</Group>
|
||||
|
||||
<Group title="Bar shape">
|
||||
<NumberField label="Corner rounding" bind:value={style.bar_border_radius} min={0} max={40} />
|
||||
<NumberField label="Inner padding" bind:value={style.bar_padding} min={0} max={40} />
|
||||
</Group>
|
||||
|
||||
<Group title="Workspace indicator">
|
||||
<NumberField label="Size" bind:value={style.workspace_font_size} min={8} max={40} />
|
||||
<Row label="Inactive dimness">
|
||||
<div class="opacity-row">
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={style.workspace_inactive_opacity} />
|
||||
<span class="pct">{Math.round(style.workspace_inactive_opacity * 100)}%</span>
|
||||
</div>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
<Group title="Spacing & icons">
|
||||
<NumberField label="Gap between stats" bind:value={style.stat_gap} min={0} max={40} />
|
||||
<NumberField label="Tray icon size" bind:value={style.tray_icon_size} min={8} max={32} />
|
||||
<NumberField label="Notification corner rounding" bind:value={style.notification_border_radius} min={0} max={24} />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={saveStyle} />
|
||||
{/if}
|
||||
|
||||
<Group title="Advanced" hint="Raw stylesheet. Anything set here can also be changed above — those fields edit this same file." wide>
|
||||
<button class="toggle" onclick={() => (advancedOpen = !advancedOpen)}>
|
||||
{#if advancedOpen}<ChevronDown size={14} />{:else}<ChevronRight size={14} />{/if}
|
||||
Edit raw CSS
|
||||
</button>
|
||||
|
||||
{#if advancedOpen}
|
||||
<textarea bind:value={css} spellcheck="false"></textarea>
|
||||
<div class="row">
|
||||
<button class="save-css" disabled={cssSaving} onclick={saveCss}>Save CSS</button>
|
||||
<span class="status">{cssStatus}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.opacity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.opacity-row input[type="range"] {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.pct {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
width: 4ch;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs, 4px);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--on-surface);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: var(--space-xs, 4px) 0;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
margin-top: var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.save-css {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-css:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
128
frontend/src/lib/views/Breadbox.svelte
Normal file
128
frontend/src/lib/views/Breadbox.svelte
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface Context {
|
||||
name: string;
|
||||
priority: string[];
|
||||
}
|
||||
|
||||
let contexts = $state<Context[] | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
contexts = await invoke<Context[]>("get_breadbox_contexts");
|
||||
});
|
||||
|
||||
function addContext() {
|
||||
contexts = [...(contexts ?? []), { name: "new", priority: [] }];
|
||||
}
|
||||
|
||||
function removeContext(i: number) {
|
||||
contexts = contexts!.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
function priorityText(ctx: Context) {
|
||||
return ctx.priority.join(", ");
|
||||
}
|
||||
|
||||
function setPriority(ctx: Context, text: string) {
|
||||
ctx.priority = text
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadbox_contexts", { contexts });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Launcher">
|
||||
{#if contexts}
|
||||
<Group
|
||||
title="Contexts"
|
||||
hint="Launcher contexts — each lists, in priority order, the apps/categories surfaced first."
|
||||
wide
|
||||
>
|
||||
{#if contexts.length === 0}
|
||||
<div class="empty">No launcher contexts yet. Add one to control which apps/categories breadbox surfaces first.</div>
|
||||
{/if}
|
||||
{#each contexts as ctx, i (i)}
|
||||
<div class="row">
|
||||
<input type="text" bind:value={ctx.name} placeholder="name" class="name" />
|
||||
<input
|
||||
type="text"
|
||||
value={priorityText(ctx)}
|
||||
oninput={(e) => setPriority(ctx, e.currentTarget.value)}
|
||||
placeholder="firefox, code, Development, ..."
|
||||
class="priority"
|
||||
/>
|
||||
<button class="remove" onclick={() => removeContext(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addContext}>Add context</button>
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
|
||||
<ServiceControl unit="breadbox-sync.service" hasConfig />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 14ch;
|
||||
}
|
||||
|
||||
.priority {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
padding: var(--space-md, 12px) 0;
|
||||
}
|
||||
</style>
|
||||
32
frontend/src/lib/views/Breadclip.svelte
Normal file
32
frontend/src/lib/views/Breadclip.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
|
||||
function openHistory() {
|
||||
invoke("open_breadclip");
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Clipboard">
|
||||
<Group
|
||||
title="Clipboard history"
|
||||
hint="Keeps a history of copied text/images and shows it as a popup. breadclipd (right) is the background daemon that watches the clipboard — breadclip itself is just the popup UI, launched on demand by the keybind or the button below."
|
||||
>
|
||||
<button class="open-btn" onclick={openHistory}>Open history (SUPER+V)</button>
|
||||
</Group>
|
||||
<ServiceControl unit="breadclipd.service" />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.open-btn {
|
||||
align-self: flex-start;
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
276
frontend/src/lib/views/Breadcrumbs.svelte
Normal file
276
frontend/src/lib/views/Breadcrumbs.svelte
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import ChipPickerField from "$lib/components/ChipPickerField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import Switch from "$lib/components/Switch.svelte";
|
||||
|
||||
interface Settings {
|
||||
default_profile: string;
|
||||
dns: string;
|
||||
exit_node: string;
|
||||
ping_host: string;
|
||||
connectivity_url: string;
|
||||
nmcli_wait: number;
|
||||
watch_interval: number;
|
||||
}
|
||||
interface Network {
|
||||
ssid: string;
|
||||
password: string;
|
||||
hidden: boolean;
|
||||
}
|
||||
interface Profile {
|
||||
name: string;
|
||||
networks: string[];
|
||||
detect_ssids: string[];
|
||||
bootstrap: string;
|
||||
exit_node: string;
|
||||
tailscale: boolean;
|
||||
include_all_known: boolean;
|
||||
}
|
||||
interface BreadcrumbsConfig {
|
||||
settings: Settings;
|
||||
networks: Network[];
|
||||
profiles: Profile[];
|
||||
}
|
||||
|
||||
let cfg = $state<BreadcrumbsConfig | null>(null);
|
||||
// Profile names are free-text (set via "Add profile" below), not a fixed
|
||||
// enum — populate the default-profile options from whatever profiles
|
||||
// actually exist, rather than a hardcoded guess that can't match a
|
||||
// user's real profile names.
|
||||
let profileNames = $derived(cfg?.profiles.map((p) => p.name) ?? []);
|
||||
// Saved network SSIDs feed the per-profile network pickers below — no
|
||||
// typing an SSID by hand and hoping it matches one saved above.
|
||||
let savedSsids = $derived(cfg?.networks.map((n) => n.ssid).filter((s) => s.trim().length > 0) ?? []);
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<BreadcrumbsConfig>("get_breadcrumbs_config");
|
||||
});
|
||||
|
||||
function addNetwork() {
|
||||
cfg!.networks = [...cfg!.networks, { ssid: "", password: "", hidden: false }];
|
||||
}
|
||||
function removeNetwork(i: number) {
|
||||
cfg!.networks = cfg!.networks.filter((_, idx) => idx !== i);
|
||||
}
|
||||
function addProfile() {
|
||||
cfg!.profiles = [
|
||||
...cfg!.profiles,
|
||||
{ name: "new", networks: [], detect_ssids: [], bootstrap: "", exit_node: "", tailscale: false, include_all_known: false },
|
||||
];
|
||||
}
|
||||
function removeProfile(i: number) {
|
||||
cfg!.profiles = cfg!.profiles.filter((_, idx) => idx !== i);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadcrumbs_config", { input: cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Wi-Fi Profiles">
|
||||
<ServiceControl unit="breadcrumbs.service" hasConfig />
|
||||
|
||||
{#if cfg}
|
||||
<Group title="Settings">
|
||||
{#if profileNames.length > 0}
|
||||
<SelectField label="Default profile" bind:value={cfg.settings.default_profile} options={profileNames} />
|
||||
{:else}
|
||||
<TextField label="Default profile" bind:value={cfg.settings.default_profile} placeholder="add a profile below first" />
|
||||
{/if}
|
||||
<TextField label="DNS" bind:value={cfg.settings.dns} placeholder="1.1.1.1" />
|
||||
<TextField label="Exit node" bind:value={cfg.settings.exit_node} placeholder="tailscale exit node" />
|
||||
<TextField label="Ping host" bind:value={cfg.settings.ping_host} placeholder="1.1.1.1" />
|
||||
<TextField label="Connectivity check URL" bind:value={cfg.settings.connectivity_url} />
|
||||
<NumberField label="Connection timeout (s)" bind:value={cfg.settings.nmcli_wait} min={1} max={120} />
|
||||
<NumberField label="Check connectivity every (s)" bind:value={cfg.settings.watch_interval} min={1} max={600} />
|
||||
</Group>
|
||||
|
||||
<Group title="Saved networks" wide>
|
||||
<div class="list">
|
||||
{#each cfg.networks as net, i (i)}
|
||||
<div class="net-row">
|
||||
<input type="text" bind:value={net.ssid} placeholder="Network name (SSID)" class="ssid" />
|
||||
<input type="password" bind:value={net.password} placeholder="Password" class="pass" />
|
||||
<div class="hidden-label">
|
||||
<Switch bind:value={net.hidden} ariaLabel="Hidden network" />
|
||||
<button type="button" class="label-text" onclick={() => (net.hidden = !net.hidden)}>Hidden network</button>
|
||||
</div>
|
||||
<button class="remove" onclick={() => removeNetwork(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="add" onclick={addNetwork}>Add network</button>
|
||||
</Group>
|
||||
|
||||
<Group title="Location profiles" hint="Each profile switches DNS/exit-node/routing when you're on one of its networks." wide>
|
||||
{#each cfg.profiles as profile, i (i)}
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<input type="text" bind:value={profile.name} placeholder="Profile name (e.g. Home)" class="profile-name" />
|
||||
<button class="remove" onclick={() => removeProfile(i)}>Remove</button>
|
||||
</div>
|
||||
|
||||
<ChipPickerField
|
||||
label="Networks in this profile"
|
||||
bind:value={profile.networks}
|
||||
options={savedSsids}
|
||||
emptyOptionsHint="Add a saved network above first."
|
||||
/>
|
||||
<ChipPickerField
|
||||
label="Auto-switch here when nearby"
|
||||
bind:value={profile.detect_ssids}
|
||||
options={savedSsids}
|
||||
emptyOptionsHint="Add a saved network above first."
|
||||
/>
|
||||
|
||||
<label class="field-label">
|
||||
Exit node
|
||||
<input type="text" bind:value={profile.exit_node} placeholder="Tailscale exit node (optional)" />
|
||||
</label>
|
||||
<label class="field-label">
|
||||
Run on connect
|
||||
<input type="text" bind:value={profile.bootstrap} placeholder="Optional command (optional)" />
|
||||
</label>
|
||||
<div class="check-label">
|
||||
<Switch bind:value={profile.tailscale} ariaLabel="Enable Tailscale" />
|
||||
<button type="button" class="label-text" onclick={() => (profile.tailscale = !profile.tailscale)}>Enable Tailscale</button>
|
||||
</div>
|
||||
<div class="check-label">
|
||||
<Switch bind:value={profile.include_all_known} ariaLabel="Also allow any other saved network" />
|
||||
<button
|
||||
type="button"
|
||||
class="label-text"
|
||||
onclick={() => (profile.include_all_known = !profile.include_all_known)}
|
||||
>
|
||||
Also allow any other saved network, not just the ones listed above
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addProfile}>Add profile</button>
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.net-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 8px);
|
||||
align-items: center;
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.ssid {
|
||||
width: 20ch;
|
||||
}
|
||||
|
||||
.pass {
|
||||
width: 14ch;
|
||||
}
|
||||
|
||||
.hidden-label,
|
||||
.check-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.field-label input[type="text"] {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
138
frontend/src/lib/views/Breadpad.svelte
Normal file
138
frontend/src/lib/views/Breadpad.svelte
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import FileField from "$lib/components/FileField.svelte";
|
||||
import PasswordField from "$lib/components/PasswordField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import TagsField from "$lib/components/TagsField.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface BreadpadConfig {
|
||||
default_type: string;
|
||||
workspace_tag: boolean;
|
||||
snooze_options: string[];
|
||||
archive_after_days: number;
|
||||
model_path: string;
|
||||
tokenizer_path: string;
|
||||
ollama_enabled: boolean;
|
||||
ollama_endpoint: string;
|
||||
ollama_model: string;
|
||||
ollama_confidence_threshold: number;
|
||||
reminders_default_morning: string;
|
||||
reminders_missed_grace_minutes: number;
|
||||
calendar_enabled: boolean;
|
||||
calendar_url: string;
|
||||
calendar_username: string;
|
||||
calendar_password: string;
|
||||
}
|
||||
|
||||
let cfg = $state<BreadpadConfig | null>(null);
|
||||
|
||||
// The confidence threshold is an f64 (0-1, step 0.05); binding a plain
|
||||
// number input directly to it shows raw float noise (e.g. "0.6000000")
|
||||
// once the value has been nudged by the spinner. Instead we mirror it
|
||||
// into a formatted display string, and only parse it back into `cfg` as
|
||||
// a value rounded to 2 decimals.
|
||||
let confidenceText = $state("0.6");
|
||||
let confidenceLoaded = false;
|
||||
|
||||
function formatConfidence(n: number): string {
|
||||
return (Math.round(n * 100) / 100).toString();
|
||||
}
|
||||
|
||||
function onConfidenceInput(e: Event) {
|
||||
confidenceText = (e.currentTarget as HTMLInputElement).value;
|
||||
const parsed = parseFloat(confidenceText);
|
||||
if (!Number.isNaN(parsed) && cfg) {
|
||||
cfg.ollama_confidence_threshold = Math.round(parsed * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
function onConfidenceBlur() {
|
||||
if (cfg) confidenceText = formatConfidence(cfg.ollama_confidence_threshold);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (cfg && !confidenceLoaded) {
|
||||
confidenceText = formatConfidence(cfg.ollama_confidence_threshold);
|
||||
confidenceLoaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<BreadpadConfig>("get_breadpad_config");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadpad_config", { cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Notes">
|
||||
{#if cfg}
|
||||
<Group title="Capture">
|
||||
<SelectField label="Default note type" bind:value={cfg.default_type} options={["note", "reminder", "task"]} />
|
||||
<SwitchField label="Tag with active workspace" bind:value={cfg.workspace_tag} />
|
||||
<TagsField label="Snooze options" bind:value={cfg.snooze_options} />
|
||||
<NumberField label="Archive after (days)" bind:value={cfg.archive_after_days} min={0} max={3650} />
|
||||
</Group>
|
||||
|
||||
<Group title="Classifier model" hint="The local model breadpad uses to guess note vs. reminder vs. task — no network needed.">
|
||||
<FileField label="ONNX model" bind:value={cfg.model_path} placeholder="~/.local/share/breadpad/model/classifier.onnx" extensions={["onnx"]} />
|
||||
<FileField label="Tokenizer" bind:value={cfg.tokenizer_path} placeholder="~/.local/share/breadpad/model/tokenizer.json" extensions={["json"]} />
|
||||
</Group>
|
||||
|
||||
<Group title="Ollama (LLM classifier)" hint="Optional: ask a locally-running LLM (via Ollama) to classify notes instead of, or alongside, the ONNX model above.">
|
||||
<SwitchField label="Use Ollama" bind:value={cfg.ollama_enabled} />
|
||||
<TextField label="Endpoint" bind:value={cfg.ollama_endpoint} placeholder="http://localhost:11434" />
|
||||
<TextField label="Model" bind:value={cfg.ollama_model} placeholder="e.g. fastflowlm" />
|
||||
<Row label="Confidence threshold">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={confidenceText}
|
||||
oninput={onConfidenceInput}
|
||||
onblur={onConfidenceBlur}
|
||||
/>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
<Group title="Reminders">
|
||||
<TextField label="Default morning time" bind:value={cfg.reminders_default_morning} placeholder="7:00" />
|
||||
<NumberField label="Missed grace (minutes)" bind:value={cfg.reminders_missed_grace_minutes} min={0} max={1440} step={5} />
|
||||
</Group>
|
||||
|
||||
<Group title="Calendar (CalDAV)">
|
||||
<SwitchField label="Sync to calendar" bind:value={cfg.calendar_enabled} />
|
||||
<TextField label="CalDAV URL" bind:value={cfg.calendar_url} placeholder="https://host/remote.php/dav/calendars/..." />
|
||||
<TextField label="Username" bind:value={cfg.calendar_username} />
|
||||
<PasswordField label="Password" bind:value={cfg.calendar_password} />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
input[type="number"] {
|
||||
width: 10ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
200
frontend/src/lib/views/Breadpaper.svelte
Normal file
200
frontend/src/lib/views/Breadpaper.svelte
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke, convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
|
||||
interface LibraryEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
let currentPath = $state<string | null>(null);
|
||||
let status = $state("");
|
||||
let libraryDir = $state("");
|
||||
let library = $state<LibraryEntry[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
|
||||
async function refreshCurrent() {
|
||||
currentPath = await invoke<string | null>("get_current_wallpaper");
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
libraryDir = await invoke<string>("wallpaper_library_dir_display");
|
||||
await refreshCurrent();
|
||||
});
|
||||
|
||||
async function apply(path: string) {
|
||||
status = "Setting…";
|
||||
try {
|
||||
await invoke("set_wallpaper", { path });
|
||||
await refreshCurrent();
|
||||
status = "Wallpaper set";
|
||||
} catch (e) {
|
||||
status = `${e}`;
|
||||
} finally {
|
||||
setTimeout(() => (status = ""), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseImage() {
|
||||
const path = await open({
|
||||
title: "Choose a wallpaper",
|
||||
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] }],
|
||||
});
|
||||
if (typeof path === "string") {
|
||||
await apply(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function browseLibrary() {
|
||||
scanning = true;
|
||||
library = await invoke<LibraryEntry[]>("list_wallpaper_library");
|
||||
scanning = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Wallpaper">
|
||||
<Group
|
||||
title="Current wallpaper"
|
||||
hint="Sets the desktop wallpaper, generates a matching pywal palette, and reloads the shared bread-theme stylesheet — the wallpaper drives the whole desktop's accent colors."
|
||||
>
|
||||
<div class="preview-card">
|
||||
{#if currentPath}
|
||||
<img class="preview" src={convertFileSrc(currentPath)} alt="Current wallpaper" />
|
||||
<span class="path">{currentPath.split("/").pop()}</span>
|
||||
{:else}
|
||||
<div class="preview placeholder">No wallpaper set</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="choose" onclick={chooseImage}>Choose image…</button>
|
||||
<span class="status">{status}</span>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Library" wide>
|
||||
{#if library === null}
|
||||
<button class="browse" disabled={scanning} onclick={browseLibrary}>
|
||||
{scanning ? "Scanning…" : `Browse ${libraryDir}`}
|
||||
</button>
|
||||
{:else if library.length === 0}
|
||||
<div class="empty">Nothing under {libraryDir} — use Choose image… above instead.</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each library as item (item.path)}
|
||||
<button class="thumb" onclick={() => apply(item.path)} title={item.path}>
|
||||
<img src={convertFileSrc(item.path)} alt={item.name} loading="lazy" />
|
||||
<span class="name">{item.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.preview-card {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-md, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
}
|
||||
|
||||
.preview.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.path {
|
||||
display: block;
|
||||
margin-top: var(--space-xs, 4px);
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
justify-content: center;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.choose,
|
||||
.browse {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browse {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.browse:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-tertiary, 4px);
|
||||
}
|
||||
|
||||
.thumb .name {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
105
frontend/src/lib/views/Breadsearch.svelte
Normal file
105
frontend/src/lib/views/Breadsearch.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import ServiceControl from "$lib/components/ServiceControl.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import NumberField from "$lib/components/NumberField.svelte";
|
||||
import PathListField from "$lib/components/PathListField.svelte";
|
||||
import TagsField from "$lib/components/TagsField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
const BACKEND_LABELS: Record<string, string> = {
|
||||
cpu: "CPU",
|
||||
npu: "NPU (AMD Ryzen AI)",
|
||||
rocm: "AMD GPU (ROCm)",
|
||||
cuda: "NVIDIA GPU (CUDA)",
|
||||
openvino: "Intel GPU (OpenVINO)",
|
||||
};
|
||||
|
||||
interface BreadsearchConfig {
|
||||
power_enabled: boolean;
|
||||
run_on_battery: boolean;
|
||||
backend: string;
|
||||
index_roots: string[];
|
||||
index_excludes: string[];
|
||||
index_extensions: string[];
|
||||
max_file_mb: number;
|
||||
search_limit: number;
|
||||
snippet_len: number;
|
||||
}
|
||||
|
||||
let cfg = $state<BreadsearchConfig | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await invoke<BreadsearchConfig>("get_breadsearch_config");
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await invoke("save_breadsearch_config", { cfg });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="File Search">
|
||||
<ServiceControl unit="breadmill.service" hasConfig />
|
||||
|
||||
{#if cfg}
|
||||
<Group
|
||||
title="Power"
|
||||
hint="breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, or just pause it on battery — it resumes automatically on AC power."
|
||||
>
|
||||
<SwitchField label="Enabled" bind:value={cfg.power_enabled} />
|
||||
<SwitchField label="Index while on battery" bind:value={cfg.run_on_battery} />
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Model"
|
||||
hint="What hardware does the indexing. Check journalctl --user -u breadmill after restarting to confirm it registered."
|
||||
>
|
||||
<Row label="Search acceleration">
|
||||
<select bind:value={cfg.backend}>
|
||||
{#each Object.entries(BACKEND_LABELS) as [code, label] (code)}
|
||||
<option value={code}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
<NumberField label="Max file size (MB)" bind:value={cfg.max_file_mb} min={0.1} max={500} step={0.5} />
|
||||
</Group>
|
||||
|
||||
<Group title="Search">
|
||||
<NumberField label="Result limit" bind:value={cfg.search_limit} min={1} max={100} />
|
||||
<NumberField label="Snippet length" bind:value={cfg.snippet_len} min={20} max={2000} step={20} />
|
||||
</Group>
|
||||
|
||||
<Group title="Index" wide>
|
||||
<PathListField label="Folders to index" bind:value={cfg.index_roots} />
|
||||
<PathListField label="Excluded folders" bind:value={cfg.index_excludes} hint="Skipped even if inside an indexed folder above." />
|
||||
<TagsField label="File extensions" bind:value={cfg.index_extensions} />
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
121
frontend/src/lib/views/DateTime.svelte
Normal file
121
frontend/src/lib/views/DateTime.svelte
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
|
||||
interface DateTimeInfo {
|
||||
current_time: string;
|
||||
timezones: string[];
|
||||
current_tz: string;
|
||||
ntp_enabled: boolean;
|
||||
ntp_synced: boolean;
|
||||
}
|
||||
|
||||
let info = $state<DateTimeInfo | null>(null);
|
||||
let tzStatus = $state("");
|
||||
let ntpEnabled = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
info = await invoke<DateTimeInfo>("get_datetime_info");
|
||||
ntpEnabled = info.ntp_enabled;
|
||||
});
|
||||
|
||||
async function applyTimezone(tz: string) {
|
||||
tzStatus = "Applying…";
|
||||
try {
|
||||
await invoke("set_timezone", { tz });
|
||||
tzStatus = "Applied";
|
||||
} catch (e) {
|
||||
tzStatus = `${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNtp(enabled: boolean) {
|
||||
ntpEnabled = enabled;
|
||||
await invoke("set_ntp_enabled", { enabled });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Date & Time">
|
||||
{#if info}
|
||||
<Group title="Current time">
|
||||
<InfoRow label="Right now" value={info.current_time} />
|
||||
</Group>
|
||||
|
||||
<Group title="Timezone">
|
||||
{#if info.timezones.length === 0}
|
||||
<Hint text="Couldn't list timezones (is timedatectl available?)." />
|
||||
{:else}
|
||||
<Row label="Timezone">
|
||||
<select value={info.current_tz} onchange={(e) => applyTimezone(e.currentTarget.value)}>
|
||||
{#each info.timezones as tz (tz)}
|
||||
<option value={tz}>{tz}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
{#if tzStatus}
|
||||
<span class="status">{tzStatus}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Network time" hint={info.ntp_synced ? "Synchronized." : "Not synchronized yet (needs network)."}>
|
||||
<Row label="Synchronize automatically">
|
||||
<button
|
||||
class="switch"
|
||||
class:on={ntpEnabled}
|
||||
role="switch"
|
||||
aria-checked={ntpEnabled}
|
||||
aria-label="Synchronize automatically"
|
||||
onclick={() => toggleNtp(!ntpEnabled)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</Row>
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
152
frontend/src/lib/views/Display.svelte
Normal file
152
frontend/src/lib/views/Display.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
|
||||
interface LiveMonitor {
|
||||
name: string;
|
||||
mode: string;
|
||||
}
|
||||
interface MonitorRule {
|
||||
output: string;
|
||||
mode: string;
|
||||
position: string;
|
||||
scale: string;
|
||||
}
|
||||
|
||||
let liveMonitors = $state<LiveMonitor[] | null>(null);
|
||||
let rules = $state<MonitorRule[] | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
liveMonitors = await invoke<LiveMonitor[]>("get_live_monitors");
|
||||
rules = await invoke<MonitorRule[]>("get_monitor_rules");
|
||||
});
|
||||
|
||||
function addRule() {
|
||||
rules = [...(rules ?? []), { output: "", mode: "preferred", position: "auto", scale: "auto" }];
|
||||
}
|
||||
|
||||
function removeRule(i: number) {
|
||||
rules = rules!.filter((_, idx) => idx !== i);
|
||||
if (rules.length === 0) {
|
||||
rules = [{ output: "", mode: "preferred", position: "auto", scale: "auto" }];
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await invoke("save_monitor_rules", { rules });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Display">
|
||||
<Group title="Connected monitors">
|
||||
{#if liveMonitors && liveMonitors.length > 0}
|
||||
{#each liveMonitors as m (m.name)}
|
||||
<InfoRow label={m.name} value={m.mode} />
|
||||
{/each}
|
||||
{:else}
|
||||
<Hint text="No monitors detected (is Hyprland running?)" />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
<Group title="Advanced">
|
||||
<button class="secondary" onclick={() => invoke("open_hyprland_conf")}>Open hyprland.lua in editor</button>
|
||||
<button class="secondary" onclick={() => invoke("open_keybinds_viewer")}>View keybinds (breadhelp)</button>
|
||||
</Group>
|
||||
|
||||
{#if rules}
|
||||
<Group
|
||||
title="Layout"
|
||||
hint="One row per monitor rule. Leave Output blank to match any monitor (the default — works on any hardware). Applies on next login/reload."
|
||||
wide
|
||||
>
|
||||
{#each rules as rule, i (i)}
|
||||
<div class="rule-row">
|
||||
<label>Output <input type="text" bind:value={rule.output} placeholder="any (blank = all)" class="w-output" /></label>
|
||||
<label>Mode <input type="text" bind:value={rule.mode} placeholder="preferred / 1920x1080@60" class="w-mode" /></label>
|
||||
<label>Position <input type="text" bind:value={rule.position} placeholder="auto / 0x0" class="w-position" /></label>
|
||||
<label>Scale <input type="text" bind:value={rule.scale} placeholder="auto / 1" class="w-scale" /></label>
|
||||
<button class="remove" onclick={() => removeRule(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="add" onclick={addRule}>Add monitor rule</button>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.rule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rule-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.w-output {
|
||||
width: 12ch;
|
||||
}
|
||||
.w-mode {
|
||||
width: 18ch;
|
||||
}
|
||||
.w-position {
|
||||
width: 10ch;
|
||||
}
|
||||
.w-scale {
|
||||
width: 8ch;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
align-self: flex-start;
|
||||
margin-bottom: var(--space-xs, 4px);
|
||||
}
|
||||
</style>
|
||||
205
frontend/src/lib/views/Firewall.svelte
Normal file
205
frontend/src/lib/views/Firewall.svelte
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import Shield from "@lucide/svelte/icons/shield";
|
||||
import ShieldAlert from "@lucide/svelte/icons/shield-alert";
|
||||
|
||||
interface FirewallRule {
|
||||
number: string;
|
||||
text: string;
|
||||
}
|
||||
interface FirewallStatus {
|
||||
active: boolean;
|
||||
rules: FirewallRule[];
|
||||
}
|
||||
|
||||
let status = $state<FirewallStatus | "unloaded" | { error: string }>("unloaded");
|
||||
let enabledSensitive = $state(false);
|
||||
let newRule = $state("");
|
||||
let log = $state<string[]>([]);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
status = await invoke<FirewallStatus>("get_firewall_status");
|
||||
enabledSensitive = true;
|
||||
} catch (e) {
|
||||
status = { error: `${e}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(enabled: boolean) {
|
||||
if (typeof status !== "object" || !("active" in status)) return;
|
||||
log = [];
|
||||
try {
|
||||
await invoke("set_firewall_enabled", { enabled });
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function removeRule(number: string) {
|
||||
log = [];
|
||||
try {
|
||||
await invoke("remove_firewall_rule", { number });
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function addRule() {
|
||||
if (!newRule.trim()) return;
|
||||
log = [];
|
||||
try {
|
||||
await invoke("add_firewall_rule", { rule: newRule.trim() });
|
||||
newRule = "";
|
||||
} catch (e) {
|
||||
log = [...log, `${e}`];
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Firewall">
|
||||
<Group
|
||||
title="Firewall"
|
||||
hint="Reading and changing firewall state needs your password (polkit) — ufw requires root even just to check status."
|
||||
>
|
||||
<Row label="Firewall enabled">
|
||||
<button
|
||||
class="switch"
|
||||
class:on={typeof status === "object" && "active" in status && status.active}
|
||||
disabled={!enabledSensitive}
|
||||
role="switch"
|
||||
aria-checked={typeof status === "object" && "active" in status && status.active}
|
||||
aria-label="Firewall enabled"
|
||||
onclick={() => toggleEnabled(!(typeof status === "object" && "active" in status && status.active))}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
<Group title="Add rule" hint={'e.g. "8080/tcp", "22/tcp", or a service name like "OpenSSH".'}>
|
||||
<div class="add-row">
|
||||
<input type="text" bind:value={newRule} placeholder="port/proto or service name" />
|
||||
<button class="allow" onclick={addRule}>Allow</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Rules" wide>
|
||||
<div class="list">
|
||||
{#if status === "unloaded"}
|
||||
<EmptyState icon={Shield} title="Status not loaded" hint="Click Refresh below to check the firewall's current state." />
|
||||
{:else if "error" in status}
|
||||
<EmptyState icon={ShieldAlert} title="Couldn't read firewall status" hint={status.error} />
|
||||
{:else if status.rules.length === 0}
|
||||
<EmptyState icon={Shield} title="No rules" hint="Only the default policy (deny incoming, allow outgoing) applies." />
|
||||
{:else}
|
||||
{#each status.rules as rule (rule.number)}
|
||||
<div class="rule-row">
|
||||
<span class="text">{rule.text}</span>
|
||||
<button class="remove" onclick={() => removeRule(rule.number)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="refresh" onclick={refresh}>Refresh status</button>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.switch:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
}
|
||||
|
||||
.list {
|
||||
min-height: 60px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rule-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.refresh {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.add-row input {
|
||||
flex: 1;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.allow {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
</style>
|
||||
140
frontend/src/lib/views/Firmware.svelte
Normal file
140
frontend/src/lib/views/Firmware.svelte
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamingCommand } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
|
||||
interface FwDevice {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
let devices = $state<FwDevice[] | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
devices = await invoke<FwDevice[]>("get_updatable_firmware");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function appendLine(line: string) {
|
||||
log = [...log, line];
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamingCommand("fwupdmgr", ["refresh"], appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamingCommand("fwupdmgr", ["update", "-y"], appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Firmware">
|
||||
<Group
|
||||
title="Updatable devices"
|
||||
hint="Firmware updates for hardware that supports them (UEFI, some peripherals). fwupd-refresh.timer already keeps update metadata current in the background."
|
||||
wide
|
||||
>
|
||||
<div class="list">
|
||||
{#if devices === null}
|
||||
<Hint text="Loading…" />
|
||||
{:else if devices.length === 0}
|
||||
<EmptyState icon={RefreshCw} title="No updatable firmware devices found" hint="Not every device supports firmware updates through fwupd." />
|
||||
{:else}
|
||||
{#each devices as dev (dev.name)}
|
||||
<div class="row">
|
||||
<span class="name" title={dev.name}>{dev.name}</span>
|
||||
<span class="version" title={dev.version}>{dev.version}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={checkForUpdates}>Check for updates</button>
|
||||
<button disabled={busy} class="primary" onclick={updateAll}>Update all</button>
|
||||
<button disabled={busy} onclick={refresh}>Refresh list</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
653
frontend/src/lib/views/Keybinds.svelte
Normal file
653
frontend/src/lib/views/Keybinds.svelte
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import TextField from "$lib/components/TextField.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import SaveButton from "$lib/components/SaveButton.svelte";
|
||||
import Switch from "$lib/components/Switch.svelte";
|
||||
|
||||
// Mirrors src-tauri/src/commands/keybinds.rs's `Bind` — `action`/`key`/
|
||||
// `mods` are the only fields every bind has; everything else (`command`,
|
||||
// `direction`, `workspace`, `options`, breadhelp's `label`/`category`/
|
||||
// `demo_cmd`, ...) round-trips through serde's `#[serde(flatten)]` as
|
||||
// arbitrary sibling keys on the same JSON object.
|
||||
interface Bind {
|
||||
action: string;
|
||||
key?: string;
|
||||
mods?: string[];
|
||||
[extra: string]: unknown;
|
||||
}
|
||||
interface BindsFile {
|
||||
active_layout: string;
|
||||
default_mods: string[];
|
||||
globals: Bind[];
|
||||
common: Bind[];
|
||||
layouts: Record<string, Bind[]>;
|
||||
bindings: Bind[];
|
||||
}
|
||||
type SchemaKind = "flat" | "multi_layout" | "unknown";
|
||||
interface BindsPayload {
|
||||
kind: SchemaKind;
|
||||
file: BindsFile;
|
||||
}
|
||||
|
||||
// Every action actually seen in real binds.json files (BOS's shipped
|
||||
// flat schema and this app's own dev MultiLayout config) — an `action`
|
||||
// dropdown beats free-typing a dispatcher name from memory. "Custom…"
|
||||
// keeps anything not in this list reachable without blocking on it.
|
||||
const KNOWN_ACTIONS = [
|
||||
"exec",
|
||||
"close",
|
||||
"fullscreen",
|
||||
"float",
|
||||
"pseudo",
|
||||
"resize",
|
||||
"focus",
|
||||
"focus_last",
|
||||
"move",
|
||||
"move_dir",
|
||||
"resize_dir",
|
||||
"drag",
|
||||
"layout",
|
||||
"exit",
|
||||
] as const;
|
||||
|
||||
// The editable, per-row UI state a bind gets flattened into on load and
|
||||
// reconstructed back into a `Bind` on save — same trade-off the GTK
|
||||
// version made with individual `Entry` widgets per field.
|
||||
interface EditRow {
|
||||
action: string;
|
||||
key: string;
|
||||
mods: string;
|
||||
// Whether `mods` has been explicitly set (present in the loaded JSON,
|
||||
// or touched by the user since). `None`/absent means "fall back to
|
||||
// default_mods"; `Some([])` — an explicitly *empty* mods list — means
|
||||
// "use no modifiers at all, even though default_mods exists" (real
|
||||
// BOS binds rely on this for e.g. bare media keys). Collapsing both
|
||||
// cases to "omit when empty" would silently turn an explicit
|
||||
// no-mods override back into "use the default" on next save.
|
||||
modsTouched: boolean;
|
||||
// The single source of truth for everything beyond action/key/mods
|
||||
// (command, direction, workspace, x/y, layout, options, breadhelp's
|
||||
// label/category/demo_cmd, ...). Dedicated per-action fields below
|
||||
// read/write specific keys of this object directly, so any sibling
|
||||
// key they don't know about (label/category/demo_cmd, or an action
|
||||
// shape this editor has no dedicated fields for) survives untouched.
|
||||
extraValue: Record<string, unknown>;
|
||||
// Raw-JSON view of `extraValue`, kept in sync both directions —
|
||||
// only actually shown when `advancedOpen` is true, or for an action
|
||||
// not in `KNOWN_ACTIONS` (nothing dedicated to show instead).
|
||||
extraText: string;
|
||||
extraError: boolean;
|
||||
advancedOpen: boolean;
|
||||
}
|
||||
|
||||
function syncExtraText(row: EditRow) {
|
||||
row.extraText = Object.keys(row.extraValue).length ? JSON.stringify(row.extraValue) : "";
|
||||
row.extraError = false;
|
||||
}
|
||||
|
||||
/** Mutates one top-level key of `row.extraValue` (deleting it when `value` is empty/undefined) and re-syncs the raw-JSON view. */
|
||||
function setExtra(row: EditRow, key: string, value: unknown) {
|
||||
if (value === undefined || value === "") {
|
||||
delete row.extraValue[key];
|
||||
} else {
|
||||
row.extraValue[key] = value;
|
||||
}
|
||||
syncExtraText(row);
|
||||
}
|
||||
|
||||
function getOption(row: EditRow, key: "locked" | "repeating"): boolean {
|
||||
const options = row.extraValue.options as Record<string, unknown> | undefined;
|
||||
return Boolean(options?.[key]);
|
||||
}
|
||||
function setOption(row: EditRow, key: "locked" | "repeating", value: boolean) {
|
||||
const options = { ...((row.extraValue.options as Record<string, unknown>) ?? {}) };
|
||||
if (value) options[key] = true;
|
||||
else delete options[key];
|
||||
if (Object.keys(options).length) row.extraValue.options = options;
|
||||
else delete row.extraValue.options;
|
||||
syncExtraText(row);
|
||||
}
|
||||
|
||||
// Hyprland workspace refs mix bare integers ("1") and relative tokens
|
||||
// ("e+1", "e-1") in the same field — keep whichever shape the user typed
|
||||
// instead of forcing everything through one type.
|
||||
function parseWorkspaceValue(s: string): string | number | undefined {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return /^-?\d+$/.test(trimmed) ? Number(trimmed) : trimmed;
|
||||
}
|
||||
|
||||
function modsToText(mods?: string[]): string {
|
||||
return (mods ?? []).join(", ");
|
||||
}
|
||||
function textToMods(s: string): string[] {
|
||||
return s
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
function bindToRow(b: Bind): EditRow {
|
||||
const { action, key, mods, ...extra } = b;
|
||||
const row: EditRow = {
|
||||
action: action ?? "",
|
||||
key: (key as string | undefined) ?? "",
|
||||
mods: modsToText(mods),
|
||||
modsTouched: mods !== undefined,
|
||||
extraValue: extra,
|
||||
extraText: "",
|
||||
extraError: false,
|
||||
advancedOpen: false,
|
||||
};
|
||||
syncExtraText(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function rowToBind(r: EditRow): Bind {
|
||||
// `drag` binds are only meaningful as a mouse bind — there's no
|
||||
// dedicated field for `options.mouse` (nothing to configure, it's
|
||||
// always true), so pin it here rather than exposing a checkbox
|
||||
// whose only correct state is "on".
|
||||
const extra = r.action === "drag" ? { ...r.extraValue, options: { ...(r.extraValue.options as object), mouse: true } } : r.extraValue;
|
||||
return {
|
||||
action: r.action,
|
||||
...(r.key.trim() ? { key: r.key.trim() } : {}),
|
||||
...(r.modsTouched ? { mods: textToMods(r.mods) } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function onExtraInput(row: EditRow, text: string) {
|
||||
row.extraText = text;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
row.extraValue = {};
|
||||
row.extraError = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
row.extraValue = parsed;
|
||||
row.extraError = false;
|
||||
} else {
|
||||
row.extraError = true;
|
||||
}
|
||||
} catch {
|
||||
row.extraError = true;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyRow(): EditRow {
|
||||
return {
|
||||
action: "exec",
|
||||
key: "",
|
||||
mods: "",
|
||||
modsTouched: false,
|
||||
extraValue: {},
|
||||
extraText: "",
|
||||
extraError: false,
|
||||
advancedOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
let loaded = $state(false);
|
||||
let kind = $state<SchemaKind>("unknown");
|
||||
let activeLayout = $state("");
|
||||
let defaultMods = $state("");
|
||||
let globalsRows = $state<EditRow[]>([]);
|
||||
let commonRows = $state<EditRow[]>([]);
|
||||
let bindingsRows = $state<EditRow[]>([]);
|
||||
let layoutRows = $state<Record<string, EditRow[]>>({});
|
||||
let layoutOrder = $state<string[]>([]);
|
||||
let newLayoutName = $state("");
|
||||
let loadError = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const p = await invoke<BindsPayload>("get_keybinds");
|
||||
kind = p.kind;
|
||||
// Rust's `#[serde(skip_serializing_if = ...)]` on every one of
|
||||
// these fields means an empty one is OMITTED from the JSON
|
||||
// entirely, not sent as `[]`/`{}`/`""` — every field here needs
|
||||
// a `??` fallback, not just the ones that are "usually" empty.
|
||||
activeLayout = p.file.active_layout ?? "";
|
||||
defaultMods = modsToText(p.file.default_mods ?? []);
|
||||
globalsRows = (p.file.globals ?? []).map(bindToRow);
|
||||
commonRows = (p.file.common ?? []).map(bindToRow);
|
||||
bindingsRows = (p.file.bindings ?? []).map(bindToRow);
|
||||
const layouts: Record<string, EditRow[]> = {};
|
||||
for (const [name, binds] of Object.entries(p.file.layouts ?? {})) layouts[name] = binds.map(bindToRow);
|
||||
layoutRows = layouts;
|
||||
layoutOrder = Object.keys(layouts);
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
loadError = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
function addLayout() {
|
||||
const name = newLayoutName.trim();
|
||||
if (!name || layoutOrder.includes(name)) return;
|
||||
layoutRows[name] = [];
|
||||
layoutOrder = [...layoutOrder, name];
|
||||
newLayoutName = "";
|
||||
}
|
||||
function removeLayout(name: string) {
|
||||
if (!confirm(`Remove layout "${name}"? Deletes every bind defined under this layout. This can't be undone here.`)) return;
|
||||
delete layoutRows[name];
|
||||
layoutOrder = layoutOrder.filter((n) => n !== name);
|
||||
if (activeLayout === name) activeLayout = layoutOrder[0] ?? "";
|
||||
}
|
||||
|
||||
// A row whose "extra" column currently holds text that doesn't parse as
|
||||
// JSON keeps its *last successfully parsed* value in `extraValue` (see
|
||||
// `onExtraInput`) rather than losing it on every keystroke while the
|
||||
// user is mid-edit — but that means saving while such a row is still
|
||||
// showing invalid/incomplete text would silently write that stale (or,
|
||||
// for a brand new row, empty) value instead of what's actually on
|
||||
// screen. Block save entirely until every row's extra JSON is valid, so
|
||||
// an in-progress edit can never be the thing that quietly drops a
|
||||
// bind's `command`/`direction`/`workspace`/etc.
|
||||
function hasInvalidExtraJson(): boolean {
|
||||
const all = [...globalsRows, ...commonRows, ...bindingsRows, ...Object.values(layoutRows).flat()];
|
||||
return all.some((r) => r.extraError);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (hasInvalidExtraJson()) {
|
||||
throw new Error("Fix the invalid JSON in the highlighted field(s) before saving.");
|
||||
}
|
||||
const file: BindsFile = {
|
||||
active_layout: activeLayout,
|
||||
default_mods: textToMods(defaultMods),
|
||||
globals: globalsRows.map(rowToBind),
|
||||
common: commonRows.map(rowToBind),
|
||||
layouts: Object.fromEntries(layoutOrder.map((name) => [name, (layoutRows[name] ?? []).map(rowToBind)])),
|
||||
bindings: bindingsRows.map(rowToBind),
|
||||
};
|
||||
await invoke("save_keybinds", { file, kind });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet extraFields(row: EditRow)}
|
||||
{#if row.action === "exec"}
|
||||
<input
|
||||
class="extra-wide"
|
||||
type="text"
|
||||
placeholder="Command to run"
|
||||
value={(row.extraValue.command as string) ?? ""}
|
||||
oninput={(e) => setExtra(row, "command", e.currentTarget.value)}
|
||||
/>
|
||||
<label class="mini-switch">
|
||||
<Switch
|
||||
ariaLabel="Repeat while held"
|
||||
bind:value={() => getOption(row, "repeating"), (v) => setOption(row, "repeating", v)}
|
||||
/>
|
||||
Repeat while held
|
||||
</label>
|
||||
<label class="mini-switch">
|
||||
<Switch
|
||||
ariaLabel="Ignore keyboard lock"
|
||||
bind:value={() => getOption(row, "locked"), (v) => setOption(row, "locked", v)}
|
||||
/>
|
||||
Works when locked
|
||||
</label>
|
||||
{:else if row.action === "focus" || row.action === "move"}
|
||||
<input
|
||||
class="extra-narrow"
|
||||
type="text"
|
||||
placeholder="Workspace (e.g. 3, e+1)"
|
||||
value={String(row.extraValue.workspace ?? "")}
|
||||
oninput={(e) => setExtra(row, "workspace", parseWorkspaceValue(e.currentTarget.value))}
|
||||
/>
|
||||
{:else if row.action === "move_dir"}
|
||||
<select
|
||||
class="extra-narrow"
|
||||
value={(row.extraValue.direction as string) ?? ""}
|
||||
onchange={(e) => setExtra(row, "direction", e.currentTarget.value || undefined)}
|
||||
>
|
||||
<option value="" disabled>Direction…</option>
|
||||
<option value="left">Left</option>
|
||||
<option value="right">Right</option>
|
||||
<option value="up">Up</option>
|
||||
<option value="down">Down</option>
|
||||
</select>
|
||||
{:else if row.action === "resize_dir"}
|
||||
<input
|
||||
class="extra-tiny"
|
||||
type="number"
|
||||
placeholder="x"
|
||||
value={row.extraValue.x !== undefined ? String(row.extraValue.x) : ""}
|
||||
oninput={(e) => setExtra(row, "x", e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value))}
|
||||
/>
|
||||
<input
|
||||
class="extra-tiny"
|
||||
type="number"
|
||||
placeholder="y"
|
||||
value={row.extraValue.y !== undefined ? String(row.extraValue.y) : ""}
|
||||
oninput={(e) => setExtra(row, "y", e.currentTarget.value === "" ? undefined : Number(e.currentTarget.value))}
|
||||
/>
|
||||
<label class="mini-switch">
|
||||
<Switch
|
||||
ariaLabel="Repeat while held"
|
||||
bind:value={() => getOption(row, "repeating"), (v) => setOption(row, "repeating", v)}
|
||||
/>
|
||||
Repeat while held
|
||||
</label>
|
||||
{:else if row.action === "layout"}
|
||||
<input
|
||||
class="extra-narrow"
|
||||
type="text"
|
||||
placeholder="Layout command (e.g. togglesplit)"
|
||||
value={(row.extraValue.layout as string) ?? ""}
|
||||
oninput={(e) => setExtra(row, "layout", e.currentTarget.value)}
|
||||
/>
|
||||
{:else if row.action === "drag"}
|
||||
<span class="extra-note">Mouse-drag bind — nothing else to set.</span>
|
||||
{:else if row.action === "close" || row.action === "fullscreen" || row.action === "float" || row.action === "pseudo" || row.action === "resize" || row.action === "focus_last" || row.action === "exit"}
|
||||
<span class="extra-note">No extra options for this action.</span>
|
||||
{:else}
|
||||
<input
|
||||
class="extra-wide"
|
||||
class:error={row.extraError}
|
||||
type="text"
|
||||
placeholder={'{"command": "..."}'}
|
||||
value={row.extraText}
|
||||
oninput={(e) => onExtraInput(row, e.currentTarget.value)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"}
|
||||
<button type="button" class="advanced-toggle" onclick={() => (row.advancedOpen = !row.advancedOpen)}>
|
||||
{row.advancedOpen ? "Hide raw JSON" : "Advanced"}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet section(rows: EditRow[], onAdd: () => void, onRemove: (i: number) => void)}
|
||||
<div class="rows">
|
||||
{#each rows as row, i (i)}
|
||||
<div class="bind-card">
|
||||
<div class="bind-top">
|
||||
<input
|
||||
class="mods"
|
||||
type="text"
|
||||
placeholder="SUPER, SHIFT"
|
||||
bind:value={row.mods}
|
||||
oninput={() => (row.modsTouched = true)}
|
||||
/>
|
||||
<input class="key" type="text" placeholder="key" bind:value={row.key} />
|
||||
<select
|
||||
class="action-select"
|
||||
value={KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) ? row.action : "__custom__"}
|
||||
onchange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
row.action = v === "__custom__" ? "" : v;
|
||||
}}
|
||||
>
|
||||
{#each KNOWN_ACTIONS as a (a)}
|
||||
<option value={a}>{a}</option>
|
||||
{/each}
|
||||
<option value="__custom__">Custom…</option>
|
||||
</select>
|
||||
{#if !KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number])}
|
||||
<input class="action-custom" type="text" placeholder="action name" bind:value={row.action} />
|
||||
{/if}
|
||||
<button type="button" class="remove" onclick={() => onRemove(i)}>Remove</button>
|
||||
</div>
|
||||
<div class="bind-bottom">
|
||||
{@render extraFields(row)}
|
||||
</div>
|
||||
{#if row.advancedOpen && KNOWN_ACTIONS.includes(row.action as (typeof KNOWN_ACTIONS)[number]) && row.action !== "drag"}
|
||||
<input
|
||||
class="extra-wide"
|
||||
class:error={row.extraError}
|
||||
type="text"
|
||||
placeholder={'{"command": "..."}'}
|
||||
value={row.extraText}
|
||||
oninput={(e) => onExtraInput(row, e.currentTarget.value)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="add" onclick={onAdd}>Add bind</button>
|
||||
{/snippet}
|
||||
|
||||
<ViewScaffold title="Keybinds">
|
||||
{#if loadError}
|
||||
<Group title="Failed to load" wide>
|
||||
<Hint text={loadError} />
|
||||
</Group>
|
||||
{/if}
|
||||
{#if loaded}
|
||||
{#if kind === "unknown"}
|
||||
<Group title="Keybinds" wide>
|
||||
<Hint
|
||||
text={`binds.json's schema wasn't recognized (expected a "bindings" key, or one of "globals"/"common"/"layouts"). Nothing below is editable, and Save is disabled, so the file on disk isn't at risk of being silently overwritten with the wrong shape. Fix or remove the file by hand, then reopen this panel.`}
|
||||
/>
|
||||
</Group>
|
||||
{:else if kind === "flat"}
|
||||
<Group
|
||||
title="Defaults"
|
||||
hint={`Mods/Key pick the shortcut; Action picks what it does. Choosing a known action (exec, move_dir, focus, ...) shows the fields it actually needs — e.g. a Command box for exec — instead of raw JSON. "Advanced" reveals the underlying JSON per bind for anything not covered (or breadhelp's label/category metadata). This machine's binds.json uses BOS's flat schema (no keyboard-layout switching), so that's all there is. Applies on next login/reload.`}
|
||||
>
|
||||
<TextField label="Default mods" bind:value={defaultMods} placeholder="SUPER" />
|
||||
</Group>
|
||||
|
||||
<Group title="Bindings" wide>
|
||||
{@render section(
|
||||
bindingsRows,
|
||||
() => bindingsRows.push(emptyRow()),
|
||||
(i) => bindingsRows.splice(i, 1)
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{:else}
|
||||
<Group
|
||||
title="Layout"
|
||||
hint={`Mods/Key pick the shortcut; Action picks what it does. Choosing a known action (exec, move_dir, focus, ...) shows the fields it actually needs — e.g. a Command box for exec — instead of raw JSON. "Advanced" reveals the underlying JSON per bind for anything not covered. Applies on next login/reload.`}
|
||||
>
|
||||
{#if layoutOrder.length > 0}
|
||||
<SelectField label="Active layout" bind:value={activeLayout} options={layoutOrder} />
|
||||
{/if}
|
||||
<TextField label="Default mods" bind:value={defaultMods} placeholder="SUPER" />
|
||||
</Group>
|
||||
|
||||
<Group title="Media & function keys (globals)" wide>
|
||||
{@render section(
|
||||
globalsRows,
|
||||
() => globalsRows.push(emptyRow()),
|
||||
(i) => globalsRows.splice(i, 1)
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group title="Common (every layout)" wide>
|
||||
{@render section(
|
||||
commonRows,
|
||||
() => commonRows.push(emptyRow()),
|
||||
(i) => commonRows.splice(i, 1)
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{#each layoutOrder as name (name)}
|
||||
<Group title={`Layout: ${name}`} wide>
|
||||
<button type="button" class="remove-layout" onclick={() => removeLayout(name)}>Remove layout</button>
|
||||
{@render section(
|
||||
layoutRows[name] ?? [],
|
||||
() => (layoutRows[name] ?? (layoutRows[name] = [])).push(emptyRow()),
|
||||
(i) => layoutRows[name]?.splice(i, 1)
|
||||
)}
|
||||
</Group>
|
||||
{/each}
|
||||
|
||||
<Group title="Add layout">
|
||||
<div class="add-layout-row">
|
||||
<input type="text" bind:value={newLayoutName} placeholder="new layout name" />
|
||||
<button type="button" onclick={addLayout}>Add layout</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<SaveButton onSave={save} />
|
||||
{/if}
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.bind-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.bind-top,
|
||||
.bind-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bind-card input,
|
||||
.bind-card select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.bind-card input:focus,
|
||||
.bind-card select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.bind-card input.error {
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
.mods {
|
||||
width: 14ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
width: 9ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-select {
|
||||
width: 11ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-custom {
|
||||
width: 11ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.extra-wide {
|
||||
flex: 1;
|
||||
min-width: 16ch;
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.extra-narrow {
|
||||
width: 20ch;
|
||||
}
|
||||
|
||||
.extra-tiny {
|
||||
width: 7ch;
|
||||
}
|
||||
|
||||
.extra-note {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.mini-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--on-surface);
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.advanced-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.remove-layout {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
margin-bottom: var(--space-sm, 8px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.add-layout-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.add-layout-row input {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.add-layout-row button {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
</style>
|
||||
276
frontend/src/lib/views/Network.svelte
Normal file
276
frontend/src/lib/views/Network.svelte
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import Wifi from "@lucide/svelte/icons/wifi";
|
||||
import WifiOff from "@lucide/svelte/icons/wifi-off";
|
||||
import Lock from "@lucide/svelte/icons/lock";
|
||||
|
||||
interface WifiNetwork {
|
||||
ssid: string;
|
||||
signal: number;
|
||||
secured: boolean;
|
||||
active: boolean;
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
let radioEnabled = $state(false);
|
||||
let ethernet = $state<string | null>(null);
|
||||
let networks = $state<WifiNetwork[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let status = $state("");
|
||||
let pendingSsid = $state<string | null>(null);
|
||||
let password = $state("");
|
||||
|
||||
// The backend reports the first `nmcli`-visible device of TYPE=ethernet,
|
||||
// which on a machine running containers/VMs can be a virtual interface
|
||||
// (docker/podman veth pairs, libvirt bridges, VPN tuns) rather than a
|
||||
// real NIC — nmcli doesn't distinguish these from physical ethernet.
|
||||
// Names like "vethYCF3ZA: unmanaged" are meaningless to a non-technical
|
||||
// user, so hide the card rather than show raw interface jargon.
|
||||
const VIRTUAL_IFACE_PREFIXES = ["veth", "docker", "br-", "virbr", "vnet", "tun", "tap", "vmnet", "podman"];
|
||||
const ETHERNET_STATE_LABELS: Record<string, string> = {
|
||||
connected: "Connected",
|
||||
connecting: "Connecting…",
|
||||
disconnected: "Not connected",
|
||||
disconnecting: "Disconnecting…",
|
||||
unavailable: "Not connected",
|
||||
unmanaged: "Not connected",
|
||||
};
|
||||
let ethernetLabel = $derived.by(() => {
|
||||
if (!ethernet) return null;
|
||||
const [iface, rawState] = ethernet.split(": ");
|
||||
if (!iface || VIRTUAL_IFACE_PREFIXES.some((prefix) => iface.startsWith(prefix))) return null;
|
||||
return ETHERNET_STATE_LABELS[rawState ?? ""] ?? "Not connected";
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const info = await invoke<{ radio_enabled: boolean; ethernet: string | null }>("get_network_info");
|
||||
radioEnabled = info.radio_enabled;
|
||||
ethernet = info.ethernet;
|
||||
});
|
||||
|
||||
async function toggleRadio(enabled: boolean) {
|
||||
radioEnabled = enabled;
|
||||
await invoke("set_wifi_radio", { enabled });
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanning = true;
|
||||
status = "Scanning…";
|
||||
networks = await invoke<WifiNetwork[]>("scan_wifi");
|
||||
status = `Found ${networks.length} network(s)`;
|
||||
scanning = false;
|
||||
}
|
||||
|
||||
async function connect(ssid: string, secured: boolean, known: boolean) {
|
||||
if (secured && !known) {
|
||||
pendingSsid = ssid;
|
||||
return;
|
||||
}
|
||||
status = `Connecting to ${ssid}…`;
|
||||
try {
|
||||
await invoke("connect_wifi", { ssid, password: null });
|
||||
status = `Connected to ${ssid}`;
|
||||
} catch (e) {
|
||||
status = `Failed to connect to ${ssid}: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectWithPassword() {
|
||||
const ssid = pendingSsid!;
|
||||
const pw = password;
|
||||
pendingSsid = null;
|
||||
password = "";
|
||||
status = `Connecting to ${ssid}…`;
|
||||
try {
|
||||
await invoke("connect_wifi", { ssid, password: pw });
|
||||
status = `Connected to ${ssid}`;
|
||||
} catch {
|
||||
status = `Failed to connect to ${ssid}: wrong password?`;
|
||||
}
|
||||
}
|
||||
|
||||
function signalLabel(signal: number): string {
|
||||
return `${Math.min(100, Math.max(0, signal))}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Network">
|
||||
<Group title="Wi-Fi">
|
||||
<Row label="Wi-Fi radio">
|
||||
<button class="switch" class:on={radioEnabled} role="switch" aria-checked={radioEnabled} aria-label="Wi-Fi radio" onclick={() => toggleRadio(!radioEnabled)}>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
{#if ethernetLabel}
|
||||
<Group title="Ethernet">
|
||||
<InfoRow label="Status" value={ethernetLabel} />
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group title="Available networks" wide>
|
||||
<div class="list">
|
||||
{#if networks === null}
|
||||
<div class="empty">
|
||||
<Wifi size={40} />
|
||||
<span>Not scanned yet</span>
|
||||
<span class="hint">Press Scan to see nearby networks.</span>
|
||||
</div>
|
||||
{:else if networks.length === 0}
|
||||
<div class="empty">
|
||||
<WifiOff size={40} />
|
||||
<span>No networks found</span>
|
||||
<span class="hint">Try Scan again, or check Wi-Fi radio is on.</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each networks as net (net.ssid)}
|
||||
<div class="net-row">
|
||||
<span class="ssid" class:active={net.active}>{net.ssid}{net.active ? " (connected)" : ""}</span>
|
||||
{#if net.secured}<Lock size={14} />{/if}
|
||||
<span class="signal">{signalLabel(net.signal)}</span>
|
||||
{#if !net.active}
|
||||
<button class="connect" onclick={() => connect(net.ssid, net.secured, net.known)}>Connect</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if pendingSsid}
|
||||
<div class="pw-row">
|
||||
<input type="password" bind:value={password} placeholder="Password" />
|
||||
<button class="connect" onclick={connectWithPassword}>Connect</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if status}
|
||||
<span class="status">{status}</span>
|
||||
{/if}
|
||||
|
||||
<button class="scan" disabled={scanning} onclick={scan}>{scanning ? "Scanning…" : "Scan"}</button>
|
||||
</Group>
|
||||
|
||||
<Group title="Advanced" hint="VPN, 802.1x, and static IP configuration aren't covered here.">
|
||||
<button class="secondary" onclick={() => invoke("open_connection_editor")}>Open connection editor</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background-color: var(--overlay);
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.switch.on {
|
||||
background-color: var(--accent);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--on-surface);
|
||||
}
|
||||
|
||||
.list {
|
||||
min-height: 100px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--space-xl, 20px) 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.net-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
}
|
||||
|
||||
.ssid {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ssid.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signal {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
.pw-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.pw-row input {
|
||||
flex: 1;
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
|
||||
button.connect,
|
||||
button.scan,
|
||||
button.secondary {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
align-self: flex-start;
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
}
|
||||
|
||||
button.scan {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
152
frontend/src/lib/views/Packages.svelte
Normal file
152
frontend/src/lib/views/Packages.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { runStreamingCommand } from "$lib/streaming";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import LogView from "$lib/components/LogView.svelte";
|
||||
import Package from "@lucide/svelte/icons/package";
|
||||
|
||||
interface InstalledPackage {
|
||||
name: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
let packages = $state<InstalledPackage[] | null>(null);
|
||||
let log = $state<string[]>([]);
|
||||
let busy = $state(false);
|
||||
|
||||
async function refresh() {
|
||||
packages = await invoke<InstalledPackage[]>("get_installed_packages");
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function appendLine(line: string) {
|
||||
log = [...log, line];
|
||||
}
|
||||
|
||||
async function updatePackage(name: string) {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamingCommand("bakery", ["update", name], appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function listInstalled() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamingCommand("bakery", ["list"], appendLine);
|
||||
busy = false;
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamingCommand("bakery", ["update", "--all"], appendLine);
|
||||
busy = false;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function updateSystem() {
|
||||
log = [];
|
||||
busy = true;
|
||||
await runStreamingCommand("pkexec", ["pacman", "-Syu", "--noconfirm"], appendLine);
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Packages">
|
||||
<Group title="Bread ecosystem (bakery)" wide>
|
||||
<div class="list">
|
||||
{#if packages === null}
|
||||
<Hint text="Loading…" />
|
||||
{:else if packages.length === 0}
|
||||
<EmptyState icon={Package} title="No bakery packages found" hint="~/.local/state/bakery/installed.json is missing or empty." />
|
||||
{:else}
|
||||
{#each packages as pkg (pkg.name)}
|
||||
<div class="row">
|
||||
<span class="name" title={pkg.name}>{pkg.name}</span>
|
||||
<span class="version" title={pkg.version}>{pkg.version}</span>
|
||||
<button disabled={busy} onclick={() => updatePackage(pkg.name)}>Update</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button disabled={busy} onclick={listInstalled}>List installed</button>
|
||||
<button disabled={busy} onclick={updateAll}>Update all</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="System packages (pacman)"
|
||||
hint="Base system, kernel, bos-settings, and republished AUR packages — the other half of what bos-update covers. Needs your password (polkit) since pacman requires root."
|
||||
>
|
||||
<button disabled={busy} onclick={updateSystem}>Update system (pacman -Syu)</button>
|
||||
</Group>
|
||||
|
||||
<LogView lines={log} />
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version {
|
||||
opacity: 0.75;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
flex-shrink: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--surface);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
105
frontend/src/lib/views/Power.svelte
Normal file
105
frontend/src/lib/views/Power.svelte
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import InfoRow from "$lib/components/InfoRow.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
|
||||
interface PowerInfo {
|
||||
battery: [string, string][];
|
||||
power_source: string;
|
||||
brightness_pct: number | null;
|
||||
charge_start: number | null;
|
||||
charge_end: number | null;
|
||||
tlp_profile: string | null;
|
||||
}
|
||||
|
||||
let info = $state<PowerInfo | null>(null);
|
||||
let brightness = $state(0);
|
||||
let chargeStart = $state(0);
|
||||
let chargeEnd = $state(100);
|
||||
|
||||
onMount(async () => {
|
||||
info = await invoke<PowerInfo>("get_power_info");
|
||||
brightness = info.brightness_pct ?? 0;
|
||||
chargeStart = info.charge_start ?? 0;
|
||||
chargeEnd = info.charge_end ?? 100;
|
||||
});
|
||||
|
||||
async function setBrightness(percent: number) {
|
||||
brightness = percent;
|
||||
await invoke("set_brightness", { percent });
|
||||
}
|
||||
|
||||
async function setChargeThreshold(which: "start" | "end", percent: number) {
|
||||
if (which === "start") chargeStart = percent;
|
||||
else chargeEnd = percent;
|
||||
await invoke("set_charge_threshold", { which, percent });
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Power">
|
||||
{#if info}
|
||||
<Group title="Battery">
|
||||
{#each info.battery as [label, value] (label)}
|
||||
<InfoRow {label} {value} />
|
||||
{/each}
|
||||
<InfoRow label="Power source" value={info.power_source} />
|
||||
</Group>
|
||||
|
||||
<Group title="Brightness">
|
||||
{#if info.brightness_pct !== null}
|
||||
<Row label="Screen brightness">
|
||||
<input type="range" min="1" max="100" bind:value={brightness} onchange={() => setBrightness(brightness)} />
|
||||
<span class="pct">{brightness}%</span>
|
||||
</Row>
|
||||
{:else}
|
||||
<Hint text="No controllable backlight found." />
|
||||
{/if}
|
||||
</Group>
|
||||
|
||||
{#if info.charge_start !== null && info.charge_end !== null}
|
||||
<Group
|
||||
title="Charge limits"
|
||||
hint="Some laptops let you cap charging below 100% to slow battery wear on a machine that's mostly plugged in."
|
||||
>
|
||||
<Row label="Start charging below (%)">
|
||||
<input type="number" min="0" max="100" bind:value={chargeStart} onchange={() => setChargeThreshold("start", chargeStart)} />
|
||||
</Row>
|
||||
<Row label="Stop charging at (%)">
|
||||
<input type="number" min="1" max="100" bind:value={chargeEnd} onchange={() => setChargeThreshold("end", chargeEnd)} />
|
||||
</Row>
|
||||
</Group>
|
||||
{/if}
|
||||
|
||||
<Group
|
||||
title="TLP"
|
||||
hint="TLP automatically applies a power-saving profile on battery and a performance profile on AC — there's no manual switch by design."
|
||||
>
|
||||
<InfoRow label="Current profile" value={info.tlp_profile ?? "unknown"} />
|
||||
</Group>
|
||||
{/if}
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
input[type="range"] {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 8ch;
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.pct {
|
||||
margin-left: var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
176
frontend/src/lib/views/Snapshots.svelte
Normal file
176
frontend/src/lib/views/Snapshots.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import EmptyState from "$lib/components/EmptyState.svelte";
|
||||
import History from "@lucide/svelte/icons/history";
|
||||
import CircleAlert from "@lucide/svelte/icons/circle-alert";
|
||||
|
||||
interface SnapshotRow {
|
||||
number: string;
|
||||
date: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
let snapshots = $state<SnapshotRow[] | "loading">("loading");
|
||||
let errorHint = $state<string | null>(null);
|
||||
let selected = $state<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
errorHint = null;
|
||||
try {
|
||||
snapshots = await invoke<SnapshotRow[]>("get_snapshots");
|
||||
} catch (e) {
|
||||
const msg = `${e}`.toLowerCase();
|
||||
if (msg.includes("no permission")) {
|
||||
errorHint = "This user isn't allowed to run snapper. Check ALLOW_USERS in /etc/snapper/configs/root — it should list your username.";
|
||||
} else if (msg.includes("unknown config") || msg.includes("no such file")) {
|
||||
errorHint = "No snapper config exists for root yet, so nothing is being snapshotted. This should be set up automatically at install.";
|
||||
} else {
|
||||
errorHint = `${e}`;
|
||||
}
|
||||
snapshots = [];
|
||||
}
|
||||
selected = null;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
function bootIntoSelected() {
|
||||
if (!selected) return;
|
||||
if (
|
||||
confirm(
|
||||
`Boot into snapshot #${selected}? Snapshots on BOS are booted directly from the GRUB menu (under "BOS snapshots"), not rolled back in place. Reboot now and pick this snapshot there.`,
|
||||
)
|
||||
) {
|
||||
invoke("reboot_system");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!selected) return;
|
||||
if (!confirm(`Delete snapshot #${selected}? This cannot be undone.`)) return;
|
||||
try {
|
||||
await invoke("delete_snapshot", { number: selected });
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(`${e}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Snapshots">
|
||||
<Group
|
||||
title="System snapshots"
|
||||
hint="Created automatically by snap-pac on each pacman transaction. Boot into one from the GRUB menu to recover; delete old ones here."
|
||||
wide
|
||||
>
|
||||
<div class="list">
|
||||
{#if snapshots === "loading"}
|
||||
<Hint text="Loading…" />
|
||||
{:else if errorHint}
|
||||
<EmptyState icon={CircleAlert} title="Couldn't read snapshots" hint={errorHint} />
|
||||
{:else if snapshots.length === 0}
|
||||
<EmptyState icon={History} title="No snapshots yet" hint="Snapshots are created automatically on every pacman transaction." />
|
||||
{:else}
|
||||
{#each snapshots as snap (snap.number)}
|
||||
<button
|
||||
class="row"
|
||||
class:selected={selected === snap.number}
|
||||
onclick={() => (selected = snap.number)}
|
||||
>
|
||||
<span class="number">{snap.number}</span>
|
||||
<span class="date">{snap.date}</span>
|
||||
<span class="desc" title={snap.description}>{snap.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button onclick={refresh}>Refresh</button>
|
||||
<button disabled={!selected} onclick={bootIntoSelected}>Boot into selected…</button>
|
||||
<button class="destructive" disabled={!selected} onclick={deleteSelected}>Delete selected</button>
|
||||
</div>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md, 12px);
|
||||
background-color: var(--surface);
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background-color: color-mix(in srgb, var(--surface), var(--on-surface) 8%);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.number {
|
||||
width: 4ch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date {
|
||||
width: 22ch;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
margin-top: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.destructive {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
131
frontend/src/lib/views/Sound.svelte
Normal file
131
frontend/src/lib/views/Sound.svelte
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
import Hint from "$lib/components/Hint.svelte";
|
||||
import SwitchField from "$lib/components/SwitchField.svelte";
|
||||
|
||||
interface SoundDevice {
|
||||
name: string;
|
||||
description: string;
|
||||
mute: boolean;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
interface DeviceSection {
|
||||
kind: "sinks" | "sources";
|
||||
title: string;
|
||||
devices: SoundDevice[];
|
||||
selected: number;
|
||||
}
|
||||
|
||||
let output = $state<DeviceSection | null>(null);
|
||||
let input = $state<DeviceSection | null>(null);
|
||||
|
||||
async function loadSection(kind: "sinks" | "sources", title: string): Promise<DeviceSection> {
|
||||
const section = await invoke<{ devices: SoundDevice[]; default_name: string | null }>("get_sound_section", { kind });
|
||||
const selected = Math.max(0, section.devices.findIndex((d) => d.name === section.default_name));
|
||||
return { kind, title, devices: section.devices, selected };
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
output = await loadSection("sinks", "Output");
|
||||
input = await loadSection("sources", "Input");
|
||||
});
|
||||
|
||||
async function selectDevice(section: DeviceSection, index: number) {
|
||||
section.selected = index;
|
||||
await invoke("set_default_sound_device", { kind: section.kind, name: section.devices[index].name });
|
||||
}
|
||||
|
||||
async function setVolume(section: DeviceSection, percent: number) {
|
||||
const device = section.devices[section.selected];
|
||||
device.percent = percent;
|
||||
await invoke("set_sound_volume", { kind: section.kind, name: device.name, percent });
|
||||
}
|
||||
|
||||
async function setMute(section: DeviceSection, mute: boolean) {
|
||||
const device = section.devices[section.selected];
|
||||
device.mute = mute;
|
||||
await invoke("set_sound_mute", { kind: section.kind, name: device.name, mute });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet deviceSection(section: DeviceSection | null)}
|
||||
{#if section}
|
||||
<Group title={section.title}>
|
||||
{#if section.devices.length === 0}
|
||||
<Hint text="No devices found." />
|
||||
{:else}
|
||||
<Row label="Device">
|
||||
<select
|
||||
value={section.selected}
|
||||
onchange={(e) => selectDevice(section, Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each section.devices as d, i (d.name)}
|
||||
<option value={i}>{d.description}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Row>
|
||||
<Row label="Volume">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="150"
|
||||
value={section.devices[section.selected].percent}
|
||||
oninput={(e) => setVolume(section, Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="pct">{section.devices[section.selected].percent}%</span>
|
||||
</Row>
|
||||
<SwitchField
|
||||
label="Mute"
|
||||
bind:value={
|
||||
() => section.devices[section.selected].mute,
|
||||
(v) => setMute(section, v)
|
||||
}
|
||||
/>
|
||||
{/if}
|
||||
</Group>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<ViewScaffold title="Sound">
|
||||
{@render deviceSection(output)}
|
||||
{@render deviceSection(input)}
|
||||
|
||||
<Group title="Advanced" hint="Per-app volume, port selection, and profile switching aren't covered here.">
|
||||
<button class="secondary" onclick={() => invoke("open_mixer")}>Open advanced mixer (pavucontrol)</button>
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
select {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.pct {
|
||||
margin-left: var(--space-sm, 8px);
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
196
frontend/src/lib/views/Users.svelte
Normal file
196
frontend/src/lib/views/Users.svelte
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import ViewScaffold from "$lib/components/ViewScaffold.svelte";
|
||||
import Group from "$lib/components/Group.svelte";
|
||||
import Row from "$lib/components/Row.svelte";
|
||||
|
||||
interface Account {
|
||||
username: string;
|
||||
full_name: string;
|
||||
}
|
||||
|
||||
let accounts = $state<Account[] | null>(null);
|
||||
let currentUser = $state("");
|
||||
let openPasswordFor = $state<string | null>(null);
|
||||
let passwordInput = $state("");
|
||||
let rowStatus = $state<Record<string, string>>({});
|
||||
|
||||
let newUsername = $state("");
|
||||
let newFullName = $state("");
|
||||
let newPassword = $state("");
|
||||
let addStatus = $state("");
|
||||
|
||||
async function refresh() {
|
||||
const info = await invoke<{ accounts: Account[]; current_user: string }>("get_users_info");
|
||||
accounts = info.accounts;
|
||||
currentUser = info.current_user;
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
|
||||
async function applyPassword(username: string) {
|
||||
if (!passwordInput) return;
|
||||
rowStatus = { ...rowStatus, [username]: "Applying…" };
|
||||
try {
|
||||
await invoke("change_password", { username, password: passwordInput });
|
||||
rowStatus = { ...rowStatus, [username]: "Password changed" };
|
||||
openPasswordFor = null;
|
||||
passwordInput = "";
|
||||
} catch {
|
||||
rowStatus = { ...rowStatus, [username]: "Failed to change password" };
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAccount(username: string) {
|
||||
if (!confirm(`Remove user ${username}? Deletes the account and its home directory. This cannot be undone.`)) return;
|
||||
await invoke("remove_user", { username });
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
if (!newUsername.trim() || !newPassword) {
|
||||
addStatus = "Username and password are required.";
|
||||
return;
|
||||
}
|
||||
addStatus = "Adding…";
|
||||
try {
|
||||
await invoke("add_user", { username: newUsername.trim(), fullName: newFullName.trim(), password: newPassword });
|
||||
addStatus = "User added.";
|
||||
newUsername = "";
|
||||
newFullName = "";
|
||||
newPassword = "";
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
addStatus = `${e}`;
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewScaffold title="Users">
|
||||
<Group title="Accounts" hint="Real login accounts on this machine (system/service accounts aren't shown). Your own account can't be removed from here." wide>
|
||||
<div class="list">
|
||||
{#if accounts}
|
||||
{#each accounts as acc (acc.username)}
|
||||
<div class="card">
|
||||
<div class="top">
|
||||
<span class="name">{acc.username}{acc.full_name ? ` (${acc.full_name})` : ""}</span>
|
||||
<button class="action" onclick={() => (openPasswordFor = openPasswordFor === acc.username ? null : acc.username)}>
|
||||
Change password
|
||||
</button>
|
||||
<button class="remove" disabled={acc.username === currentUser} onclick={() => removeAccount(acc.username)}>Remove</button>
|
||||
</div>
|
||||
{#if openPasswordFor === acc.username}
|
||||
<div class="pw-row">
|
||||
<input type="password" bind:value={passwordInput} placeholder="New password" />
|
||||
<button class="action" onclick={() => applyPassword(acc.username)}>Apply</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if rowStatus[acc.username]}
|
||||
<span class="status">{rowStatus[acc.username]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Add user">
|
||||
<Row label="Username">
|
||||
<input type="text" bind:value={newUsername} placeholder="username" />
|
||||
</Row>
|
||||
<Row label="Full name">
|
||||
<input type="text" bind:value={newFullName} placeholder="Full name (optional)" />
|
||||
</Row>
|
||||
<Row label="Password">
|
||||
<input type="password" bind:value={newPassword} placeholder="password" />
|
||||
</Row>
|
||||
<button class="add" onclick={addUser}>Add user</button>
|
||||
{#if addStatus}
|
||||
<span class="status">{addStatus}</span>
|
||||
{/if}
|
||||
</Group>
|
||||
</ViewScaffold>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs, 4px);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-md, 12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pw-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-secondary, 6px);
|
||||
padding: var(--space-xs, 4px) var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.pw-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--radius-primary, 8px);
|
||||
padding: var(--space-xs, 4px) var(--space-md, 12px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action {
|
||||
background-color: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.remove {
|
||||
background-color: var(--red);
|
||||
color: var(--on-red);
|
||||
}
|
||||
|
||||
.remove:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.add {
|
||||
background-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
align-self: flex-start;
|
||||
margin-top: var(--space-sm, 8px);
|
||||
padding: var(--space-sm, 8px) var(--space-lg, 16px);
|
||||
}
|
||||
|
||||
.status {
|
||||
opacity: 0.6;
|
||||
font-size: var(--font-size-secondary, 12px);
|
||||
}
|
||||
</style>
|
||||
56
frontend/src/lib/views/registry.ts
Normal file
56
frontend/src/lib/views/registry.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Maps a sidebar page id to its view component. Pages not yet migrated
|
||||
// fall back to Placeholder (see +page.svelte) — this map only lists pages
|
||||
// that actually have a real Tauri-backed view.
|
||||
|
||||
import type { Component } from "svelte";
|
||||
import About from "./About.svelte";
|
||||
import Breadclip from "./Breadclip.svelte";
|
||||
import Bread from "./Bread.svelte";
|
||||
import Breadbar from "./Breadbar.svelte";
|
||||
import Breadbox from "./Breadbox.svelte";
|
||||
import Breadpad from "./Breadpad.svelte";
|
||||
import Breadpaper from "./Breadpaper.svelte";
|
||||
import Breadsearch from "./Breadsearch.svelte";
|
||||
import Breadcrumbs from "./Breadcrumbs.svelte";
|
||||
import Appearance from "./Appearance.svelte";
|
||||
import Autostart from "./Autostart.svelte";
|
||||
import Display from "./Display.svelte";
|
||||
import Keybinds from "./Keybinds.svelte";
|
||||
import Sound from "./Sound.svelte";
|
||||
import DateTime from "./DateTime.svelte";
|
||||
import Power from "./Power.svelte";
|
||||
import Network from "./Network.svelte";
|
||||
import Bluetooth from "./Bluetooth.svelte";
|
||||
import Firewall from "./Firewall.svelte";
|
||||
import Users from "./Users.svelte";
|
||||
import Packages from "./Packages.svelte";
|
||||
import Aur from "./Aur.svelte";
|
||||
import Firmware from "./Firmware.svelte";
|
||||
import Snapshots from "./Snapshots.svelte";
|
||||
|
||||
export const VIEWS: Record<string, Component> = {
|
||||
about: About,
|
||||
breadclip: Breadclip,
|
||||
bread: Bread,
|
||||
breadbar: Breadbar,
|
||||
breadbox: Breadbox,
|
||||
breadpad: Breadpad,
|
||||
breadpaper: Breadpaper,
|
||||
breadsearch: Breadsearch,
|
||||
breadcrumbs: Breadcrumbs,
|
||||
appearance: Appearance,
|
||||
autostart: Autostart,
|
||||
hyprland: Display,
|
||||
keybinds: Keybinds,
|
||||
sound: Sound,
|
||||
datetime: DateTime,
|
||||
power: Power,
|
||||
network: Network,
|
||||
bluetooth: Bluetooth,
|
||||
firewall: Firewall,
|
||||
users: Users,
|
||||
packages: Packages,
|
||||
aur: Aur,
|
||||
firmware: Firmware,
|
||||
snapshots: Snapshots,
|
||||
};
|
||||
5
frontend/src/routes/+layout.ts
Normal file
5
frontend/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
61
frontend/src/routes/+page.svelte
Normal file
61
frontend/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Sidebar from "$lib/components/Sidebar.svelte";
|
||||
import Placeholder from "$lib/components/Placeholder.svelte";
|
||||
import { DEFAULT_PAGE } from "$lib/sidebar";
|
||||
import { initTheme } from "$lib/theme";
|
||||
import { VIEWS } from "$lib/views/registry";
|
||||
|
||||
let activePage = $state(DEFAULT_PAGE);
|
||||
let ActiveView = $derived(VIEWS[activePage]);
|
||||
|
||||
onMount(() => {
|
||||
initTheme();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<Sidebar bind:activePage />
|
||||
<main class="content">
|
||||
{#if ActiveView}
|
||||
<ActiveView />
|
||||
{:else}
|
||||
<Placeholder page={activePage} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(html, body) {
|
||||
margin: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
color-scheme: dark;
|
||||
background-color: var(--bg, #0c0c0c);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-family, sans-serif);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
}
|
||||
|
||||
:global(#svelte) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: var(--bg, #0c0c0c);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
</style>
|
||||
BIN
frontend/static/favicon.png
Normal file
BIN
frontend/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
18
frontend/svelte.config.js
Normal file
18
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
32
frontend/vite.config.js
Normal file
32
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [sveltekit()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue