feat: add accordion overlay controls and icon API
This commit is contained in:
3
public/icons/bell.svg
Normal file
3
public/icons/bell.svg
Normal 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
3
public/icons/fire.svg
Normal 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
3
public/icons/flag.svg
Normal 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 |
3
public/icons/hourglass.svg
Normal file
3
public/icons/hourglass.svg
Normal 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
3
public/icons/star.svg
Normal 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 |
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -13,4 +13,5 @@ pub struct CountdownTickPayload {
|
||||
pub enum AppEvent {
|
||||
Tick(CountdownTickPayload),
|
||||
Changed(Vec<CountdownSnapshotDto>),
|
||||
ConfigChanged(u64),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user