feat: add accordion overlay controls and icon API

This commit is contained in:
2026-03-06 16:28:03 +01:00
parent 027027b203
commit 35358c2d4e
16 changed files with 359 additions and 46 deletions

3
public/icons/bell.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2a5 5 0 0 0-5 5v2.17A7 7 0 0 1 5 14.12V17h14v-2.88a7 7 0 0 1-2-4.95V7a5 5 0 0 0-5-5zm-2 17a2 2 0 1 0 4 0h2a4 4 0 1 1-8 0h2z"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

3
public/icons/fire.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M13.5 2S15 5 12 8c-1.2 1.2-1.5 2.5-1.5 3.5S11 14 11 14s-4-1-4-5c0-2 1-4 1-4S4 7.5 4 12c0 5 3.5 10 8 10s8-3.5 8-8c0-5-3-7-6.5-12zM12 22a5 5 0 0 1-5-5c0-2.2 1.1-3.9 2.8-5.5.2 2.3 1.9 3.8 4.2 4.5-.4-1.6 0-3 .9-4.2 1.4 1.5 2.1 3.2 2.1 5.2a5 5 0 0 1-5 5z"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

3
public/icons/flag.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 2h2v2h9l-1 3 1 3H8v12H6V2z"/>
</svg>

After

Width:  |  Height:  |  Size: 132 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 2h10v2h-1v3.59a4 4 0 0 1-1.17 2.83L13.41 12l1.42 1.41A4 4 0 0 1 16 16.24V20h1v2H7v-2h1v-3.76a4 4 0 0 1 1.17-2.83L10.59 12 9.17 10.59A4 4 0 0 1 8 7.76V4H7V2zm3 2v3.76c0 .53.21 1.04.59 1.41L12 10.59l1.41-1.42c.38-.37.59-.88.59-1.41V4h-4zm2 9.41-1.41 1.42c-.38.37-.59.88-.59 1.41V20h4v-3.76c0-.53-.21-1.04-.59-1.41L12 13.41z"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

3
public/icons/star.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="m12 2 2.8 5.67 6.2.9-4.5 4.38 1.06 6.18L12 16.2l-5.56 2.93 1.06-6.18L3 8.57l6.2-.9L12 2z"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@@ -1,5 +1,6 @@
use crate::countdown::events::AppEvent;
use crate::countdown::service::CountdownService;
use std::collections::HashMap;
use tokio::sync::broadcast;
#[derive(Clone, Debug)]
@@ -28,11 +29,30 @@ impl ClockAnchor {
}
}
}
#[derive(Debug, Clone)]
pub struct OverlayConfig {
pub icon: String,
pub text_color: String,
pub bg_color: String,
}
impl Default for OverlayConfig {
fn default() -> Self {
Self {
icon: String::new(),
text_color: "white".to_string(),
bg_color: "transparent".to_string(),
}
}
}
#[derive(Debug)]
pub struct AppState {
pub clock_anchor: ClockAnchor,
pub countdown_service: CountdownService,
pub event_bus: broadcast::Sender<AppEvent>,
pub overlay_configs: tokio::sync::Mutex<HashMap<u64, OverlayConfig>>,
}
impl AppState {
@@ -42,6 +62,7 @@ impl AppState {
clock_anchor: ClockAnchor::new(),
countdown_service: CountdownService::new(),
event_bus,
overlay_configs: tokio::sync::Mutex::new(HashMap::new()),
}
}
}

View File

@@ -169,6 +169,30 @@ pub async fn countdown_snapshot(
})
}
#[command]
pub async fn set_overlay_config(
state: State<'_, Arc<AppState>>,
id: u64,
icon: String,
text_color: String,
bg_color: String,
) -> Result<(), String> {
state
.overlay_configs
.lock()
.await
.insert(
id,
crate::app_state::OverlayConfig {
icon,
text_color,
bg_color,
},
);
let _ = state.event_bus.send(AppEvent::ConfigChanged(id));
Ok(())
}
pub(crate) fn spawn_ticker(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(100));

View File

@@ -13,4 +13,5 @@ pub struct CountdownTickPayload {
pub enum AppEvent {
Tick(CountdownTickPayload),
Changed(Vec<CountdownSnapshotDto>),
ConfigChanged(u64),
}

View File

@@ -9,7 +9,7 @@ use std::sync::Arc;
use crate::app_state::AppState;
use crate::countdown::commands::{
countdown_create, countdown_delete, countdown_list, countdown_pause, countdown_reset,
countdown_resume, countdown_snapshot, countdown_start, spawn_ticker,
countdown_resume, countdown_snapshot, countdown_start, set_overlay_config, spawn_ticker,
};
use tauri::Manager;
@@ -29,6 +29,7 @@ pub fn run() {
countdown_resume,
countdown_pause,
countdown_snapshot,
set_overlay_config,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -1,15 +1,19 @@
use crate::app_state::AppState;
use axum::http::Method;
use axum::{routing::get, Router};
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
mod routes;
pub async fn start(state: Arc<AppState>) {
let app = Router::new()
.route("/api/icons", get(routes::list_icons))
.route("/overlay/countdown", get(routes::overlay_countdown))
.route("/sse/countdown/{id}", get(routes::sse_countdown))
.nest_service("/static", ServeDir::new("public"))
.layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET]))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:7420")

View File

@@ -1,6 +1,7 @@
use crate::app_state::AppState;
use crate::countdown::commands::build_snapshot_dtos;
use crate::countdown::events::AppEvent;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::response::sse::KeepAlive;
use axum::response::{
@@ -27,6 +28,13 @@ pub async fn overlay_countdown(
.and_then(|snaps| snaps.into_iter().find(|s| s.id == q.id))
.map(|s| format_remaining(s.duration as u64))
.unwrap_or_else(|| "??:??:??.???".to_string());
let config = state
.overlay_configs
.lock()
.await
.get(&q.id)
.cloned()
.unwrap_or_default();
let mut env = minijinja::Environment::new();
env.add_template(
@@ -37,7 +45,13 @@ pub async fn overlay_countdown(
let html = env
.get_template("page")
.unwrap()
.render(minijinja::context! { id => q.id, remaining => remaining })
.render(minijinja::context! {
id => q.id,
remaining => remaining,
icon => config.icon,
text_color => config.text_color,
bg_color => config.bg_color,
})
.unwrap();
Html(html)
}
@@ -56,6 +70,9 @@ pub async fn sse_countdown(
.event("tick")
.data(format_remaining(s.duration as u64)))
}),
Ok(AppEvent::ConfigChanged(cid)) if cid == id => {
Some(Ok(Event::default().event("reload").data("")))
}
_ => None,
});
Sse::new(stream).keep_alive(
@@ -72,3 +89,19 @@ fn format_remaining(ms: u64) -> String {
let millis = ms % 1_000;
format!("{:02}:{:02}:{:02}.{:03}", h, m, s, millis)
}
pub async fn list_icons() -> Json<Vec<String>> {
let mut names = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir("public/icons").await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if matches!(path.extension().and_then(|e| e.to_str()), Some("svg" | "png")) {
if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
names.push(filename.to_string());
}
}
}
}
names.sort();
Json(names)
}

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="/static/overlay.css" rel="stylesheet">
<meta charset="UTF-8">
<link rel="stylesheet" href="/static/overlay.css">
<style>
body { background-color: {{ bg_color }}; }
#timer { color: {{ text_color }}; }
#icon { width: 3rem; height: 3rem; vertical-align: middle; margin-right: 0.5rem; }
</style>
</head>
<body>
<div id="timer">{{ remaining }}</div>
<script>
<div id="display">
{% if icon %}<img id="icon" src="/static/icons/{{ icon }}" />{% endif %}
<span id="timer">{{ remaining }}</span>
</div>
<script>
const es = new EventSource('/sse/countdown/{{ id }}');
es.addEventListener('tick', e => {
document.getElementById('timer').textContent = e.data;
});
es.addEventListener('tick', e => { document.getElementById('timer').textContent = e.data; });
es.addEventListener('reload', () => location.reload());
es.onerror = () => setTimeout(() => location.reload(), 3000);
</script>
</script>
</body>
</html>

View File

@@ -19,4 +19,56 @@
mark[data-state="Running"] { color: var(--pico-color-jade-500, #29a16a); }
mark[data-state="Paused"] { color: var(--pico-color-amber-500, #929200); }
mark[data-state="Finished"] { color: var(--pico-color-red-500, #b00020); }
mark[data-state="Idle"] { color: var(--pico-color-zinc-400, #666666); }
mark[data-state="Idle"] { color: var(--pico-color-zinc-400, #666666); }
details {
margin-bottom: 0.75rem;
}
summary mark {
margin-left: 0.5rem;
}
.icon-picker {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.icon-btn {
padding: 0.3rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn img {
width: 1.5rem;
height: 1.5rem;
}
.icon-btn.selected {
outline: 2px solid var(--pico-primary);
}
.overlay-colors {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.source-url {
display: flex;
gap: 0.5rem;
align-items: center;
}
.source-url input {
flex: 1;
font-size: 0.8rem;
}

View File

@@ -2,6 +2,7 @@ import {invokeCommand} from "../../../shared/tauri/invoke";
import type {
CountdownCommand,
CountdownPayload,
OverlayConfigPayload,
CountdownSnapshot,
CountdownSnapshotDto
} from "../model/countdown.types";
@@ -50,3 +51,18 @@ export async function resetCountdown(id: number): Promise<void> {
export async function fetchCountdownSnapshot(id: number): Promise<CountdownSnapshot> {
return mapSnapshotDtoToSnapshot(await invokeCommand<CountdownSnapshotDto>("countdown_snapshot", {id}));
}
export async function setOverlayConfig(
id: number,
icon: string,
textColor: string,
bgColor: string,
): Promise<void> {
const payload: OverlayConfigPayload = {
id,
icon,
text_color: textColor,
bg_color: bgColor,
};
await invokeCommand<void>("set_overlay_config", payload);
}

View File

@@ -1,12 +1,25 @@
<script lang="ts">
import {onDestroy, onMount} from "svelte";
import {countdownStore} from "../state/countdown.store";
import {setOverlayConfig} from "../api/countdown.client";
import {type Duration, formatDuration} from "../../../shared/time/duration";
const OVERLAY_SERVER_ORIGIN = "http://localhost:7420";
type OverlaySettings = {
icon: string;
textColor: string;
bgColor: string;
bgTransparent: boolean;
};
let label = "";
let hours = 0;
let minutes = 0;
let seconds = 0;
let icons: string[] = [];
let cleanup: (() => void) | null = null;
let overlaySettings: Record<number, OverlaySettings> = {};
function handleCreate() {
const duration: Duration = {hours, minutes, seconds, millis: 0};
@@ -17,11 +30,54 @@
seconds = 0;
}
let cleanup: (() => void) | null = null;
function getSettings(id: number): OverlaySettings {
if (!overlaySettings[id]) {
overlaySettings = {
...overlaySettings,
[id]: {
icon: "",
textColor: "#ffffff",
bgColor: "#000000",
bgTransparent: true,
},
};
}
return overlaySettings[id];
}
function updateSettings(id: number, patch: Partial<OverlaySettings>) {
overlaySettings = {
...overlaySettings,
[id]: {
...getSettings(id),
...patch,
},
};
}
function pushConfig(id: number) {
const s = getSettings(id);
void setOverlayConfig(id, s.icon, s.textColor, s.bgTransparent ? "transparent" : s.bgColor);
}
async function copyUrl(id: number) {
await navigator.clipboard.writeText(`${OVERLAY_SERVER_ORIGIN}/overlay/countdown?id=${id}`);
}
function selectAndRun(id: number, action: () => Promise<void>) {
countdownStore.select(id);
void action();
}
onMount(async () => {
void countdownStore.loadList();
cleanup = await countdownStore.initStoreListeners();
try {
const res = await fetch(`${OVERLAY_SERVER_ORIGIN}/api/icons`);
icons = await res.json();
} catch {
icons = [];
}
});
onDestroy(() => cleanup?.());
@@ -41,36 +97,107 @@
</fieldset>
</article>
{#if $countdownStore.items.length > 0}
<article>
<header>
<select on:change={(e) => countdownStore.select(Number(e.currentTarget.value))}>
{#each $countdownStore.items as item (item.id)}
<option value={item.id}>{item.label}</option>
{/each}
</select>
{#if $countdownStore.selected}
<mark data-state={$countdownStore.selected.state}>
{$countdownStore.selected.state}
</mark>
{#each $countdownStore.items as item (item.id)}
<details on:toggle={(e) => (e.currentTarget as HTMLDetailsElement).open && countdownStore.select(item.id)}>
<summary>
{#if getSettings(item.id).icon}
<img
src={`${OVERLAY_SERVER_ORIGIN}/static/icons/${getSettings(item.id).icon}`}
alt={getSettings(item.id).icon}
style="width:1.2em;height:1.2em;vertical-align:middle;margin-right:0.3em;"
/>
{/if}
</header>
{item.label}
<mark data-state={item.state}>{item.state}</mark>
</summary>
{#if $countdownStore.selected}
<p class="timer-display">
{formatDuration($countdownStore.liveRemaining ?? $countdownStore.selected.duration)}
</p>
<footer class="countdown-actions">
{#if $countdownStore.selected.state === "Idle"}
<button on:click={() => countdownStore.startSelected()}>Start</button>
{:else if $countdownStore.selected.state === "Running"}
<button on:click={() => countdownStore.pauseSelected()}>Pause</button>
{:else if $countdownStore.selected.state === "Paused"}
<button on:click={() => countdownStore.resumeSelected()}>Resume</button>
{/if}
<button class="secondary" on:click={() => countdownStore.resetSelected()}>Reset</button>
<button class="secondary contrast" on:click={() => countdownStore.deleteSelected()}>Delete</button>
</footer>
{/if}
</article>
{/if}
<p class="timer-display">
{#if $countdownStore.selectedId === item.id && $countdownStore.liveRemaining}
{formatDuration($countdownStore.liveRemaining)}
{:else}
{formatDuration(item.duration)}
{/if}
</p>
<div class="countdown-actions">
{#if item.state === "Idle"}
<button on:click={() => selectAndRun(item.id, countdownStore.startSelected)}>Start</button>
{:else if item.state === "Running"}
<button on:click={() => selectAndRun(item.id, countdownStore.pauseSelected)}>Pause</button>
{:else if item.state === "Paused"}
<button on:click={() => selectAndRun(item.id, countdownStore.resumeSelected)}>Resume</button>
{/if}
<button class="secondary" on:click={() => selectAndRun(item.id, countdownStore.resetSelected)}>Reset</button>
<button class="secondary contrast" on:click={() => selectAndRun(item.id, countdownStore.deleteSelected)}>Delete</button>
</div>
<hr />
<p><small>Icon</small></p>
<div class="icon-picker">
{#each icons as name}
<button
class="icon-btn {getSettings(item.id).icon === name ? 'selected' : ''}"
on:click={() => {
updateSettings(item.id, {icon: name});
pushConfig(item.id);
}}
>
<img src={`${OVERLAY_SERVER_ORIGIN}/static/icons/${name}`} alt={name} />
</button>
{/each}
<button
class="icon-btn"
on:click={() => {
updateSettings(item.id, {icon: ''});
pushConfig(item.id);
}}
>
</button>
</div>
<div class="overlay-colors">
<label>
Text
<input
type="color"
value={getSettings(item.id).textColor}
on:change={(e) => {
updateSettings(item.id, {textColor: e.currentTarget.value});
pushConfig(item.id);
}}
/>
</label>
<label>
<input
type="checkbox"
checked={getSettings(item.id).bgTransparent}
on:change={(e) => {
updateSettings(item.id, {bgTransparent: e.currentTarget.checked});
pushConfig(item.id);
}}
/>
Transparent BG
</label>
{#if !getSettings(item.id).bgTransparent}
<label>
BG
<input
type="color"
value={getSettings(item.id).bgColor}
on:change={(e) => {
updateSettings(item.id, {bgColor: e.currentTarget.value});
pushConfig(item.id);
}}
/>
</label>
{/if}
</div>
<div class="source-url">
<input readonly value={`${OVERLAY_SERVER_ORIGIN}/overlay/countdown?id=${item.id}`} />
<button class="secondary" on:click={() => copyUrl(item.id)}>Copy</button>
</div>
</details>
{/each}

View File

@@ -29,9 +29,14 @@ export type CountdownCommand =
| "countdown_pause"
| "countdown_resume"
| "countdown_reset"
| "countdown_snapshot";
| "countdown_snapshot"
| "set_overlay_config";
export type CountdownPayload = EmptyPayload | CountdownIdPayload | CountdownCreatePayload;
export type CountdownPayload =
| EmptyPayload
| CountdownIdPayload
| CountdownCreatePayload
| OverlayConfigPayload;
export type CountdownIdPayload = {
id: number;
@@ -46,4 +51,11 @@ export type CountdownTickPayload = {
id: number;
label: string;
remaining_ms: number;
};
};
export type OverlayConfigPayload = {
id: number;
icon: string;
text_color: string;
bg_color: string;
};