Initial commit - Core Cooldown v0.1.0
Portable Windows break timer to prevent RSI and eye strain. Tauri v2 + Svelte 5 + Tailwind CSS v4. No installer, no telemetry, no data leaves the machine. CC0 public domain.
This commit is contained in:
111
src/lib/components/BackgroundBlobs.svelte
Normal file
111
src/lib/components/BackgroundBlobs.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
accentColor: string;
|
||||
breakColor: string;
|
||||
}
|
||||
|
||||
let { accentColor, breakColor }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<!-- Gradient blobs -->
|
||||
<div
|
||||
class="blob blob-1"
|
||||
style="background: radial-gradient(circle, {accentColor} 0%, transparent 70%);"
|
||||
></div>
|
||||
<div
|
||||
class="blob blob-2"
|
||||
style="background: radial-gradient(circle, {breakColor} 0%, transparent 70%);"
|
||||
></div>
|
||||
<div
|
||||
class="blob blob-3"
|
||||
style="background: radial-gradient(circle, {accentColor} 0%, transparent 70%);"
|
||||
></div>
|
||||
<div
|
||||
class="blob blob-4"
|
||||
style="background: radial-gradient(circle, {breakColor} 0%, transparent 70%);"
|
||||
></div>
|
||||
|
||||
<!-- Film grain overlay -->
|
||||
<svg class="absolute inset-0 h-full w-full" style="opacity: 0.08; mix-blend-mode: overlay;">
|
||||
<filter id="grain-filter">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.45" numOctaves="3" stitchTiles="stitch">
|
||||
<animate
|
||||
attributeName="seed"
|
||||
from="0"
|
||||
to="100"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</feTurbulence>
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#grain-filter)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.4;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.blob-1 {
|
||||
top: -15%;
|
||||
left: -10%;
|
||||
animation: drift-1 30s ease-in-out infinite;
|
||||
}
|
||||
.blob-2 {
|
||||
bottom: -15%;
|
||||
right: -10%;
|
||||
animation: drift-2 35s ease-in-out infinite;
|
||||
}
|
||||
.blob-3 {
|
||||
top: 40%;
|
||||
right: -15%;
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
animation: drift-3 28s ease-in-out infinite;
|
||||
}
|
||||
.blob-4 {
|
||||
bottom: 20%;
|
||||
left: -15%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
animation: drift-4 32s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes drift-1 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
20% { transform: translate(300px, 200px) scale(1.15); }
|
||||
40% { transform: translate(150px, 450px) scale(0.9); }
|
||||
60% { transform: translate(350px, 350px) scale(1.1); }
|
||||
80% { transform: translate(100px, 100px) scale(0.95); }
|
||||
}
|
||||
|
||||
@keyframes drift-2 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
20% { transform: translate(-250px, -150px) scale(1.1); }
|
||||
40% { transform: translate(-100px, -400px) scale(0.95); }
|
||||
60% { transform: translate(-300px, -200px) scale(1.15); }
|
||||
80% { transform: translate(-50px, -300px) scale(0.9); }
|
||||
}
|
||||
|
||||
@keyframes drift-3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(-300px, -200px) scale(1.1); }
|
||||
50% { transform: translate(-150px, 150px) scale(0.9); }
|
||||
75% { transform: translate(-350px, -50px) scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes drift-4 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(280px, -180px) scale(1.15); }
|
||||
50% { transform: translate(100px, 200px) scale(0.9); }
|
||||
75% { transform: translate(320px, 50px) scale(1.1); }
|
||||
}
|
||||
</style>
|
||||
417
src/lib/components/BreakScreen.svelte
Normal file
417
src/lib/components/BreakScreen.svelte
Normal file
@@ -0,0 +1,417 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { timer, currentView, formatTime, type TimerSnapshot } from "../stores/timer";
|
||||
import { config } from "../stores/config";
|
||||
import TimerRing from "./TimerRing.svelte";
|
||||
import { scaleIn, fadeIn, pressable } from "../utils/animate";
|
||||
import { pickRandomActivity, getCategoryIcon, getCategoryLabel, type BreakActivity } from "../utils/activities";
|
||||
|
||||
interface Props {
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
let { standalone = false }: Props = $props();
|
||||
|
||||
const appWindow = standalone ? getCurrentWebviewWindow() : null;
|
||||
|
||||
let currentActivity = $state<BreakActivity>(pickRandomActivity());
|
||||
let activityCycleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Cycle activity every 30 seconds during break
|
||||
$effect(() => {
|
||||
if ($config.show_break_activities && $timer.state === "breakActive") {
|
||||
activityCycleTimer = setInterval(() => {
|
||||
currentActivity = pickRandomActivity(currentActivity);
|
||||
}, 30_000);
|
||||
}
|
||||
return () => {
|
||||
if (activityCycleTimer) {
|
||||
clearInterval(activityCycleTimer);
|
||||
activityCycleTimer = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
async function cancelBreak() {
|
||||
const snap = await invoke<TimerSnapshot>("cancel_break");
|
||||
timer.set(snap);
|
||||
if (standalone) {
|
||||
appWindow?.hide();
|
||||
} else {
|
||||
currentView.set(snap.currentView);
|
||||
}
|
||||
}
|
||||
|
||||
async function snoozeBreak() {
|
||||
const snap = await invoke<TimerSnapshot>("snooze");
|
||||
timer.set(snap);
|
||||
// If snooze ended the break, hide standalone window
|
||||
if (standalone && snap.state !== "breakActive") {
|
||||
appWindow?.hide();
|
||||
}
|
||||
}
|
||||
|
||||
const breakRingProgress = $derived(
|
||||
$timer.breakTotalDuration > 0
|
||||
? $timer.breakTimeRemaining / $timer.breakTotalDuration
|
||||
: 0,
|
||||
);
|
||||
|
||||
const cancelBtnText = $derived(
|
||||
$timer.breakPastHalf && $config.allow_end_early ? "End break" : "Skip",
|
||||
);
|
||||
|
||||
const showButtons = $derived(!$config.strict_mode);
|
||||
|
||||
// Bottom progress bar uses a gradient from break color to accent
|
||||
const barGradient = $derived(
|
||||
`linear-gradient(to right, ${$config.break_color}, ${$config.accent_color})`,
|
||||
);
|
||||
|
||||
const isModal = $derived(!$config.fullscreen_mode && !standalone);
|
||||
</script>
|
||||
|
||||
{#if standalone}
|
||||
<!-- ── Standalone break window: horizontal card, transparent background ── -->
|
||||
<div class="standalone-card" use:scaleIn={{ duration: 0.5 }}>
|
||||
<!-- Ripples emanate from the ring, visible outside the card -->
|
||||
<div class="standalone-ring-area">
|
||||
<div class="ripple-container">
|
||||
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
|
||||
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
|
||||
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
|
||||
</div>
|
||||
<div class="break-breathe">
|
||||
<TimerRing
|
||||
progress={breakRingProgress}
|
||||
size={140}
|
||||
strokeWidth={5}
|
||||
accentColor={$config.break_color}
|
||||
>
|
||||
<div class="break-breathe-counter">
|
||||
<span
|
||||
class="font-semibold leading-none tabular-nums text-white text-[26px]"
|
||||
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
||||
>
|
||||
{formatTime($timer.breakTimeRemaining)}
|
||||
</span>
|
||||
</div>
|
||||
</TimerRing>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side: text + buttons -->
|
||||
<div class="standalone-content">
|
||||
<h2 class="text-[17px] font-medium text-white mb-1.5">
|
||||
{$timer.breakTitle}
|
||||
</h2>
|
||||
<p class="text-[12px] leading-relaxed text-[#666] mb-4 max-w-[240px]">
|
||||
{$timer.breakMessage}
|
||||
</p>
|
||||
|
||||
{#if $config.show_break_activities}
|
||||
<div class="rounded-xl border border-[#ffffff08] bg-[#ffffff06] px-4 py-2.5 mb-4 max-w-[260px]">
|
||||
<div class="text-[9px] font-medium tracking-[0.15em] text-[#555] uppercase mb-1">
|
||||
{getCategoryLabel(currentActivity.category)}
|
||||
</div>
|
||||
<p class="text-[12px] leading-relaxed text-[#999]">
|
||||
{currentActivity.text}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showButtons}
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-full border border-[#333] px-5 py-2 text-[11px]
|
||||
tracking-wider text-[#666] uppercase
|
||||
transition-colors duration-200
|
||||
hover:border-[#444] hover:text-[#aaa]"
|
||||
onclick={cancelBreak}
|
||||
>
|
||||
{cancelBtnText}
|
||||
</button>
|
||||
{#if $timer.canSnooze}
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-full px-5 py-2 text-[11px]
|
||||
tracking-wider text-white uppercase
|
||||
transition-colors duration-200"
|
||||
style="background: rgba(255,255,255,0.08);"
|
||||
onclick={snoozeBreak}
|
||||
>
|
||||
Snooze
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $config.snooze_limit > 0}
|
||||
<p class="mt-2 text-[9px] text-[#333]">
|
||||
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Bottom progress bar with clip-path -->
|
||||
<div class="standalone-progress-container">
|
||||
<div class="standalone-progress-track">
|
||||
<div
|
||||
class="h-full transition-[width] duration-1000 ease-linear"
|
||||
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── In-app break screen: full-screen fill OR modal with backdrop ── -->
|
||||
<div
|
||||
class="relative h-full"
|
||||
class:flex={isModal}
|
||||
class:items-center={isModal}
|
||||
class:justify-center={isModal}
|
||||
style={isModal ? `background: #000;` : ""}
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col"
|
||||
class:h-full={!isModal}
|
||||
class:break-modal={isModal}
|
||||
>
|
||||
<!-- Break ring with breathing pulse + ripples -->
|
||||
<div class="mb-6 relative" use:scaleIn={{ duration: 0.6, delay: 0.1 }}>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="break-ripple ripple-1" style="--ripple-color: {$config.break_color}"></div>
|
||||
<div class="break-ripple ripple-2" style="--ripple-color: {$config.break_color}"></div>
|
||||
<div class="break-ripple ripple-3" style="--ripple-color: {$config.break_color}"></div>
|
||||
</div>
|
||||
|
||||
<div class="break-breathe relative">
|
||||
<TimerRing
|
||||
progress={breakRingProgress}
|
||||
size={isModal ? 160 : 200}
|
||||
strokeWidth={isModal ? 5 : 6}
|
||||
accentColor={$config.break_color}
|
||||
>
|
||||
<div class="break-breathe-counter">
|
||||
<span
|
||||
class="font-semibold leading-none tabular-nums text-white"
|
||||
class:text-[30px]={isModal}
|
||||
class:text-[38px]={!isModal}
|
||||
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
||||
>
|
||||
{formatTime($timer.breakTimeRemaining)}
|
||||
</span>
|
||||
</div>
|
||||
</TimerRing>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-2 text-lg font-medium text-white" use:fadeIn={{ delay: 0.25, y: 10 }}>
|
||||
{$timer.breakTitle}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="max-w-[300px] text-center text-[13px] leading-relaxed text-[#555]"
|
||||
class:mb-4={$config.show_break_activities}
|
||||
class:mb-8={!$config.show_break_activities}
|
||||
use:fadeIn={{ delay: 0.35, y: 10 }}
|
||||
>
|
||||
{$timer.breakMessage}
|
||||
</p>
|
||||
|
||||
{#if $config.show_break_activities}
|
||||
<div
|
||||
class="mb-8 mx-auto max-w-[320px] rounded-2xl border border-[#1a1a1a] bg-[#0a0a0a] px-5 py-3.5 text-center"
|
||||
use:fadeIn={{ delay: 0.4, y: 10 }}
|
||||
>
|
||||
<div class="mb-1.5 text-[10px] font-medium tracking-[0.15em] text-[#444] uppercase">
|
||||
{getCategoryLabel(currentActivity.category)}
|
||||
</div>
|
||||
<p class="text-[13px] leading-relaxed text-[#999]">
|
||||
{currentActivity.text}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showButtons}
|
||||
<div class="flex items-center gap-3" use:fadeIn={{ delay: 0.45, y: 10 }}>
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-full border border-[#222] px-6 py-2.5 text-[12px]
|
||||
tracking-wider text-[#555] uppercase
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#999]"
|
||||
onclick={cancelBreak}
|
||||
>
|
||||
{cancelBtnText}
|
||||
</button>
|
||||
{#if $timer.canSnooze}
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-full px-6 py-2.5 text-[12px]
|
||||
tracking-wider text-white uppercase backdrop-blur-xl
|
||||
transition-colors duration-200"
|
||||
style="background: rgba(20,20,20,0.7);"
|
||||
onclick={snoozeBreak}
|
||||
>
|
||||
Snooze
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $config.snooze_limit > 0}
|
||||
<p class="mt-3 text-[10px] text-[#2a2a2a]">
|
||||
{$timer.snoozesUsed}/{$config.snooze_limit} snoozes used
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Bottom progress bar for modal - uses clip-path to respect border radius -->
|
||||
<div class="break-modal-progress-container">
|
||||
<div class="break-modal-progress-track">
|
||||
<div
|
||||
class="h-full transition-[width] duration-1000 ease-linear"
|
||||
style="width: {$timer.breakProgress * 100}%; background: {barGradient};"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Standalone horizontal card ── */
|
||||
.standalone-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
background: rgba(12, 12, 12, 0.97);
|
||||
backdrop-filter: blur(32px);
|
||||
-webkit-backdrop-filter: blur(32px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 24px;
|
||||
padding: 32px 36px 32px 32px;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.3),
|
||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 80px rgba(0, 0, 0, 0.3);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.standalone-ring-area {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.standalone-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Standalone progress bar - positioned inside the padding area */
|
||||
.standalone-progress-container {
|
||||
position: absolute;
|
||||
bottom: 28px;
|
||||
left: 28px;
|
||||
right: 28px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.standalone-progress-track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Ripple container — sits behind the ring, overflows the card */
|
||||
.ripple-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Modal card when fullscreen is off (in-app) ── */
|
||||
.break-modal {
|
||||
position: relative;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 28px;
|
||||
padding: 40px 32px 32px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 24px 100px rgba(0, 0, 0, 0.7);
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Progress bar - positioned inside padding to avoid rounded corner overflow */
|
||||
.break-modal-progress-container {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.break-modal-progress-track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* ── Breathing pulse on the ring ── */
|
||||
.break-breathe {
|
||||
animation: breathe 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.04); }
|
||||
}
|
||||
|
||||
.break-breathe-counter {
|
||||
animation: breathe-counter 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe-counter {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(0.962); }
|
||||
}
|
||||
|
||||
/* ── Ripple circles ── */
|
||||
.break-ripple {
|
||||
position: absolute;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--ripple-color);
|
||||
opacity: 0;
|
||||
animation: ripple-expand 4s ease-out infinite;
|
||||
}
|
||||
.ripple-1 { animation-delay: 0s; }
|
||||
.ripple-2 { animation-delay: 1.33s; }
|
||||
.ripple-3 { animation-delay: 2.66s; }
|
||||
|
||||
@keyframes ripple-expand {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
src/lib/components/BreakWindow.svelte
Normal file
63
src/lib/components/BreakWindow.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
import { initTimerStore } from "../stores/timer";
|
||||
import { loadConfig, config } from "../stores/config";
|
||||
import BreakScreen from "./BreakScreen.svelte";
|
||||
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
|
||||
// Base window dimensions (from tauri.conf.json)
|
||||
const BASE_W = 900;
|
||||
const BASE_H = 540;
|
||||
|
||||
let ready = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await loadConfig();
|
||||
await initTimerStore();
|
||||
ready = true;
|
||||
|
||||
// Auto-hide when break ends (window is persistent, just hide it)
|
||||
await listen("break-ended", () => {
|
||||
appWindow.hide();
|
||||
});
|
||||
|
||||
// Live-reload config when main window changes settings (zoom, colors, etc.)
|
||||
await listen("config-changed", () => {
|
||||
loadConfig();
|
||||
});
|
||||
});
|
||||
|
||||
const zoomScale = $derived($config.ui_zoom / 100);
|
||||
|
||||
// Resize the actual Tauri window when zoom changes
|
||||
$effect(() => {
|
||||
const scale = $config.ui_zoom / 100;
|
||||
const w = Math.round(BASE_W * scale);
|
||||
const h = Math.round(BASE_H * scale);
|
||||
appWindow.setSize(new LogicalSize(w, h));
|
||||
appWindow.center();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<div class="w-full h-full flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
style="
|
||||
width: {100 / zoomScale}%;
|
||||
height: {100 / zoomScale}%;
|
||||
transform: scale({zoomScale});
|
||||
transform-origin: center center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
"
|
||||
>
|
||||
{#if ready}
|
||||
<BreakScreen standalone={true} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
301
src/lib/components/ColorPicker.svelte
Normal file
301
src/lib/components/ColorPicker.svelte
Normal file
@@ -0,0 +1,301 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
label: string;
|
||||
presets?: string[];
|
||||
onchange?: (color: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
label,
|
||||
presets = [
|
||||
"#ff4d00", "#ff6b35", "#e63946", "#d62828",
|
||||
"#f77f00", "#fcbf49", "#2ec4b6", "#3fb950",
|
||||
"#7c6aef", "#9b5de5", "#4361ee", "#4895ef",
|
||||
"#f72585", "#ff006e", "#ffffff", "#888888",
|
||||
],
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
let showCustom = $state(false);
|
||||
let hue = $state(0);
|
||||
let sat = $state(100);
|
||||
let light = $state(50);
|
||||
let hexInput = $state(value);
|
||||
let draggingSL = $state(false);
|
||||
let draggingHue = $state(false);
|
||||
|
||||
// Pointer ID tracking for proper cleanup
|
||||
let slPointerId = $state<number | null>(null);
|
||||
let huePointerId = $state<number | null>(null);
|
||||
|
||||
// Parse current value into HSL on mount
|
||||
$effect(() => {
|
||||
const hsl = hexToHsl(value);
|
||||
if (hsl) {
|
||||
hue = hsl.h;
|
||||
sat = hsl.s;
|
||||
light = hsl.l;
|
||||
}
|
||||
hexInput = value;
|
||||
});
|
||||
|
||||
function selectPreset(color: string) {
|
||||
value = color;
|
||||
hexInput = color;
|
||||
showCustom = false;
|
||||
const hsl = hexToHsl(color);
|
||||
if (hsl) { hue = hsl.h; sat = hsl.s; light = hsl.l; }
|
||||
onchange?.(color);
|
||||
}
|
||||
|
||||
function updateFromHSL() {
|
||||
const hex = hslToHex(hue, sat, light);
|
||||
value = hex;
|
||||
hexInput = hex;
|
||||
onchange?.(hex);
|
||||
}
|
||||
|
||||
function onHexInput(e: Event) {
|
||||
const input = (e.target as HTMLInputElement).value;
|
||||
hexInput = input;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(input)) {
|
||||
value = input;
|
||||
const hsl = hexToHsl(input);
|
||||
if (hsl) { hue = hsl.h; sat = hsl.s; light = hsl.l; }
|
||||
onchange?.(input);
|
||||
}
|
||||
}
|
||||
|
||||
// --- SL pad interaction (Pointer Events) ---
|
||||
let slPad = $state<HTMLDivElement>(undefined!);
|
||||
|
||||
function handleSLPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
draggingSL = true;
|
||||
slPointerId = e.pointerId;
|
||||
|
||||
// Capture pointer to keep receiving events even outside element
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
|
||||
// Update immediately on down
|
||||
updateSLFromPointer(e);
|
||||
}
|
||||
|
||||
function handleSLPointerMove(e: PointerEvent) {
|
||||
if (!draggingSL || !slPad || e.pointerId !== slPointerId) return;
|
||||
e.preventDefault();
|
||||
updateSLFromPointer(e);
|
||||
}
|
||||
|
||||
function updateSLFromPointer(e: PointerEvent) {
|
||||
if (!slPad) return;
|
||||
const rect = slPad.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
sat = Math.round(x * 100);
|
||||
light = Math.round((1 - y) * 100);
|
||||
updateFromHSL();
|
||||
}
|
||||
|
||||
function handleSLPointerUp(e: PointerEvent) {
|
||||
if (!draggingSL || e.pointerId !== slPointerId) return;
|
||||
e.preventDefault();
|
||||
draggingSL = false;
|
||||
slPointerId = null;
|
||||
}
|
||||
|
||||
// --- Hue bar interaction (Pointer Events) ---
|
||||
let hueBar = $state<HTMLDivElement>(undefined!);
|
||||
|
||||
function handleHuePointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
draggingHue = true;
|
||||
huePointerId = e.pointerId;
|
||||
|
||||
// Capture pointer to keep receiving events even outside element
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
|
||||
// Update immediately on down
|
||||
updateHueFromPointer(e);
|
||||
}
|
||||
|
||||
function handleHuePointerMove(e: PointerEvent) {
|
||||
if (!draggingHue || !hueBar || e.pointerId !== huePointerId) return;
|
||||
e.preventDefault();
|
||||
updateHueFromPointer(e);
|
||||
}
|
||||
|
||||
function updateHueFromPointer(e: PointerEvent) {
|
||||
if (!hueBar) return;
|
||||
const rect = hueBar.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
hue = Math.round(x * 360);
|
||||
updateFromHSL();
|
||||
}
|
||||
|
||||
function handleHuePointerUp(e: PointerEvent) {
|
||||
if (!draggingHue || e.pointerId !== huePointerId) return;
|
||||
e.preventDefault();
|
||||
draggingHue = false;
|
||||
huePointerId = null;
|
||||
}
|
||||
|
||||
// --- Color conversion helpers ---
|
||||
function hexToHsl(hex: string): { h: number; s: number; l: number } | null {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return null;
|
||||
let r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
let g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
let b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||
else h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const sl = s / 100, ll = l / 100;
|
||||
const a = sl * Math.min(ll, 1 - ll);
|
||||
function f(n: number) {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = ll - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
}
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
}
|
||||
|
||||
const isPreset = $derived(presets.includes(value));
|
||||
|
||||
// SL cursor position
|
||||
const slX = $derived(sat);
|
||||
const slY = $derived(100 - light);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Header row: label + current color preview -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">{label}</div>
|
||||
<div class="font-mono text-[11px] text-[#444]">{value}</div>
|
||||
</div>
|
||||
<div
|
||||
class="h-6 w-6 rounded-full border border-[#333] shadow-[0_0_8px_0px] transition-shadow duration-300"
|
||||
style="background: {value}; --tw-shadow-color: {value}40;"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Preset swatches -->
|
||||
<div class="flex flex-wrap gap-[6px]">
|
||||
{#each presets as color}
|
||||
<button
|
||||
type="button"
|
||||
class="h-[22px] w-[22px] rounded-full transition-all duration-150
|
||||
{value === color
|
||||
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
|
||||
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
|
||||
style="background: {color};"
|
||||
onclick={() => selectPreset(color)}
|
||||
aria-label="Select {color}"
|
||||
></button>
|
||||
{/each}
|
||||
|
||||
<!-- Custom toggle swatch — ring shows when picker open OR value is custom -->
|
||||
<button
|
||||
type="button"
|
||||
class="h-[22px] w-[22px] rounded-full transition-all duration-150
|
||||
{showCustom || !isPreset
|
||||
? 'ring-[1.5px] ring-white ring-offset-1 ring-offset-black scale-110'
|
||||
: 'hover:scale-110 opacity-80 hover:opacity-100'}"
|
||||
style="background: conic-gradient(from 0deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);"
|
||||
onclick={() => { showCustom = !showCustom; }}
|
||||
aria-label="Custom color"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<!-- Inline custom picker — slides open/closed -->
|
||||
{#if showCustom}
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl bg-[#0f0f0f] p-3 border border-[#1a1a1a]"
|
||||
transition:slide={{ duration: 280, easing: cubicOut }}
|
||||
>
|
||||
<!-- Saturation / Lightness pad -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={slPad}
|
||||
class="relative h-[120px] w-full cursor-crosshair rounded-lg overflow-hidden touch-none"
|
||||
style="background: linear-gradient(to right, hsl({hue}, 0%, 50%), hsl({hue}, 100%, 50%));"
|
||||
onpointerdown={handleSLPointerDown}
|
||||
onpointermove={handleSLPointerMove}
|
||||
onpointerup={handleSLPointerUp}
|
||||
onpointercancel={handleSLPointerUp}
|
||||
role="application"
|
||||
aria-label="Saturation and lightness"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Lightness overlay: white at top, black at bottom -->
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style="background: linear-gradient(to bottom, hsl({hue}, 100%, 100%), transparent, hsl({hue}, 100%, 0%));"
|
||||
></div>
|
||||
<!-- Cursor -->
|
||||
<div
|
||||
class="pointer-events-none absolute h-[14px] w-[14px] -translate-x-1/2 -translate-y-1/2 rounded-full
|
||||
border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
|
||||
style="left: {slX}%; top: {slY}%; background: {value};"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Hue bar -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={hueBar}
|
||||
class="relative h-[16px] w-full cursor-pointer rounded-full overflow-hidden touch-none"
|
||||
style="background: linear-gradient(to right,
|
||||
hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%),
|
||||
hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%));"
|
||||
onpointerdown={handleHuePointerDown}
|
||||
onpointermove={handleHuePointerMove}
|
||||
onpointerup={handleHuePointerUp}
|
||||
onpointercancel={handleHuePointerUp}
|
||||
role="application"
|
||||
aria-label="Hue"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Hue cursor -->
|
||||
<div
|
||||
class="pointer-events-none absolute top-1/2 h-[12px] w-[12px] -translate-x-1/2 -translate-y-1/2 rounded-full
|
||||
border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)]"
|
||||
style="left: {(hue / 360) * 100}%; background: hsl({hue}, 100%, 50%);"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Hex input -->
|
||||
<input
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px]
|
||||
font-mono text-white outline-none
|
||||
placeholder:text-[#333] focus:border-[#333]"
|
||||
placeholder="#ff4d00"
|
||||
value={hexInput}
|
||||
oninput={onHexInput}
|
||||
maxlength={7}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
289
src/lib/components/Dashboard.svelte
Normal file
289
src/lib/components/Dashboard.svelte
Normal file
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
timer,
|
||||
currentView,
|
||||
formatTime,
|
||||
formatDurationAgo,
|
||||
type TimerSnapshot,
|
||||
} from "../stores/timer";
|
||||
import { config } from "../stores/config";
|
||||
import TimerRing from "./TimerRing.svelte";
|
||||
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
|
||||
|
||||
async function toggleTimer() {
|
||||
const snap = await invoke<TimerSnapshot>("toggle_timer");
|
||||
timer.set(snap);
|
||||
}
|
||||
|
||||
async function startBreakNow() {
|
||||
const snap = await invoke<TimerSnapshot>("start_break_now");
|
||||
timer.set(snap);
|
||||
currentView.set(snap.currentView);
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
invoke("set_view", { view: "settings" });
|
||||
currentView.set("settings");
|
||||
}
|
||||
|
||||
const statusText = $derived(
|
||||
$timer.idlePaused
|
||||
? "IDLE"
|
||||
: $timer.prebreakWarning
|
||||
? "BREAK SOON"
|
||||
: $timer.state === "running"
|
||||
? "FOCUS"
|
||||
: "PAUSED",
|
||||
);
|
||||
|
||||
const toggleBtnText = $derived(
|
||||
$timer.state === "running" ? "PAUSE" : "START",
|
||||
);
|
||||
|
||||
// Responsive ring scaling computed from window dimensions
|
||||
let windowW = $state(window.innerWidth);
|
||||
let windowH = $state(window.innerHeight);
|
||||
|
||||
// Ring scales 1.0 → 0.6 based on both dimensions
|
||||
const ringScale = $derived(
|
||||
Math.min(1, Math.max(0.6, Math.min(
|
||||
(windowH - 300) / 400,
|
||||
(windowW - 200) / 300,
|
||||
))),
|
||||
);
|
||||
|
||||
// Text scales less aggressively: 1.0 → 0.7 (counter-scale inside ring)
|
||||
// counterScale = textScale / ringScale, where textScale = lerp(0.7, 1.0, (ringScale-0.6)/0.4)
|
||||
const textCounterScale = $derived(
|
||||
ringScale < 1
|
||||
? Math.min(1.2, (0.7 + (ringScale - 0.6) * 0.75) / ringScale)
|
||||
: 1,
|
||||
);
|
||||
|
||||
// Gap between ring and button, compensating for CSS transform phantom space.
|
||||
// transform: scale() doesn't affect layout, so the 280px box stays full-size
|
||||
// even when visually shrunk — creating phantom space below the visual ring.
|
||||
const ringSize = 280;
|
||||
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
|
||||
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
|
||||
const ringMargin = $derived(targetGap - phantomBelow);
|
||||
|
||||
// Natural break notification
|
||||
let showNaturalBreakToast = $state(false);
|
||||
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Watch for natural break detection
|
||||
$effect(() => {
|
||||
if ($timer.naturalBreakOccurred) {
|
||||
showNaturalBreakToast = true;
|
||||
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
|
||||
naturalBreakToastTimeout = setTimeout(() => {
|
||||
showNaturalBreakToast = false;
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={windowW} bind:innerHeight={windowH} />
|
||||
|
||||
<div class="relative flex h-full flex-col items-center justify-center">
|
||||
<!-- Outer: responsive scaling (JS-driven), Inner: mount animation -->
|
||||
<div style="transform: scale({ringScale}); transform-origin: center center; margin-bottom: {ringMargin}px;">
|
||||
<div use:scaleIn={{ duration: 0.7, delay: 0.1 }}>
|
||||
<TimerRing
|
||||
progress={$timer.progress}
|
||||
size={280}
|
||||
strokeWidth={8}
|
||||
accentColor={$config.accent_color}
|
||||
>
|
||||
<!-- Counter-scale wrapper: text shrinks less than ring -->
|
||||
<div style="transform: scale({textCounterScale}); transform-origin: center center;">
|
||||
<!-- Eye icon -->
|
||||
<svg
|
||||
class="mx-auto mb-3 eye-blink"
|
||||
width="26"
|
||||
height="26"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#444"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
|
||||
<!-- Time display -->
|
||||
<span
|
||||
class="block text-center text-[54px] font-semibold leading-none tracking-tight tabular-nums text-white"
|
||||
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
||||
>
|
||||
{formatTime($timer.timeRemaining)}
|
||||
</span>
|
||||
|
||||
<div class="h-3"></div>
|
||||
|
||||
<!-- Status label -->
|
||||
<span
|
||||
class="block text-center text-[11px] font-medium tracking-[0.25em]"
|
||||
class:text-[#444]={!$timer.prebreakWarning &&
|
||||
$timer.state === "running"}
|
||||
class:text-[#333]={!$timer.prebreakWarning &&
|
||||
$timer.state === "paused"}
|
||||
class:text-warning={$timer.prebreakWarning}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
</TimerRing>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last break info -->
|
||||
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
|
||||
{#if $timer.hasHadBreak}
|
||||
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#2a2a2a]">
|
||||
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
|
||||
</p>
|
||||
{:else}
|
||||
<div style="margin-bottom: {ringMargin}px; height: 18px;"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Natural break notification toast -->
|
||||
{#if showNaturalBreakToast}
|
||||
<div
|
||||
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
|
||||
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
|
||||
use:scaleIn={{ duration: 0.3, delay: 0 }}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pause / Start button -->
|
||||
<button
|
||||
use:fadeIn={{ delay: 0.3, y: 12 }}
|
||||
use:pressable
|
||||
use:glowHover={{ color: $config.accent_color }}
|
||||
class="w-[200px] rounded-full py-3.5 text-[13px] font-medium
|
||||
tracking-[0.2em] text-white uppercase backdrop-blur-xl
|
||||
transition-colors duration-200"
|
||||
style="background: rgba(20,20,20,0.7);"
|
||||
onclick={toggleTimer}
|
||||
>
|
||||
{toggleBtnText}
|
||||
</button>
|
||||
|
||||
<!-- Bottom left: start break now -->
|
||||
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
|
||||
<button
|
||||
aria-label="Start break now"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
onclick={startBreakNow}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom center: stats -->
|
||||
<div class="absolute bottom-5 left-1/2 -translate-x-1/2" use:fadeIn={{ delay: 0.52, y: 8 }}>
|
||||
<button
|
||||
aria-label="Statistics"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
onclick={() => {
|
||||
invoke("set_view", { view: "stats" });
|
||||
currentView.set("stats");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom right: settings -->
|
||||
<div class="absolute bottom-5 right-5" use:fadeIn={{ delay: 0.55, y: 8 }}>
|
||||
<button
|
||||
aria-label="Settings"
|
||||
use:pressable
|
||||
class="flex h-11 w-11 items-center justify-center rounded-full
|
||||
border border-[#222] text-[#383838]
|
||||
transition-colors duration-200
|
||||
hover:border-[#333] hover:text-[#666]"
|
||||
onclick={openSettings}
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Eye blink animation - natural human blink (~4s interval, 0.5s duration) */
|
||||
.eye-blink {
|
||||
transform-origin: center center;
|
||||
animation: blink 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 45%, 55%, 100% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
98
src/lib/components/FontSelector.svelte
Normal file
98
src/lib/components/FontSelector.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { slide } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onchange?: (font: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
const fonts = [
|
||||
{ family: "", label: "System Default" },
|
||||
{ family: "JetBrains Mono", label: "JetBrains Mono" },
|
||||
{ family: "Space Mono", label: "Space Mono" },
|
||||
{ family: "Roboto Mono", label: "Roboto Mono" },
|
||||
{ family: "Fira Code", label: "Fira Code" },
|
||||
{ family: "IBM Plex Mono", label: "IBM Plex Mono" },
|
||||
{ family: "Source Code Pro", label: "Source Code Pro" },
|
||||
{ family: "Share Tech Mono", label: "Share Tech Mono" },
|
||||
{ family: "Major Mono Display", label: "Major Mono" },
|
||||
{ family: "Azeret Mono", label: "Azeret Mono" },
|
||||
{ family: "DM Mono", label: "DM Mono" },
|
||||
{ family: "Inconsolata", label: "Inconsolata" },
|
||||
{ family: "Ubuntu Mono", label: "Ubuntu Mono" },
|
||||
{ family: "Overpass Mono", label: "Overpass Mono" },
|
||||
{ family: "Red Hat Mono", label: "Red Hat Mono" },
|
||||
{ family: "Martian Mono", label: "Martian Mono" },
|
||||
{ family: "Noto Sans Mono", label: "Noto Sans Mono" },
|
||||
{ family: "Oxygen Mono", label: "Oxygen Mono" },
|
||||
{ family: "Anonymous Pro", label: "Anonymous Pro" },
|
||||
{ family: "Courier Prime", label: "Courier Prime" },
|
||||
];
|
||||
|
||||
function selectFont(family: string) {
|
||||
value = family;
|
||||
onchange?.(family);
|
||||
}
|
||||
|
||||
function fontStyle(family: string): string {
|
||||
return family ? `font-family: '${family}', monospace;` : "";
|
||||
}
|
||||
|
||||
let expanded = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Header row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Countdown font</div>
|
||||
<div class="text-[11px] text-[#555]">
|
||||
{value || "System default"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-[#1a1a1a] bg-black px-3 py-1.5 text-[12px] text-[#666]
|
||||
transition-colors hover:border-[#333] hover:text-white"
|
||||
onclick={() => { expanded = !expanded; }}
|
||||
>
|
||||
{expanded ? "Close" : "Browse"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Font preview grid -->
|
||||
{#if expanded}
|
||||
<div
|
||||
class="grid grid-cols-2 gap-2"
|
||||
transition:slide={{ duration: 280, easing: cubicOut }}
|
||||
>
|
||||
{#each fonts as font}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-1.5 rounded-xl border p-3
|
||||
transition-all duration-150
|
||||
{value === font.family
|
||||
? 'border-white/30 bg-[#141414]'
|
||||
: 'border-[#141414] bg-[#0a0a0a] hover:border-[#222] hover:bg-[#0f0f0f]'}"
|
||||
onclick={() => selectFont(font.family)}
|
||||
>
|
||||
<span
|
||||
class="text-[28px] leading-none tabular-nums text-white"
|
||||
style={fontStyle(font.family)}
|
||||
>
|
||||
25:00
|
||||
</span>
|
||||
<span class="text-[9px] tracking-wider text-[#555] uppercase">
|
||||
{font.label}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
336
src/lib/components/MiniTimer.svelte
Normal file
336
src/lib/components/MiniTimer.svelte
Normal file
@@ -0,0 +1,336 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
import type { TimerSnapshot } from "../stores/timer";
|
||||
import { config, loadConfig } from "../stores/config";
|
||||
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
|
||||
let timeText = $state("--:--");
|
||||
let state = $state<"running" | "paused" | "breakActive">("paused");
|
||||
let progress = $state(0);
|
||||
let accentColor = $state("#ff4d00");
|
||||
let breakColor = $state("#7c6aef");
|
||||
let countdownFont = $state("");
|
||||
let draggable = $state(false);
|
||||
|
||||
// Use config store directly for live updates
|
||||
const uiZoom = $derived($config.ui_zoom);
|
||||
|
||||
function formatTime(secs: number): string {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Sync local color/font state from the config store
|
||||
function applyConfigLocals() {
|
||||
accentColor = $config.accent_color || "#ff4d00";
|
||||
breakColor = $config.break_color || "#7c6aef";
|
||||
countdownFont = $config.countdown_font || "";
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Get initial state
|
||||
try {
|
||||
const snap = await invoke<TimerSnapshot>("get_timer_state");
|
||||
updateFromSnapshot(snap);
|
||||
} catch (e) {
|
||||
console.error("Mini: Failed to get state", e);
|
||||
}
|
||||
|
||||
// Load config into the shared config store
|
||||
await loadConfig();
|
||||
applyConfigLocals();
|
||||
|
||||
// Live-reload config when main window changes settings (zoom, colors, etc.)
|
||||
await listen("config-changed", async () => {
|
||||
await loadConfig();
|
||||
applyConfigLocals();
|
||||
});
|
||||
|
||||
// Listen for ticks
|
||||
await listen<TimerSnapshot>("timer-tick", (event) => {
|
||||
updateFromSnapshot(event.payload);
|
||||
});
|
||||
|
||||
// Save position on move (debounced)
|
||||
let posTimer: ReturnType<typeof setTimeout>;
|
||||
appWindow.onMoved(() => {
|
||||
clearTimeout(posTimer);
|
||||
posTimer = setTimeout(async () => {
|
||||
try {
|
||||
const pos = await appWindow.outerPosition();
|
||||
await invoke("save_window_position", {
|
||||
label: "mini", x: pos.x, y: pos.y, width: 184, height: 92,
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Click-through mode: reactive to config changes
|
||||
$effect(() => {
|
||||
const isClickThrough = $config.mini_click_through;
|
||||
const threshold = $config.mini_hover_threshold;
|
||||
|
||||
if (!isClickThrough) {
|
||||
appWindow.setIgnoreCursorEvents(false);
|
||||
draggable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
appWindow.setIgnoreCursorEvents(true);
|
||||
draggable = false;
|
||||
let hoverTime = 0;
|
||||
let localDraggable = false;
|
||||
const POLL_MS = 200;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const [cx, cy] = await invoke<[number, number]>("get_cursor_position");
|
||||
const pos = await appWindow.outerPosition();
|
||||
const size = await appWindow.outerSize();
|
||||
|
||||
const inside =
|
||||
cx >= pos.x && cx <= pos.x + size.width &&
|
||||
cy >= pos.y && cy <= pos.y + size.height;
|
||||
|
||||
if (inside) {
|
||||
hoverTime += POLL_MS / 1000;
|
||||
if (hoverTime >= threshold && !localDraggable) {
|
||||
localDraggable = true;
|
||||
draggable = true;
|
||||
await appWindow.setIgnoreCursorEvents(false);
|
||||
}
|
||||
} else {
|
||||
if (localDraggable) {
|
||||
localDraggable = false;
|
||||
draggable = false;
|
||||
await appWindow.setIgnoreCursorEvents(true);
|
||||
}
|
||||
hoverTime = 0;
|
||||
}
|
||||
} catch {}
|
||||
}, POLL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
function updateFromSnapshot(snap: TimerSnapshot) {
|
||||
state = snap.state;
|
||||
if (snap.state === "breakActive") {
|
||||
timeText = formatTime(snap.breakTimeRemaining);
|
||||
progress = snap.breakTotalDuration > 0
|
||||
? snap.breakTimeRemaining / snap.breakTotalDuration
|
||||
: 0;
|
||||
} else {
|
||||
timeText = formatTime(snap.timeRemaining);
|
||||
progress = snap.progress;
|
||||
}
|
||||
}
|
||||
|
||||
// Click opens main window
|
||||
async function openMain() {
|
||||
try {
|
||||
await invoke("set_view", { view: "dashboard" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Drag support
|
||||
function startDrag(e: MouseEvent) {
|
||||
if (e.button === 0) {
|
||||
appWindow.startDragging();
|
||||
}
|
||||
}
|
||||
|
||||
// Ring SVG computations
|
||||
const ringSize = 34;
|
||||
const strokeW = 3;
|
||||
const pad = 16;
|
||||
const viewSize = ringSize + pad * 2;
|
||||
const radius = (ringSize - strokeW) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const dashOffset = $derived(circumference * (1 - progress));
|
||||
const ctr = viewSize / 2;
|
||||
|
||||
const activeColor = $derived(state === "breakActive" ? breakColor : accentColor);
|
||||
|
||||
function lighten(hex: string, amount: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const lr = Math.min(255, r + (255 - r) * amount);
|
||||
const lg = Math.min(255, g + (255 - g) * amount);
|
||||
const lb = Math.min(255, b + (255 - b) * amount);
|
||||
return `#${Math.round(lr).toString(16).padStart(2, "0")}${Math.round(lg).toString(16).padStart(2, "0")}${Math.round(lb).toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const gradA = $derived(activeColor);
|
||||
const gradB = $derived(lighten(activeColor, 0.25));
|
||||
|
||||
const fontStyle = $derived(
|
||||
countdownFont ? `font-family: '${countdownFont}', monospace;` : ""
|
||||
);
|
||||
|
||||
const zoomScale = $derived(uiZoom / 100);
|
||||
|
||||
// Base window dimensions (matches lib.rs toggle_mini_window)
|
||||
const MINI_BASE_W = 184;
|
||||
const MINI_BASE_H = 92;
|
||||
|
||||
// Resize the actual Tauri window when zoom changes
|
||||
$effect(() => {
|
||||
const scale = uiZoom / 100;
|
||||
const w = Math.round(MINI_BASE_W * scale);
|
||||
const h = Math.round(MINI_BASE_H * scale);
|
||||
appWindow.setSize(new LogicalSize(w, h));
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="w-full h-full flex items-center justify-center overflow-hidden">
|
||||
<div
|
||||
style="
|
||||
width: {100 / zoomScale}%;
|
||||
height: {100 / zoomScale}%;
|
||||
transform: scale({zoomScale});
|
||||
transform-origin: center center;
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-full h-full"
|
||||
style="padding: 22px 14px 22px 24px;"
|
||||
>
|
||||
<div
|
||||
class="mini-pill flex h-full w-full items-center select-none"
|
||||
class:mini-draggable={draggable}
|
||||
style="
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 9999px;
|
||||
border: 1px solid {draggable ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.08)'};
|
||||
backdrop-filter: blur(12px);
|
||||
cursor: {draggable ? 'grab' : 'default'};
|
||||
padding: 0 12px 0 5px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
{draggable ? `box-shadow: 0 0 12px rgba(255, 255, 255, 0.08);` : ''}
|
||||
"
|
||||
onmousedown={draggable ? startDrag : undefined}
|
||||
ondblclick={draggable ? openMain : undefined}
|
||||
>
|
||||
<!-- Mini ring with glow -->
|
||||
<div class="relative flex-shrink-0" style="width: {ringSize}px; height: {ringSize}px;">
|
||||
<!-- Glow SVG (larger for blur room) -->
|
||||
<svg
|
||||
width={viewSize}
|
||||
height={viewSize}
|
||||
class="pointer-events-none absolute"
|
||||
style="left: {-pad}px; top: {-pad}px; transform: rotate(-90deg); overflow: visible;"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="mini-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color={gradA} />
|
||||
<stop offset="100%" stop-color={gradB} />
|
||||
</linearGradient>
|
||||
<filter id="mini-glow-wide" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="8" />
|
||||
</filter>
|
||||
<filter id="mini-glow-mid" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur stdDeviation="4" />
|
||||
</filter>
|
||||
<filter id="mini-glow-core" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{#if progress > 0.002}
|
||||
<!-- Layer 1: Wide ambient bloom -->
|
||||
<circle
|
||||
cx={ctr} cy={ctr} r={radius}
|
||||
fill="none"
|
||||
stroke="url(#mini-grad)"
|
||||
stroke-width={strokeW * 4}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
filter="url(#mini-glow-wide)"
|
||||
opacity="0.35"
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
<!-- Layer 2: Medium glow -->
|
||||
<circle
|
||||
cx={ctr} cy={ctr} r={radius}
|
||||
fill="none"
|
||||
stroke="url(#mini-grad)"
|
||||
stroke-width={strokeW * 2}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
filter="url(#mini-glow-mid)"
|
||||
opacity="0.5"
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
<!-- Layer 3: Core tight glow -->
|
||||
<circle
|
||||
cx={ctr} cy={ctr} r={radius}
|
||||
fill="none"
|
||||
stroke="url(#mini-grad)"
|
||||
stroke-width={strokeW}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
filter="url(#mini-glow-core)"
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<!-- Non-glow SVG: track + crisp ring -->
|
||||
<svg
|
||||
width={ringSize}
|
||||
height={ringSize}
|
||||
class="absolute"
|
||||
style="transform: rotate(-90deg);"
|
||||
>
|
||||
<!-- Background track -->
|
||||
<circle
|
||||
cx={ringSize / 2} cy={ringSize / 2} r={radius}
|
||||
fill="none"
|
||||
stroke={state === "paused" ? "#1a1a1a" : "#161616"}
|
||||
stroke-width={strokeW}
|
||||
/>
|
||||
{#if progress > 0.002}
|
||||
<!-- Foreground arc -->
|
||||
<circle
|
||||
cx={ringSize / 2} cy={ringSize / 2} r={radius}
|
||||
fill="none"
|
||||
stroke="url(#mini-grad)"
|
||||
stroke-width={strokeW}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Countdown text -->
|
||||
<span
|
||||
class="ml-2.5 text-[18px] font-semibold leading-none tabular-nums"
|
||||
style="color: {state === 'paused' ? '#555' : '#fff'}; {fontStyle}"
|
||||
>
|
||||
{timeText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
801
src/lib/components/Settings.svelte
Normal file
801
src/lib/components/Settings.svelte
Normal file
@@ -0,0 +1,801 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { timer, currentView } from "../stores/timer";
|
||||
import { config, autoSave, loadConfig, resetConfig } from "../stores/config";
|
||||
import ToggleSwitch from "./ToggleSwitch.svelte";
|
||||
import Stepper from "./Stepper.svelte";
|
||||
import ColorPicker from "./ColorPicker.svelte";
|
||||
import FontSelector from "./FontSelector.svelte";
|
||||
import TimeSpinner from "./TimeSpinner.svelte";
|
||||
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
|
||||
import { playSound } from "../utils/sounds";
|
||||
import type { TimeRange } from "../stores/config";
|
||||
|
||||
const soundPresets = ["bell", "chime", "soft", "digital", "harp", "bowl", "rain", "whistle"] as const;
|
||||
const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
|
||||
|
||||
function goBack() {
|
||||
invoke("set_view", { view: "dashboard" });
|
||||
currentView.set("dashboard");
|
||||
}
|
||||
|
||||
function markChanged() {
|
||||
autoSave();
|
||||
}
|
||||
|
||||
// Working hours functions
|
||||
function toggleDayEnabled(dayIndex: number) {
|
||||
$config.working_hours_schedule[dayIndex].enabled = !$config.working_hours_schedule[dayIndex].enabled;
|
||||
$config.working_hours_schedule = $config.working_hours_schedule;
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function addTimeRange(dayIndex: number) {
|
||||
$config.working_hours_schedule[dayIndex].ranges = [
|
||||
...$config.working_hours_schedule[dayIndex].ranges,
|
||||
{ start: "09:00", end: "18:00" }
|
||||
];
|
||||
$config.working_hours_schedule = $config.working_hours_schedule;
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function removeTimeRange(dayIndex: number, rangeIndex: number) {
|
||||
if ($config.working_hours_schedule[dayIndex].ranges.length > 1) {
|
||||
$config.working_hours_schedule[dayIndex].ranges = $config.working_hours_schedule[dayIndex].ranges.filter((_, i) => i !== rangeIndex);
|
||||
$config.working_hours_schedule = $config.working_hours_schedule;
|
||||
markChanged();
|
||||
}
|
||||
}
|
||||
|
||||
function cloneTimeRange(dayIndex: number, rangeIndex: number) {
|
||||
const range = $config.working_hours_schedule[dayIndex].ranges[rangeIndex];
|
||||
$config.working_hours_schedule[dayIndex].ranges = [
|
||||
...$config.working_hours_schedule[dayIndex].ranges,
|
||||
{ ...range }
|
||||
];
|
||||
$config.working_hours_schedule = $config.working_hours_schedule;
|
||||
markChanged();
|
||||
}
|
||||
|
||||
function updateTimeRange(dayIndex: number, rangeIndex: number, field: "start" | "end", value: string) {
|
||||
$config.working_hours_schedule[dayIndex].ranges[rangeIndex][field] = value;
|
||||
$config.working_hours_schedule = $config.working_hours_schedule;
|
||||
markChanged();
|
||||
}
|
||||
|
||||
// Reset button two-click confirmation
|
||||
let resetConfirming = $state(false);
|
||||
let resetTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleReset() {
|
||||
if (!resetConfirming) {
|
||||
resetConfirming = true;
|
||||
// Auto-cancel after 3 seconds
|
||||
resetTimeout = setTimeout(() => {
|
||||
resetConfirming = false;
|
||||
}, 3000);
|
||||
} else {
|
||||
resetConfirming = false;
|
||||
if (resetTimeout) clearTimeout(resetTimeout);
|
||||
resetConfig();
|
||||
autoSave();
|
||||
}
|
||||
}
|
||||
|
||||
// Reload config when entering settings
|
||||
$effect(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Header -->
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="flex items-center px-5 pt-5 pb-4"
|
||||
use:fadeIn={{ duration: 0.4, y: 8 }}
|
||||
>
|
||||
<button
|
||||
aria-label="Back to dashboard"
|
||||
use:pressable
|
||||
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
|
||||
text-[#444] transition-colors hover:text-white"
|
||||
onclick={goBack}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1
|
||||
data-tauri-drag-region
|
||||
class="flex-1 text-lg font-medium text-white"
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content with drag scroll -->
|
||||
<div
|
||||
class="settings-scroll-container flex-1 overflow-y-auto px-5 pb-6"
|
||||
use:dragScroll
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Timer -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Timer
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Break frequency</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
Every {$config.break_frequency} min
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.break_frequency}
|
||||
min={5}
|
||||
max={120}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Break duration</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
{$config.break_duration} min
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.break_duration}
|
||||
min={1}
|
||||
max={60}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Auto-start</div>
|
||||
<div class="text-[11px] text-[#777]">Start timer on launch</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.auto_start}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Break Screen -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Break Screen
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[13px] text-white" for="break-title">
|
||||
Break title
|
||||
</label>
|
||||
<input
|
||||
id="break-title"
|
||||
type="text"
|
||||
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px]
|
||||
text-white outline-none transition-colors
|
||||
placeholder:text-[#2a2a2a] focus:border-[#333]"
|
||||
placeholder="Enter break title..."
|
||||
bind:value={$config.break_title}
|
||||
oninput={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[13px] text-white" for="break-message">
|
||||
Break message
|
||||
</label>
|
||||
<input
|
||||
id="break-message"
|
||||
type="text"
|
||||
class="rounded-xl border border-[#161616] bg-black px-3.5 py-2.5 text-[13px]
|
||||
text-white outline-none transition-colors
|
||||
placeholder:text-[#2a2a2a] focus:border-[#333]"
|
||||
placeholder="Enter break message..."
|
||||
bind:value={$config.break_message}
|
||||
oninput={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Fullscreen break</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
{$config.fullscreen_mode ? "Fills entire screen" : "Centered modal"}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.fullscreen_mode}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Activity suggestions</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
Exercise ideas during breaks
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.show_break_activities}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Behavior -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Behavior
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Strict mode</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
Disable skip and snooze
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.strict_mode}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !$config.strict_mode}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Allow end early</div>
|
||||
<div class="text-[11px] text-[#777]">After 50% of break</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.allow_end_early}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Snooze duration</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
{$config.snooze_duration} min
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.snooze_duration}
|
||||
min={1}
|
||||
max={30}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Snooze limit</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
{$config.snooze_limit === 0
|
||||
? "Unlimited"
|
||||
: `${$config.snooze_limit} per break`}
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.snooze_limit}
|
||||
min={0}
|
||||
max={5}
|
||||
formatValue={(v) => (v === 0 ? "\u221E" : String(v))}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Immediate breaks</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
Skip pre-break warning
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.immediately_start_breaks}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Working Hours -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.18 }}>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Working hours</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
Only show breaks during your configured work schedule
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.working_hours_enabled}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.working_hours_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
{#each $config.working_hours_schedule as daySchedule, dayIndex}
|
||||
{@const dayName = daysOfWeek[dayIndex]}
|
||||
<div class="mb-4">
|
||||
<!-- Day header with toggle -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.working_hours_schedule[dayIndex].enabled}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
<span class="text-[13px] text-white w-20">{dayName}</span>
|
||||
</div>
|
||||
|
||||
{#if daySchedule.enabled}
|
||||
<!-- Time ranges for this day -->
|
||||
<div class="space-y-2">
|
||||
{#each daySchedule.ranges as range, rangeIndex}
|
||||
<div class="flex items-center gap-2">
|
||||
<TimeSpinner
|
||||
value={range.start}
|
||||
countdownFont={$config.countdown_font}
|
||||
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "start", v)}
|
||||
/>
|
||||
<span class="text-[#555] text-[13px]">to</span>
|
||||
<TimeSpinner
|
||||
value={range.end}
|
||||
countdownFont={$config.countdown_font}
|
||||
onchange={(v) => updateTimeRange(dayIndex, rangeIndex, "end", v)}
|
||||
/>
|
||||
|
||||
<!-- Add range button -->
|
||||
{#if rangeIndex === daySchedule.ranges.length - 1}
|
||||
<button
|
||||
use:pressable
|
||||
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
|
||||
onclick={() => addTimeRange(dayIndex)}
|
||||
aria-label="Add time range"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Clone button -->
|
||||
<button
|
||||
use:pressable
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-white hover:bg-[#1a1a1a] transition-colors"
|
||||
onclick={() => cloneTimeRange(dayIndex, rangeIndex)}
|
||||
aria-label="Clone time range"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Delete button (never show for first range) -->
|
||||
{#if rangeIndex > 0}
|
||||
<button
|
||||
use:pressable
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-[#444] hover:text-[#f85149] hover:bg-[#f8514915] transition-colors"
|
||||
onclick={() => removeTimeRange(dayIndex, rangeIndex)}
|
||||
aria-label="Remove time range"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dayIndex < 6}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Idle Detection -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.21 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Idle Detection
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Auto-pause when idle</div>
|
||||
<div class="text-[11px] text-[#777]">Pause timer when away</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.idle_detection_enabled}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.idle_detection_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Idle timeout</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
{$config.idle_timeout}s of inactivity
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.idle_timeout}
|
||||
min={30}
|
||||
max={600}
|
||||
step={30}
|
||||
formatValue={(v) => `${v}s`}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Smart Breaks -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.24 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Smart Breaks
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Enable smart breaks</div>
|
||||
<div class="text-[11px] text-[#777]">Auto-reset timer when you step away</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.smart_breaks_enabled}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.smart_breaks_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Minimum away time</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
{$config.smart_break_threshold >= 60
|
||||
? `${Math.floor($config.smart_break_threshold / 60)} min`
|
||||
: `${$config.smart_break_threshold}s`} to count as break
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.smart_break_threshold}
|
||||
min={120}
|
||||
max={900}
|
||||
step={60}
|
||||
formatValue={(v) => `${Math.floor(v / 60)}m`}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Count in statistics</div>
|
||||
<div class="text-[11px] text-[#777]">Track natural breaks in stats</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.smart_break_count_stats}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Notifications
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Pre-break alert</div>
|
||||
<div class="text-[11px] text-[#777]">Warn before breaks</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.notification_enabled}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.notification_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Alert timing</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
{$config.notification_before_break}s before
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.notification_before_break}
|
||||
min={0}
|
||||
max={300}
|
||||
step={10}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Sound -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.27 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Sound
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Sound effects</div>
|
||||
<div class="text-[11px] text-[#777]">Play sounds on break events</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.sound_enabled}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.sound_enabled}
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Volume</div>
|
||||
<div class="text-[11px] text-[#777]">{$config.sound_volume}%</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.sound_volume}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
formatValue={(v) => `${v}%`}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div>
|
||||
<div class="mb-3 text-[13px] text-white">Sound preset</div>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{#each soundPresets as preset}
|
||||
<button
|
||||
use:pressable
|
||||
class="rounded-xl py-2.5 text-[11px] tracking-wider uppercase
|
||||
transition-all duration-200
|
||||
{$config.sound_preset === preset
|
||||
? 'bg-[#1a1a1a] text-white border border-[#333]'
|
||||
: 'bg-[#0a0a0a] text-[#555] border border-[#161616] hover:border-[#333] hover:text-[#999]'}"
|
||||
onclick={() => {
|
||||
$config.sound_preset = preset;
|
||||
markChanged();
|
||||
playSound(preset, $config.sound_volume);
|
||||
}}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Appearance -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.3 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Appearance
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">UI zoom</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
{$config.ui_zoom}%
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.ui_zoom}
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
formatValue={(v) => `${v}%`}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<ColorPicker
|
||||
label="Accent color"
|
||||
bind:value={$config.accent_color}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<ColorPicker
|
||||
label="Break screen color"
|
||||
bind:value={$config.break_color}
|
||||
presets={[
|
||||
"#7c6aef", "#9b5de5", "#4361ee", "#4895ef",
|
||||
"#2ec4b6", "#06d6a0", "#3fb950", "#80ed99",
|
||||
"#f72585", "#ff006e", "#e63946", "#ff4d00",
|
||||
"#fca311", "#ffbe0b", "#ffffff", "#888888",
|
||||
]}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<FontSelector
|
||||
bind:value={$config.countdown_font}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-[13px] text-white">Animated background</div>
|
||||
<div class="text-[11px] text-[#777]">
|
||||
Gradient blobs with film grain
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.background_blobs_enabled}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Mini Mode -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.33 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Mini Mode
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Click-through</div>
|
||||
<div class="text-[11px] text-[#555]">
|
||||
Mini timer ignores clicks until you hover over it
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
bind:checked={$config.mini_click_through}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if $config.mini_click_through}
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Hover delay</div>
|
||||
<div class="text-[11px] text-[#555]">
|
||||
Seconds to hover before it becomes draggable
|
||||
</div>
|
||||
</div>
|
||||
<Stepper
|
||||
bind:value={$config.mini_hover_threshold}
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
formatValue={(v) => `${v}s`}
|
||||
onchange={markChanged}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Shortcuts -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.36 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Keyboard Shortcuts
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[13px] text-white">Pause / Resume</span>
|
||||
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+P</kbd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[13px] text-white">Start break now</span>
|
||||
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+B</kbd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[13px] text-white">Show / Hide window</span>
|
||||
<kbd class="rounded-lg bg-[#161616] px-2.5 py-1 text-[11px] text-[#666]">Ctrl+Shift+S</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reset -->
|
||||
<div class="pt-2 pb-6" use:inView={{ delay: 0.39 }}>
|
||||
<button
|
||||
use:pressable
|
||||
class="w-full rounded-full border py-3 text-[12px]
|
||||
tracking-wider uppercase
|
||||
transition-all duration-200
|
||||
{resetConfirming
|
||||
? 'border-[#f85149] text-[#f85149] hover:bg-[#f85149] hover:text-white'
|
||||
: 'border-[#1a1a1a] text-[#444] hover:border-[#333] hover:text-white'}"
|
||||
onclick={handleReset}
|
||||
>
|
||||
{resetConfirming ? "Tap again to confirm reset" : "Reset to defaults"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
274
src/lib/components/StatsView.svelte
Normal file
274
src/lib/components/StatsView.svelte
Normal file
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { currentView } from "../stores/timer";
|
||||
import { config } from "../stores/config";
|
||||
import { fadeIn, inView, pressable, dragScroll } from "../utils/animate";
|
||||
|
||||
interface StatsSnapshot {
|
||||
todayCompleted: number;
|
||||
todaySkipped: number;
|
||||
todaySnoozed: number;
|
||||
todayBreakTimeSecs: number;
|
||||
complianceRate: number;
|
||||
currentStreak: number;
|
||||
bestStreak: number;
|
||||
}
|
||||
|
||||
interface DayRecord {
|
||||
date: string;
|
||||
breaksCompleted: number;
|
||||
breaksSkipped: number;
|
||||
breaksSnoozed: number;
|
||||
totalBreakTimeSecs: number;
|
||||
}
|
||||
|
||||
let stats = $state<StatsSnapshot | null>(null);
|
||||
let history = $state<DayRecord[]>([]);
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await invoke<StatsSnapshot>("get_stats");
|
||||
history = await invoke<DayRecord[]>("get_daily_history", { days: 7 });
|
||||
} catch (e) {
|
||||
console.error("Failed to load stats:", e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
invoke("set_view", { view: "dashboard" });
|
||||
currentView.set("dashboard");
|
||||
}
|
||||
|
||||
const compliancePercent = $derived(
|
||||
stats ? Math.round(stats.complianceRate * 100) : 100,
|
||||
);
|
||||
|
||||
const breakTimeFormatted = $derived(() => {
|
||||
if (!stats) return "0 min";
|
||||
const mins = Math.floor(stats.todayBreakTimeSecs / 60);
|
||||
if (mins < 60) return `${mins} min`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const rem = mins % 60;
|
||||
return `${hrs}h ${rem}m`;
|
||||
});
|
||||
|
||||
// Chart rendering
|
||||
let chartCanvas: HTMLCanvasElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (!chartCanvas || history.length === 0) return;
|
||||
drawChart(chartCanvas, history);
|
||||
});
|
||||
|
||||
function drawChart(canvas: HTMLCanvasElement, data: DayRecord[]) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const maxBreaks = Math.max(1, ...data.map((d) => d.breaksCompleted + d.breaksSkipped));
|
||||
const barWidth = Math.floor((w - 40) / data.length) - 8;
|
||||
const barGap = 8;
|
||||
const chartHeight = h - 30;
|
||||
|
||||
const accentColor = $config.accent_color || "#ff4d00";
|
||||
|
||||
data.forEach((day, i) => {
|
||||
const x = 20 + i * (barWidth + barGap);
|
||||
const total = day.breaksCompleted + day.breaksSkipped;
|
||||
const completedH = total > 0 ? (day.breaksCompleted / maxBreaks) * chartHeight : 0;
|
||||
const skippedH = total > 0 ? (day.breaksSkipped / maxBreaks) * chartHeight : 0;
|
||||
|
||||
// Completed bar
|
||||
if (completedH > 0) {
|
||||
ctx.fillStyle = accentColor;
|
||||
ctx.beginPath();
|
||||
const barY = chartHeight - completedH;
|
||||
roundedRect(ctx, x, barY, barWidth, completedH, 4);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Skipped bar (stacked on top)
|
||||
if (skippedH > 0) {
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.beginPath();
|
||||
const barY = chartHeight - completedH - skippedH;
|
||||
roundedRect(ctx, x, barY, barWidth, skippedH, 4);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Day label
|
||||
ctx.fillStyle = "#444";
|
||||
ctx.font = "10px -apple-system, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
const label = day.date.slice(5); // "MM-DD"
|
||||
ctx.fillText(label, x + barWidth / 2, h - 5);
|
||||
});
|
||||
}
|
||||
|
||||
function roundedRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number,
|
||||
) {
|
||||
r = Math.min(r, h / 2, w / 2);
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Header -->
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="flex items-center px-5 pt-5 pb-4"
|
||||
use:fadeIn={{ duration: 0.4, y: 8 }}
|
||||
>
|
||||
<button
|
||||
aria-label="Back to dashboard"
|
||||
use:pressable
|
||||
class="mr-3 flex h-8 w-8 items-center justify-center rounded-full
|
||||
text-[#444] transition-colors hover:text-white"
|
||||
onclick={goBack}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1
|
||||
data-tauri-drag-region
|
||||
class="flex-1 text-lg font-medium text-white"
|
||||
>
|
||||
Statistics
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto px-5 pb-6" use:dragScroll>
|
||||
<div class="space-y-3">
|
||||
<!-- Today's summary -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Today
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||
{stats?.todayCompleted ?? 0}
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Breaks taken</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold tabular-nums"
|
||||
style="color: {$config.accent_color}"
|
||||
>
|
||||
{compliancePercent}%
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Compliance</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||
{breakTimeFormatted()}
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Break time</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-[28px] font-semibold text-white tabular-nums">
|
||||
{stats?.todaySkipped ?? 0}
|
||||
</div>
|
||||
<div class="text-[11px] text-[#777]">Skipped</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Streak -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.06 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Streak
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Current streak</div>
|
||||
<div class="text-[11px] text-[#777]">Consecutive days with breaks</div>
|
||||
</div>
|
||||
<div class="text-[24px] font-semibold tabular-nums" style="color: {$config.accent_color}">
|
||||
{stats?.currentStreak ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4 h-px bg-[#161616]"></div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-[13px] text-white">Best streak</div>
|
||||
<div class="text-[11px] text-[#777]">All-time record</div>
|
||||
</div>
|
||||
<div class="text-[24px] font-semibold text-white tabular-nums">
|
||||
{stats?.bestStreak ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Weekly chart -->
|
||||
<section class="rounded-2xl p-5 backdrop-blur-xl" style="background: rgba(17,17,17,0.7);" use:inView={{ delay: 0.12 }}>
|
||||
<h3
|
||||
class="mb-4 text-[11px] font-medium tracking-[0.15em] text-[#666] uppercase"
|
||||
>
|
||||
Last 7 Days
|
||||
</h3>
|
||||
|
||||
<canvas
|
||||
bind:this={chartCanvas}
|
||||
class="h-[140px] w-full"
|
||||
></canvas>
|
||||
|
||||
<div class="mt-3 flex items-center justify-center gap-4 text-[10px] text-[#777]">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-2 w-2 rounded-sm" style="background: {$config.accent_color}"></div>
|
||||
Completed
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-2 w-2 rounded-sm bg-[#333]"></div>
|
||||
Skipped
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
88
src/lib/components/Stepper.svelte
Normal file
88
src/lib/components/Stepper.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
formatValue?: (v: number) => string;
|
||||
onchange?: (value: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
formatValue = (v: number) => String(v),
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
let holdTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let holdInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function decrement() {
|
||||
if (value > min) {
|
||||
value = Math.max(min, value - step);
|
||||
onchange?.(value);
|
||||
}
|
||||
}
|
||||
|
||||
function increment() {
|
||||
if (value < max) {
|
||||
value = Math.min(max, value + step);
|
||||
onchange?.(value);
|
||||
}
|
||||
}
|
||||
|
||||
function startHold(fn: () => void) {
|
||||
fn();
|
||||
// Initial delay before repeating
|
||||
holdTimer = setTimeout(() => {
|
||||
// Start repeating, accelerate over time
|
||||
let delay = 150;
|
||||
function tick() {
|
||||
fn();
|
||||
delay = Math.max(40, delay * 0.85);
|
||||
holdInterval = setTimeout(tick, delay) as unknown as ReturnType<typeof setInterval>;
|
||||
}
|
||||
tick();
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function stopHold() {
|
||||
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
|
||||
if (holdInterval) { clearTimeout(holdInterval as unknown as ReturnType<typeof setTimeout>); holdInterval = null; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg
|
||||
bg-[#141414] text-[#444] transition-colors
|
||||
hover:bg-[#1c1c1c] hover:text-white
|
||||
disabled:opacity-20"
|
||||
onmousedown={() => startHold(decrement)}
|
||||
onmouseup={stopHold}
|
||||
onmouseleave={stopHold}
|
||||
disabled={value <= min}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="min-w-[38px] text-center text-[13px] tabular-nums text-white">
|
||||
{formatValue(value)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg
|
||||
bg-[#141414] text-[#444] transition-colors
|
||||
hover:bg-[#1c1c1c] hover:text-white
|
||||
disabled:opacity-20"
|
||||
onmousedown={() => startHold(increment)}
|
||||
onmouseup={stopHold}
|
||||
onmouseleave={stopHold}
|
||||
disabled={value >= max}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
399
src/lib/components/TimeSpinner.svelte
Normal file
399
src/lib/components/TimeSpinner.svelte
Normal file
@@ -0,0 +1,399 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
onchange?: (value: string) => void;
|
||||
countdownFont?: string;
|
||||
}
|
||||
|
||||
let { value, onchange, countdownFont = "" }: Props = $props();
|
||||
|
||||
// Local display values — driven by prop normally, overridden during drag/momentum
|
||||
let displayHours = $state(0);
|
||||
let displayMinutes = $state(0);
|
||||
let isAnimating = $state(false); // true during drag OR momentum
|
||||
|
||||
// Fractional offset for smooth wheel rotation (0 to <1)
|
||||
let hoursFraction = $state(0);
|
||||
let minutesFraction = $state(0);
|
||||
|
||||
// Sync display from prop when NOT dragging/animating
|
||||
$effect(() => {
|
||||
if (!isAnimating) {
|
||||
displayHours = parseInt(value.split(":")[0]) || 0;
|
||||
displayMinutes = parseInt(value.split(":")[1]) || 0;
|
||||
hoursFraction = 0;
|
||||
minutesFraction = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Drag state ──
|
||||
|
||||
let hoursDragging = $state(false);
|
||||
let hoursLastY = $state(0);
|
||||
let hoursAccumPx = $state(0);
|
||||
let hoursBaseValue = $state(0);
|
||||
let hoursVelocity = $state(0);
|
||||
let hoursLastMoveTime = $state(0);
|
||||
|
||||
let minutesDragging = $state(false);
|
||||
let minutesLastY = $state(0);
|
||||
let minutesAccumPx = $state(0);
|
||||
let minutesBaseValue = $state(0);
|
||||
let minutesVelocity = $state(0);
|
||||
let minutesLastMoveTime = $state(0);
|
||||
|
||||
// Momentum animation handle
|
||||
let momentumRaf: number | null = null;
|
||||
|
||||
const SENSITIVITY = 20; // pixels per value step
|
||||
const ITEM_ANGLE = 30; // degrees between items on the cylinder
|
||||
const WHEEL_RADIUS = 26; // cylinder radius in px
|
||||
const FRICTION = 0.93; // momentum decay per frame
|
||||
const MIN_VELOCITY = 0.3; // px/frame threshold to stop momentum
|
||||
|
||||
function wrapValue(v: number, max: number): number {
|
||||
return ((v % max) + max) % max;
|
||||
}
|
||||
|
||||
function emitValue(h: number, m: number) {
|
||||
onchange?.(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
function stopMomentum() {
|
||||
if (momentumRaf !== null) {
|
||||
cancelAnimationFrame(momentumRaf);
|
||||
momentumRaf = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the visible items for the 3D wheel
|
||||
function getWheelItems(current: number, fraction: number, maxVal: number) {
|
||||
const items = [];
|
||||
for (let i = -2; i <= 2; i++) {
|
||||
const val = wrapValue(current + i, maxVal);
|
||||
const angle = (i - fraction) * ITEM_ANGLE;
|
||||
const absAngle = Math.abs(angle);
|
||||
// Center item bright, off-center very dim
|
||||
const isCenter = absAngle < ITEM_ANGLE * 0.5;
|
||||
const opacity = isCenter
|
||||
? Math.max(0.7, 1 - absAngle / 60)
|
||||
: Math.max(0.05, 0.35 - absAngle / 120);
|
||||
items.push({ value: val, angle, opacity });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const hoursItems = $derived(getWheelItems(displayHours, hoursFraction, 24));
|
||||
const minutesItems = $derived(getWheelItems(displayMinutes, minutesFraction, 60));
|
||||
|
||||
// ── Shared step logic (used by both drag and momentum) ──
|
||||
|
||||
function applyAccum(
|
||||
field: "hours" | "minutes",
|
||||
accumPx: number,
|
||||
baseValue: number,
|
||||
): { newVal: number; fraction: number } {
|
||||
const totalSteps = accumPx / SENSITIVITY;
|
||||
const wholeSteps = Math.round(totalSteps);
|
||||
const fraction = totalSteps - wholeSteps;
|
||||
const maxVal = field === "hours" ? 24 : 60;
|
||||
const newVal = wrapValue(baseValue + wholeSteps, maxVal);
|
||||
return { newVal, fraction };
|
||||
}
|
||||
|
||||
// ── Hours pointer handlers ──
|
||||
|
||||
function handleHoursPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopMomentum();
|
||||
hoursDragging = true;
|
||||
isAnimating = true;
|
||||
hoursLastY = e.clientY;
|
||||
hoursAccumPx = 0;
|
||||
hoursBaseValue = displayHours;
|
||||
hoursVelocity = 0;
|
||||
hoursLastMoveTime = performance.now();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function handleHoursPointerMove(e: PointerEvent) {
|
||||
if (!hoursDragging) return;
|
||||
e.preventDefault();
|
||||
const dy = hoursLastY - e.clientY;
|
||||
hoursAccumPx += dy;
|
||||
hoursLastY = e.clientY;
|
||||
|
||||
// Track velocity (px/ms)
|
||||
const now = performance.now();
|
||||
const dt = now - hoursLastMoveTime;
|
||||
if (dt > 0) hoursVelocity = dy / dt;
|
||||
hoursLastMoveTime = now;
|
||||
|
||||
const { newVal, fraction } = applyAccum("hours", hoursAccumPx, hoursBaseValue);
|
||||
hoursFraction = fraction;
|
||||
if (newVal !== displayHours) {
|
||||
displayHours = newVal;
|
||||
emitValue(newVal, displayMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHoursPointerUp(e: PointerEvent) {
|
||||
if (!hoursDragging) return;
|
||||
e.preventDefault();
|
||||
hoursDragging = false;
|
||||
|
||||
// Launch momentum if velocity is significant
|
||||
const velocityPxPerFrame = hoursVelocity * 16; // convert px/ms to px/frame (~16ms)
|
||||
if (Math.abs(velocityPxPerFrame) > MIN_VELOCITY) {
|
||||
startMomentum("hours", velocityPxPerFrame);
|
||||
} else {
|
||||
hoursFraction = 0;
|
||||
isAnimating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minutes pointer handlers ──
|
||||
|
||||
function handleMinutesPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopMomentum();
|
||||
minutesDragging = true;
|
||||
isAnimating = true;
|
||||
minutesLastY = e.clientY;
|
||||
minutesAccumPx = 0;
|
||||
minutesBaseValue = displayMinutes;
|
||||
minutesVelocity = 0;
|
||||
minutesLastMoveTime = performance.now();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function handleMinutesPointerMove(e: PointerEvent) {
|
||||
if (!minutesDragging) return;
|
||||
e.preventDefault();
|
||||
const dy = minutesLastY - e.clientY;
|
||||
minutesAccumPx += dy;
|
||||
minutesLastY = e.clientY;
|
||||
|
||||
const now = performance.now();
|
||||
const dt = now - minutesLastMoveTime;
|
||||
if (dt > 0) minutesVelocity = dy / dt;
|
||||
minutesLastMoveTime = now;
|
||||
|
||||
const { newVal, fraction } = applyAccum("minutes", minutesAccumPx, minutesBaseValue);
|
||||
minutesFraction = fraction;
|
||||
if (newVal !== displayMinutes) {
|
||||
displayMinutes = newVal;
|
||||
emitValue(displayHours, newVal);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMinutesPointerUp(e: PointerEvent) {
|
||||
if (!minutesDragging) return;
|
||||
e.preventDefault();
|
||||
minutesDragging = false;
|
||||
|
||||
const velocityPxPerFrame = minutesVelocity * 16;
|
||||
if (Math.abs(velocityPxPerFrame) > MIN_VELOCITY) {
|
||||
startMomentum("minutes", velocityPxPerFrame);
|
||||
} else {
|
||||
minutesFraction = 0;
|
||||
isAnimating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Momentum animation ──
|
||||
|
||||
function startMomentum(field: "hours" | "minutes", velocity: number) {
|
||||
stopMomentum();
|
||||
|
||||
function tick() {
|
||||
velocity *= FRICTION;
|
||||
|
||||
if (Math.abs(velocity) < MIN_VELOCITY) {
|
||||
// Snap to nearest value
|
||||
if (field === "hours") hoursFraction = 0;
|
||||
else minutesFraction = 0;
|
||||
isAnimating = false;
|
||||
momentumRaf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === "hours") {
|
||||
hoursAccumPx += velocity;
|
||||
const { newVal, fraction } = applyAccum("hours", hoursAccumPx, hoursBaseValue);
|
||||
hoursFraction = fraction;
|
||||
if (newVal !== displayHours) {
|
||||
displayHours = newVal;
|
||||
emitValue(newVal, displayMinutes);
|
||||
}
|
||||
} else {
|
||||
minutesAccumPx += velocity;
|
||||
const { newVal, fraction } = applyAccum("minutes", minutesAccumPx, minutesBaseValue);
|
||||
minutesFraction = fraction;
|
||||
if (newVal !== displayMinutes) {
|
||||
displayMinutes = newVal;
|
||||
emitValue(displayHours, newVal);
|
||||
}
|
||||
}
|
||||
|
||||
momentumRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
momentumRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function format(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="time-spinner" style="font-family: {countdownFont ? `'${countdownFont}', monospace` : 'monospace'};">
|
||||
|
||||
<!-- Hours wheel -->
|
||||
<div
|
||||
class="wheel-field"
|
||||
class:dragging={hoursDragging}
|
||||
role="slider"
|
||||
aria-label="Hours"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={23}
|
||||
aria-valuenow={displayHours}
|
||||
tabindex={0}
|
||||
onpointerdown={handleHoursPointerDown}
|
||||
onpointermove={handleHoursPointerMove}
|
||||
onpointerup={handleHoursPointerUp}
|
||||
onpointercancel={handleHoursPointerUp}
|
||||
>
|
||||
<div class="wheel-viewport">
|
||||
<div class="wheel-cylinder">
|
||||
{#each hoursItems as item}
|
||||
<div
|
||||
class="wheel-item"
|
||||
style="
|
||||
transform: translateY(-50%) rotateX({-item.angle}deg) translateZ({WHEEL_RADIUS}px);
|
||||
opacity: {item.opacity};
|
||||
"
|
||||
>
|
||||
{format(item.value)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<span class="unit-badge">h</span>
|
||||
</div>
|
||||
|
||||
<span class="separator">:</span>
|
||||
|
||||
<!-- Minutes wheel -->
|
||||
<div
|
||||
class="wheel-field"
|
||||
class:dragging={minutesDragging}
|
||||
role="slider"
|
||||
aria-label="Minutes"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={59}
|
||||
aria-valuenow={displayMinutes}
|
||||
tabindex={0}
|
||||
onpointerdown={handleMinutesPointerDown}
|
||||
onpointermove={handleMinutesPointerMove}
|
||||
onpointerup={handleMinutesPointerUp}
|
||||
onpointercancel={handleMinutesPointerUp}
|
||||
>
|
||||
<div class="wheel-viewport">
|
||||
<div class="wheel-cylinder">
|
||||
{#each minutesItems as item}
|
||||
<div
|
||||
class="wheel-item"
|
||||
style="
|
||||
transform: translateY(-50%) rotateX({-item.angle}deg) translateZ({WHEEL_RADIUS}px);
|
||||
opacity: {item.opacity};
|
||||
"
|
||||
>
|
||||
{format(item.value)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<span class="unit-badge">m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.wheel-field {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
cursor: ns-resize;
|
||||
touch-action: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wheel-field.dragging {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Perspective container — looking into the cylinder from outside */
|
||||
.wheel-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
perspective: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The 3D cylinder that holds number items */
|
||||
.wheel-cylinder {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Individual number on the cylinder surface */
|
||||
.wheel-item {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1;
|
||||
backface-visibility: hidden;
|
||||
pointer-events: none;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
/* Unit label pinned to the right of the field */
|
||||
.unit-badge {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
177
src/lib/components/TimerRing.svelte
Normal file
177
src/lib/components/TimerRing.svelte
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
progress: number;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
accentColor?: string;
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
progress,
|
||||
size = 280,
|
||||
strokeWidth = 8,
|
||||
accentColor = "#ff4d00",
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const padding = 40;
|
||||
const viewSize = $derived(size + padding * 2);
|
||||
const radius = $derived((size - strokeWidth) / 2);
|
||||
const circumference = $derived(2 * Math.PI * radius);
|
||||
const dashOffset = $derived(circumference * (1 - progress));
|
||||
const center = $derived(viewSize / 2);
|
||||
|
||||
// Unique filter IDs based on color to avoid SVG collisions
|
||||
const fid = $derived(`ring-${size}-${accentColor.replace('#', '')}`);
|
||||
|
||||
// Derive lighter shade for gradient end
|
||||
function lighten(hex: string, amount: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const lr = Math.min(255, r + (255 - r) * amount);
|
||||
const lg = Math.min(255, g + (255 - g) * amount);
|
||||
const lb = Math.min(255, b + (255 - b) * amount);
|
||||
return `#${Math.round(lr).toString(16).padStart(2, '0')}${Math.round(lg).toString(16).padStart(2, '0')}${Math.round(lb).toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const gradA = $derived(accentColor);
|
||||
const gradB = $derived(lighten(accentColor, 0.2));
|
||||
const gradC = $derived(lighten(accentColor, 0.4));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex items-center justify-center"
|
||||
style="width: {size}px; height: {size}px;"
|
||||
>
|
||||
<!-- Glow SVG — drawn larger than the container so blur isn't clipped -->
|
||||
<svg
|
||||
width={viewSize}
|
||||
height={viewSize}
|
||||
class="pointer-events-none absolute"
|
||||
style="
|
||||
left: {-padding}px;
|
||||
top: {-padding}px;
|
||||
transform: rotate(-90deg);
|
||||
overflow: visible;
|
||||
"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="ring-grad-{fid}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color={gradA} />
|
||||
<stop offset="50%" stop-color={gradB} />
|
||||
<stop offset="100%" stop-color={gradC} />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Wide ambient glow filter -->
|
||||
<filter id="glow-wide-{fid}" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="28" />
|
||||
</filter>
|
||||
|
||||
<!-- Medium glow filter -->
|
||||
<filter id="glow-mid-{fid}" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur stdDeviation="12" />
|
||||
</filter>
|
||||
|
||||
<!-- Core tight glow filter -->
|
||||
<filter id="glow-core-{fid}" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{#if progress > 0.002}
|
||||
<!-- Layer 1: Wide ambient bloom -->
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="url(#ring-grad-{fid})"
|
||||
stroke-width={strokeWidth * 4}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
filter="url(#glow-wide-{fid})"
|
||||
opacity="0.35"
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
|
||||
<!-- Layer 2: Medium glow -->
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="url(#ring-grad-{fid})"
|
||||
stroke-width={strokeWidth * 2}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
filter="url(#glow-mid-{fid})"
|
||||
opacity="0.5"
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
|
||||
<!-- Layer 3: Core ring with tight glow -->
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="url(#ring-grad-{fid})"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
filter="url(#glow-core-{fid})"
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<!-- Non-glow SVG — exact size, draws the track + crisp ring -->
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
class="absolute"
|
||||
style="transform: rotate(-90deg);"
|
||||
>
|
||||
<!-- Background track -->
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#161616"
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
|
||||
{#if progress > 0.002}
|
||||
<!-- Sharp foreground ring (no filter) -->
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="url(#ring-grad-{fid})"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
class="transition-[stroke-dashoffset] duration-1000 ease-linear"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<!-- Content overlay -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
83
src/lib/components/Titlebar.svelte
Normal file
83
src/lib/components/Titlebar.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
const appWindow = getCurrentWebviewWindow();
|
||||
</script>
|
||||
|
||||
<!-- Invisible drag region – traffic lights on the right -->
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
class="group absolute top-0 left-0 right-0 z-50 flex h-10 items-center justify-end pr-3.5 select-none"
|
||||
>
|
||||
<!-- Centered app name -->
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center
|
||||
text-[11px] font-semibold tracking-[0.3em] text-[#383838] uppercase"
|
||||
style="font-family: 'Space Mono', monospace;"
|
||||
>
|
||||
Core Cooldown
|
||||
</span>
|
||||
|
||||
<!-- Traffic light buttons (order: maximize, minimize, close → close is rightmost) -->
|
||||
<div class="flex items-center gap-[8px] opacity-10 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<!-- Maximize (green) -->
|
||||
<button
|
||||
aria-label="Maximize"
|
||||
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
|
||||
rounded-full bg-[#27C93F] transition-all duration-150
|
||||
hover:brightness-110"
|
||||
onclick={() => appWindow.toggleMaximize()}
|
||||
>
|
||||
<svg
|
||||
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
fill="none"
|
||||
>
|
||||
<polyline points="1,5 1,7 3,7" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||
<polyline points="7,3 7,1 5,1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||
<line x1="1" y1="7" x2="7" y2="1" stroke="#006500" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Minimize (yellow) -->
|
||||
<button
|
||||
aria-label="Minimize"
|
||||
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
|
||||
rounded-full bg-[#FFBD2E] transition-all duration-150
|
||||
hover:brightness-110"
|
||||
onclick={() => appWindow.minimize()}
|
||||
>
|
||||
<svg
|
||||
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
||||
width="8"
|
||||
height="2"
|
||||
viewBox="0 0 8 2"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="1" y1="1" x2="7" y2="1" stroke="#995700" stroke-width="1.3" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Close (red) — rightmost -->
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="traffic-btn group/btn relative flex h-[15px] w-[15px] items-center justify-center
|
||||
rounded-full bg-[#FF5F57] transition-all duration-150
|
||||
hover:brightness-110"
|
||||
onclick={() => appWindow.close()}
|
||||
>
|
||||
<svg
|
||||
class="absolute opacity-0 transition-opacity duration-150 group-hover/btn:opacity-100"
|
||||
width="8"
|
||||
height="8"
|
||||
viewBox="0 0 8 8"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="1.5" y1="1.5" x2="6.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
|
||||
<line x1="6.5" y1="1.5" x2="1.5" y2="6.5" stroke="#4a0002" stroke-width="1.3" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
32
src/lib/components/ToggleSwitch.svelte
Normal file
32
src/lib/components/ToggleSwitch.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { config } from "../stores/config";
|
||||
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
onchange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
let { checked = $bindable(), onchange }: Props = $props();
|
||||
|
||||
function toggle() {
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label="Toggle"
|
||||
aria-checked={checked}
|
||||
class="relative inline-flex h-[24px] w-[48px] shrink-0 cursor-pointer rounded-full
|
||||
transition-colors duration-200 ease-in-out focus:outline-none"
|
||||
style="background: {checked ? $config.accent_color : '#1a1a1a'};"
|
||||
onclick={toggle}
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-[19px] w-[19px] rounded-full
|
||||
shadow-sm transition-transform duration-200 ease-in-out
|
||||
{checked ? 'translate-x-[26px] bg-white' : 'translate-x-[3px] bg-[#444]'} mt-[2.5px]"
|
||||
></span>
|
||||
</button>
|
||||
149
src/lib/stores/config.ts
Normal file
149
src/lib/stores/config.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface TimeRange {
|
||||
start: string; // Format: "HH:MM"
|
||||
end: string; // Format: "HH:MM"
|
||||
}
|
||||
|
||||
export interface DaySchedule {
|
||||
enabled: boolean;
|
||||
ranges: TimeRange[];
|
||||
}
|
||||
|
||||
export type { TimeRange, DaySchedule };
|
||||
|
||||
export interface Config {
|
||||
break_duration: number;
|
||||
break_frequency: number;
|
||||
auto_start: boolean;
|
||||
break_title: string;
|
||||
break_message: string;
|
||||
fullscreen_mode: boolean;
|
||||
strict_mode: boolean;
|
||||
allow_end_early: boolean;
|
||||
immediately_start_breaks: boolean;
|
||||
working_hours_enabled: boolean;
|
||||
working_hours_schedule: DaySchedule[]; // 7 days: Mon, Tue, Wed, Thu, Fri, Sat, Sun
|
||||
dark_mode: boolean;
|
||||
color_scheme: string;
|
||||
backdrop_opacity: number;
|
||||
notification_enabled: boolean;
|
||||
notification_before_break: number;
|
||||
snooze_duration: number;
|
||||
snooze_limit: number;
|
||||
skip_cooldown: number;
|
||||
sound_enabled: boolean;
|
||||
sound_volume: number;
|
||||
sound_preset: string;
|
||||
idle_detection_enabled: boolean;
|
||||
idle_timeout: number;
|
||||
smart_breaks_enabled: boolean;
|
||||
smart_break_threshold: number;
|
||||
smart_break_count_stats: boolean;
|
||||
show_break_activities: boolean;
|
||||
ui_zoom: number;
|
||||
accent_color: string;
|
||||
break_color: string;
|
||||
countdown_font: string;
|
||||
background_blobs_enabled: boolean;
|
||||
mini_click_through: boolean;
|
||||
mini_hover_threshold: number;
|
||||
}
|
||||
|
||||
const defaultConfig: Config = {
|
||||
break_duration: 5,
|
||||
break_frequency: 25,
|
||||
auto_start: true,
|
||||
break_title: "Rest your eyes",
|
||||
break_message: "Look away from the screen. Stretch and relax.",
|
||||
fullscreen_mode: true,
|
||||
strict_mode: false,
|
||||
allow_end_early: true,
|
||||
immediately_start_breaks: false,
|
||||
working_hours_enabled: false,
|
||||
working_hours_schedule: [
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Monday
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Tuesday
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Wednesday
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Thursday
|
||||
{ enabled: true, ranges: [{ start: "09:00", end: "18:00" }] }, // Friday
|
||||
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Saturday
|
||||
{ enabled: false, ranges: [{ start: "09:00", end: "18:00" }] }, // Sunday
|
||||
],
|
||||
dark_mode: true,
|
||||
color_scheme: "Ocean",
|
||||
backdrop_opacity: 0.92,
|
||||
notification_enabled: true,
|
||||
notification_before_break: 30,
|
||||
snooze_duration: 5,
|
||||
snooze_limit: 3,
|
||||
skip_cooldown: 60,
|
||||
sound_enabled: true,
|
||||
sound_volume: 70,
|
||||
sound_preset: "bell",
|
||||
idle_detection_enabled: true,
|
||||
idle_timeout: 120,
|
||||
smart_breaks_enabled: true,
|
||||
smart_break_threshold: 300,
|
||||
smart_break_count_stats: false,
|
||||
show_break_activities: true,
|
||||
ui_zoom: 100,
|
||||
accent_color: "#ff4d00",
|
||||
break_color: "#7c6aef",
|
||||
countdown_font: "",
|
||||
background_blobs_enabled: true,
|
||||
mini_click_through: true,
|
||||
mini_hover_threshold: 3.0,
|
||||
};
|
||||
|
||||
export const config = writable<Config>(defaultConfig);
|
||||
|
||||
export async function loadConfig() {
|
||||
try {
|
||||
const cfg = await invoke<Config>("get_config");
|
||||
config.set(cfg);
|
||||
} catch (e) {
|
||||
console.error("Failed to load config:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(): Promise<boolean> {
|
||||
try {
|
||||
const cfg = get(config);
|
||||
await invoke("save_config", { config: cfg });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Failed to save config:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyConfigChanged() {
|
||||
try {
|
||||
const cfg = get(config);
|
||||
await invoke("update_pending_config", { config: cfg });
|
||||
} catch (e) {
|
||||
console.error("Failed to update pending config:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetConfig() {
|
||||
try {
|
||||
const cfg = await invoke<Config>("reset_config");
|
||||
config.set(cfg);
|
||||
} catch (e) {
|
||||
console.error("Failed to reset config:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced auto-save: updates backend immediately, persists to disk after 400ms idle
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function autoSave() {
|
||||
notifyConfigChanged();
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveConfig();
|
||||
}, 400);
|
||||
}
|
||||
133
src/lib/stores/timer.ts
Normal file
133
src/lib/stores/timer.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { playSound, playBreakEndSound } from "../utils/sounds";
|
||||
import { config } from "./config";
|
||||
|
||||
export interface TimerSnapshot {
|
||||
state: "running" | "paused" | "breakActive";
|
||||
currentView: "dashboard" | "breakScreen" | "settings" | "stats";
|
||||
timeRemaining: number;
|
||||
totalDuration: number;
|
||||
progress: number;
|
||||
hasHadBreak: boolean;
|
||||
secondsSinceLastBreak: number;
|
||||
prebreakWarning: boolean;
|
||||
snoozesUsed: number;
|
||||
canSnooze: boolean;
|
||||
breakTitle: string;
|
||||
breakMessage: string;
|
||||
breakProgress: number;
|
||||
breakTimeRemaining: number;
|
||||
breakTotalDuration: number;
|
||||
breakPastHalf: boolean;
|
||||
settingsModified: boolean;
|
||||
idlePaused: boolean;
|
||||
naturalBreakOccurred: boolean;
|
||||
smartBreaksEnabled: boolean;
|
||||
smartBreakThreshold: number;
|
||||
}
|
||||
|
||||
const defaultSnapshot: TimerSnapshot = {
|
||||
state: "paused",
|
||||
currentView: "dashboard",
|
||||
timeRemaining: 1500,
|
||||
totalDuration: 1500,
|
||||
progress: 1.0,
|
||||
hasHadBreak: false,
|
||||
secondsSinceLastBreak: 0,
|
||||
prebreakWarning: false,
|
||||
snoozesUsed: 0,
|
||||
canSnooze: true,
|
||||
breakTitle: "Rest your eyes",
|
||||
breakMessage: "Look away from the screen. Stretch and relax.",
|
||||
breakProgress: 0,
|
||||
breakTimeRemaining: 0,
|
||||
breakTotalDuration: 0,
|
||||
breakPastHalf: false,
|
||||
settingsModified: false,
|
||||
idlePaused: false,
|
||||
naturalBreakOccurred: false,
|
||||
smartBreaksEnabled: true,
|
||||
smartBreakThreshold: 300,
|
||||
};
|
||||
|
||||
export const timer = writable<TimerSnapshot>(defaultSnapshot);
|
||||
|
||||
// Track the current view separately so UI can switch views optimistically
|
||||
export const currentView = writable<
|
||||
"dashboard" | "breakScreen" | "settings" | "stats"
|
||||
>("dashboard");
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export async function initTimerStore() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
// Get initial state
|
||||
try {
|
||||
const snapshot = await invoke<TimerSnapshot>("get_timer_state");
|
||||
timer.set(snapshot);
|
||||
currentView.set(snapshot.currentView);
|
||||
} catch (e) {
|
||||
console.error("Failed to get initial timer state:", e);
|
||||
}
|
||||
|
||||
// Listen for tick events
|
||||
await listen<TimerSnapshot>("timer-tick", (event) => {
|
||||
timer.set(event.payload);
|
||||
// Sync view from backend (backend is authoritative for break transitions)
|
||||
currentView.set(event.payload.currentView);
|
||||
});
|
||||
|
||||
// Listen for pre-break warning
|
||||
await listen("prebreak-warning", () => {
|
||||
const cfg = get(config);
|
||||
if (cfg.sound_enabled) {
|
||||
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.6);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for break started
|
||||
await listen("break-started", () => {
|
||||
currentView.set("breakScreen");
|
||||
const cfg = get(config);
|
||||
if (cfg.sound_enabled) {
|
||||
playSound(cfg.sound_preset as any, cfg.sound_volume);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for break ended
|
||||
await listen("break-ended", () => {
|
||||
currentView.set("dashboard");
|
||||
const cfg = get(config);
|
||||
if (cfg.sound_enabled) {
|
||||
playBreakEndSound(cfg.sound_preset as any, cfg.sound_volume);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for natural break detected
|
||||
await listen<number>("natural-break-detected", (event) => {
|
||||
const cfg = get(config);
|
||||
if (cfg.sound_enabled) {
|
||||
playSound(cfg.sound_preset as any, cfg.sound_volume * 0.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: format seconds as MM:SS
|
||||
export function formatTime(secs: number): string {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Helper: format "X ago" string
|
||||
export function formatDurationAgo(secs: number): string {
|
||||
if (secs < 60) return `${secs} sec ago`;
|
||||
const m = Math.floor(secs / 60);
|
||||
if (m < 60) return m === 1 ? "1 min ago" : `${m} min ago`;
|
||||
const h = Math.floor(secs / 3600);
|
||||
return h === 1 ? "1 hr ago" : `${h} hrs ago`;
|
||||
}
|
||||
115
src/lib/utils/activities.ts
Normal file
115
src/lib/utils/activities.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export interface BreakActivity {
|
||||
category: "eyes" | "stretch" | "breathing" | "movement";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const breakActivities: BreakActivity[] = [
|
||||
// Eyes
|
||||
{ category: "eyes", text: "Look at something 20 feet away for 20 seconds" },
|
||||
{ category: "eyes", text: "Close your eyes and gently press your palms over them" },
|
||||
{ category: "eyes", text: "Slowly roll your eyes in circles, 5 times each direction" },
|
||||
{ category: "eyes", text: "Focus on a distant object, then a near one — repeat 5 times" },
|
||||
{ category: "eyes", text: "Blink rapidly 20 times to refresh your eyes" },
|
||||
{ category: "eyes", text: "Look up, down, left, right — hold each for 2 seconds" },
|
||||
{ category: "eyes", text: "Cup your hands over closed eyes and breathe deeply" },
|
||||
{ category: "eyes", text: "Trace a figure-8 with your eyes, slowly" },
|
||||
{ category: "eyes", text: "Look out the nearest window and find the farthest point" },
|
||||
{ category: "eyes", text: "Gently massage your temples in small circles" },
|
||||
{ category: "eyes", text: "Close your eyes and visualize a calm, dark space for 20 seconds" },
|
||||
{ category: "eyes", text: "Warm your palms by rubbing them together, then rest them over closed eyes" },
|
||||
{ category: "eyes", text: "Slowly shift your gaze along the horizon line outside" },
|
||||
{ category: "eyes", text: "Focus on the tip of your nose for 5 seconds, then a far wall" },
|
||||
{ category: "eyes", text: "Look at something green — plants reduce eye strain naturally" },
|
||||
{ category: "eyes", text: "Close your eyes and gently press your eyebrows outward from center" },
|
||||
{ category: "eyes", text: "Squeeze your eyes shut for 3 seconds, then open wide — repeat 5 times" },
|
||||
{ category: "eyes", text: "Trace the outline of a window frame with your eyes, slowly" },
|
||||
|
||||
// Stretches
|
||||
{ category: "stretch", text: "Roll your shoulders backward slowly, 5 times" },
|
||||
{ category: "stretch", text: "Interlace fingers behind your back and open your chest" },
|
||||
{ category: "stretch", text: "Tilt your head to each side, holding for 10 seconds" },
|
||||
{ category: "stretch", text: "Stretch your arms overhead and reach for the ceiling" },
|
||||
{ category: "stretch", text: "Rotate your wrists in circles, 10 times each direction" },
|
||||
{ category: "stretch", text: "Clasp hands together and push palms away from you" },
|
||||
{ category: "stretch", text: "Gently twist your torso left and right from your chair" },
|
||||
{ category: "stretch", text: "Drop your chin to your chest and slowly roll your neck" },
|
||||
{ category: "stretch", text: "Extend each arm across your chest, hold for 15 seconds" },
|
||||
{ category: "stretch", text: "Open and close your hands, spreading fingers wide" },
|
||||
{ category: "stretch", text: "Place your right hand on your left knee and twist gently — switch sides" },
|
||||
{ category: "stretch", text: "Reach one arm overhead and lean to the opposite side, hold 10 seconds each" },
|
||||
{ category: "stretch", text: "Interlace your fingers and flip your palms to the ceiling, push up" },
|
||||
{ category: "stretch", text: "Pull each finger gently backward for 3 seconds to stretch your forearms" },
|
||||
{ category: "stretch", text: "Press your palms together at chest height and push for 10 seconds" },
|
||||
{ category: "stretch", text: "Sit tall, reach behind to grab the back of your chair, and open your chest" },
|
||||
{ category: "stretch", text: "Cross one ankle over the opposite knee and lean forward gently" },
|
||||
{ category: "stretch", text: "Shrug your shoulders up to your ears, hold 5 seconds, release slowly" },
|
||||
|
||||
// Breathing
|
||||
{ category: "breathing", text: "Inhale for 4 seconds, hold for 4, exhale for 6" },
|
||||
{ category: "breathing", text: "Take 5 deep belly breaths — feel your diaphragm expand" },
|
||||
{ category: "breathing", text: "Box breathing: inhale 4s, hold 4s, exhale 4s, hold 4s" },
|
||||
{ category: "breathing", text: "Breathe in through your nose, out through your mouth — 5 times" },
|
||||
{ category: "breathing", text: "Sigh deeply 3 times to release tension" },
|
||||
{ category: "breathing", text: "Place a hand on your chest and breathe so only your belly moves" },
|
||||
{ category: "breathing", text: "Alternate nostril breathing: 3 cycles each side" },
|
||||
{ category: "breathing", text: "Exhale completely, then take the deepest breath you can" },
|
||||
{ category: "breathing", text: "Count your breaths backward from 10 to 1" },
|
||||
{ category: "breathing", text: "Breathe in calm, breathe out tension — 5 rounds" },
|
||||
{ category: "breathing", text: "4-7-8 breathing: inhale 4s, hold 7s, exhale slowly for 8s" },
|
||||
{ category: "breathing", text: "Take 3 breaths making your exhale twice as long as your inhale" },
|
||||
{ category: "breathing", text: "Breathe in for 2 counts, out for 2 — gradually increase to 6 each" },
|
||||
{ category: "breathing", text: "Hum gently on each exhale for 5 breaths — feel the vibration in your chest" },
|
||||
{ category: "breathing", text: "Imagine breathing in cool blue air and exhaling warm red air — 5 rounds" },
|
||||
{ category: "breathing", text: "Place both hands on your ribs and breathe so you feel them expand sideways" },
|
||||
{ category: "breathing", text: "Breathe in through pursed lips slowly, then exhale in a long, steady stream" },
|
||||
|
||||
// Movement
|
||||
{ category: "movement", text: "Stand up and walk to the nearest window" },
|
||||
{ category: "movement", text: "Do 10 standing calf raises" },
|
||||
{ category: "movement", text: "Walk to get a glass of water" },
|
||||
{ category: "movement", text: "Stand and do 5 gentle squats" },
|
||||
{ category: "movement", text: "Take a short walk around your room" },
|
||||
{ category: "movement", text: "Stand on one foot for 15 seconds, then switch" },
|
||||
{ category: "movement", text: "Do 10 arm circles, forward then backward" },
|
||||
{ category: "movement", text: "March in place for 30 seconds" },
|
||||
{ category: "movement", text: "Shake out your hands and arms for 10 seconds" },
|
||||
{ category: "movement", text: "Stand up, touch your toes, and slowly rise" },
|
||||
{ category: "movement", text: "Walk to the farthest room in your home and back" },
|
||||
{ category: "movement", text: "Do 5 wall push-ups — hands on the wall, lean in and push back" },
|
||||
{ category: "movement", text: "Stand up and slowly shift your weight side to side for 20 seconds" },
|
||||
{ category: "movement", text: "Walk in a small circle, paying attention to each footstep" },
|
||||
{ category: "movement", text: "Rise onto your tiptoes, hold for 5 seconds, lower slowly — repeat 5 times" },
|
||||
{ category: "movement", text: "Do a gentle standing forward fold — let your arms hang loose" },
|
||||
{ category: "movement", text: "Step away from your desk and do 10 hip circles in each direction" },
|
||||
{ category: "movement", text: "Stand with your back against a wall and slide down into a gentle wall sit for 15 seconds" },
|
||||
];
|
||||
|
||||
const categoryIcons: Record<BreakActivity["category"], string> = {
|
||||
eyes: "👁",
|
||||
stretch: "🤸",
|
||||
breathing: "🌬",
|
||||
movement: "🚶",
|
||||
};
|
||||
|
||||
const categoryLabels: Record<BreakActivity["category"], string> = {
|
||||
eyes: "Eye Exercise",
|
||||
stretch: "Stretch",
|
||||
breathing: "Breathing",
|
||||
movement: "Movement",
|
||||
};
|
||||
|
||||
export function getCategoryIcon(cat: BreakActivity["category"]): string {
|
||||
return categoryIcons[cat];
|
||||
}
|
||||
|
||||
export function getCategoryLabel(cat: BreakActivity["category"]): string {
|
||||
return categoryLabels[cat];
|
||||
}
|
||||
|
||||
/** Pick a random activity, optionally excluding a previous one */
|
||||
export function pickRandomActivity(exclude?: BreakActivity): BreakActivity {
|
||||
const pool = exclude
|
||||
? breakActivities.filter((a) => a.text !== exclude.text)
|
||||
: breakActivities;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
366
src/lib/utils/animate.ts
Normal file
366
src/lib/utils/animate.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { animate } from "motion";
|
||||
|
||||
/**
|
||||
* Svelte action: fade in + slide up on mount
|
||||
*/
|
||||
export function fadeIn(
|
||||
node: HTMLElement,
|
||||
options?: { duration?: number; delay?: number; y?: number },
|
||||
) {
|
||||
const { duration = 0.5, delay = 0, y = 15 } = options ?? {};
|
||||
node.style.opacity = "0";
|
||||
|
||||
const controls = animate(
|
||||
node,
|
||||
{ opacity: [0, 1], transform: [`translateY(${y}px)`, "translateY(0px)"] },
|
||||
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
|
||||
);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
controls.cancel();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action: scale in + fade on mount
|
||||
*/
|
||||
export function scaleIn(
|
||||
node: HTMLElement,
|
||||
options?: { duration?: number; delay?: number },
|
||||
) {
|
||||
const { duration = 0.6, delay = 0 } = options ?? {};
|
||||
node.style.opacity = "0";
|
||||
|
||||
const controls = animate(
|
||||
node,
|
||||
{ opacity: [0, 1], transform: ["scale(0.92)", "scale(1)"] },
|
||||
{ duration, delay, easing: [0.22, 0.03, 0.26, 1] },
|
||||
);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
controls.cancel();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action: animate when scrolled into view (IntersectionObserver)
|
||||
*/
|
||||
export function inView(
|
||||
node: HTMLElement,
|
||||
options?: { delay?: number; y?: number; threshold?: number },
|
||||
) {
|
||||
const { delay = 0, y = 20, threshold = 0.05 } = options ?? {};
|
||||
node.style.opacity = "0";
|
||||
node.style.transform = `translateY(${y}px)`;
|
||||
|
||||
let controls: ReturnType<typeof animate> | null = null;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
controls = animate(
|
||||
node,
|
||||
{
|
||||
opacity: [0, 1],
|
||||
transform: [`translateY(${y}px)`, "translateY(0px)"],
|
||||
},
|
||||
{ duration: 0.45, delay, easing: [0.22, 0.03, 0.26, 1] },
|
||||
);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold },
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
controls?.cancel();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action: spring-scale press feedback on buttons
|
||||
*/
|
||||
export function pressable(node: HTMLElement) {
|
||||
let active: ReturnType<typeof animate> | null = null;
|
||||
|
||||
function onDown() {
|
||||
active?.cancel();
|
||||
// Explicit [from, to] avoids transform conflicts with fadeIn's translateY
|
||||
active = animate(
|
||||
node,
|
||||
{ transform: ["scale(1)", "scale(0.95)"] },
|
||||
{ duration: 0.1, easing: [0.22, 0.03, 0.26, 1] },
|
||||
);
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
active?.cancel();
|
||||
active = animate(
|
||||
node,
|
||||
{ transform: ["scale(0.95)", "scale(1)"] },
|
||||
{ duration: 0.3, easing: [0.22, 1.2, 0.36, 1] },
|
||||
);
|
||||
}
|
||||
|
||||
node.addEventListener("mousedown", onDown);
|
||||
node.addEventListener("mouseup", onUp);
|
||||
node.addEventListener("mouseleave", onUp);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("mousedown", onDown);
|
||||
node.removeEventListener("mouseup", onUp);
|
||||
node.removeEventListener("mouseleave", onUp);
|
||||
active?.cancel();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action: animated glow band on hover.
|
||||
* Creates a crisp ring "band" plus a soft atmospheric glow.
|
||||
* Pass a hex color (e.g. "#ff4d00").
|
||||
*
|
||||
* Uses same-hue zero-alpha as the "off" state so the Web Animations API
|
||||
* interpolates through the correct color channel instead of through black.
|
||||
*/
|
||||
export function glowHover(
|
||||
node: HTMLElement,
|
||||
options?: { color?: string },
|
||||
) {
|
||||
let color = options?.color ?? "#ff4d00";
|
||||
let enterAnim: ReturnType<typeof animate> | null = null;
|
||||
let leaveAnim: ReturnType<typeof animate> | null = null;
|
||||
|
||||
// "off" state: same hue at zero alpha (NOT transparent, which is rgba(0,0,0,0))
|
||||
function off() { return `0 0 0 0px ${color}00, 0 0 0px 0px ${color}00`; }
|
||||
function on() { return `0 0 0 1.5px ${color}90, 0 0 22px 6px ${color}40`; }
|
||||
|
||||
node.style.boxShadow = off();
|
||||
|
||||
function onEnter() {
|
||||
leaveAnim?.cancel();
|
||||
enterAnim = animate(
|
||||
node,
|
||||
{ boxShadow: [off(), on()] },
|
||||
{ duration: 0.4, easing: [0.22, 0.03, 0.26, 1] },
|
||||
);
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
enterAnim?.cancel();
|
||||
leaveAnim = animate(
|
||||
node,
|
||||
{ boxShadow: [on(), off()] },
|
||||
{ duration: 0.5, easing: [0.22, 0.03, 0.26, 1] },
|
||||
);
|
||||
}
|
||||
|
||||
node.addEventListener("mouseenter", onEnter);
|
||||
node.addEventListener("mouseleave", onLeave);
|
||||
|
||||
return {
|
||||
update(newOptions?: { color?: string }) {
|
||||
color = newOptions?.color ?? "#ff4d00";
|
||||
node.style.boxShadow = off();
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener("mouseenter", onEnter);
|
||||
node.removeEventListener("mouseleave", onLeave);
|
||||
enterAnim?.cancel();
|
||||
leaveAnim?.cancel();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action: momentum-based grab-and-drag scrolling
|
||||
* with elastic overscroll and spring-back at boundaries.
|
||||
*
|
||||
* IMPORTANT: The node must have exactly one child element (a wrapper div).
|
||||
* Overscroll transforms are applied to that child, NOT to the scroll
|
||||
* container itself (which would break overflow clipping).
|
||||
*/
|
||||
export function dragScroll(node: HTMLElement) {
|
||||
const content = node.children[0] as HTMLElement | null;
|
||||
|
||||
let isDown = false;
|
||||
let startY = 0;
|
||||
let scrollStart = 0;
|
||||
let lastY = 0;
|
||||
let lastTime = 0;
|
||||
let animFrame = 0;
|
||||
let velocitySamples: number[] = [];
|
||||
let overscrollAmount = 0;
|
||||
let springAnim: ReturnType<typeof animate> | null = null;
|
||||
|
||||
function getMaxScroll() {
|
||||
return node.scrollHeight - node.clientHeight;
|
||||
}
|
||||
|
||||
function setOverscroll(amount: number) {
|
||||
if (!content) return;
|
||||
overscrollAmount = amount;
|
||||
if (Math.abs(amount) < 0.5) {
|
||||
content.style.transform = "";
|
||||
overscrollAmount = 0;
|
||||
} else {
|
||||
content.style.transform = `translateY(${-amount}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
function springBack() {
|
||||
if (!content) return;
|
||||
const from = overscrollAmount;
|
||||
if (Math.abs(from) < 0.5) {
|
||||
setOverscroll(0);
|
||||
return;
|
||||
}
|
||||
springAnim = animate(
|
||||
content,
|
||||
{ transform: [`translateY(${-from}px)`, "translateY(0px)"] },
|
||||
{ duration: 0.6, easing: [0.16, 1, 0.3, 1] },
|
||||
);
|
||||
overscrollAmount = 0;
|
||||
// Ensure DOM is clean after animation completes
|
||||
springAnim.finished.then(() => {
|
||||
if (content) content.style.transform = "";
|
||||
}).catch(() => { /* cancelled — onDown handles cleanup */ });
|
||||
}
|
||||
|
||||
function forceReset() {
|
||||
springAnim?.cancel();
|
||||
cancelAnimationFrame(animFrame);
|
||||
overscrollAmount = 0;
|
||||
if (content) content.style.transform = "";
|
||||
}
|
||||
|
||||
function onDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (["BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"].includes(tag)) return;
|
||||
|
||||
// Force-reset any lingering animation/transform state
|
||||
forceReset();
|
||||
|
||||
isDown = true;
|
||||
startY = e.pageY;
|
||||
scrollStart = node.scrollTop;
|
||||
lastY = e.pageY;
|
||||
lastTime = Date.now();
|
||||
velocitySamples = [];
|
||||
node.style.cursor = "grabbing";
|
||||
}
|
||||
|
||||
function onMove(e: MouseEvent) {
|
||||
if (!isDown) return;
|
||||
e.preventDefault();
|
||||
const y = e.pageY;
|
||||
const now = Date.now();
|
||||
const dt = now - lastTime;
|
||||
if (dt > 0) {
|
||||
velocitySamples.push((lastY - y) / dt);
|
||||
if (velocitySamples.length > 5) velocitySamples.shift();
|
||||
}
|
||||
lastY = y;
|
||||
lastTime = now;
|
||||
|
||||
const desiredScroll = scrollStart - (y - startY);
|
||||
const maxScroll = getMaxScroll();
|
||||
|
||||
if (desiredScroll < 0) {
|
||||
node.scrollTop = 0;
|
||||
setOverscroll(desiredScroll * 0.3);
|
||||
} else if (desiredScroll > maxScroll) {
|
||||
node.scrollTop = maxScroll;
|
||||
setOverscroll((desiredScroll - maxScroll) * 0.3);
|
||||
} else {
|
||||
node.scrollTop = desiredScroll;
|
||||
if (overscrollAmount !== 0) setOverscroll(0);
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
if (!isDown) return;
|
||||
isDown = false;
|
||||
node.style.cursor = "";
|
||||
|
||||
if (overscrollAmount !== 0) {
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
|
||||
const avgVelocity = velocitySamples.length > 0
|
||||
? velocitySamples.reduce((a, b) => a + b, 0) / velocitySamples.length
|
||||
: 0;
|
||||
|
||||
// Clamp velocity (px/ms) and bail if negligible
|
||||
const maxV = 4;
|
||||
const v0 = Math.max(-maxV, Math.min(maxV, avgVelocity));
|
||||
if (Math.abs(v0) < 0.005) return;
|
||||
|
||||
// Time-based exponential decay (iOS-style scroll physics).
|
||||
// position(t) = start + v0 * tau * (1 - e^(-t/tau))
|
||||
// velocity(t) = v0 * e^(-t/tau) → naturally asymptotes to zero
|
||||
const tau = 325; // time constant in ms — iOS UIScrollView feel
|
||||
const coastStart = performance.now();
|
||||
const scrollStart2 = node.scrollTop;
|
||||
const totalDist = v0 * tau;
|
||||
|
||||
function coast() {
|
||||
const t = performance.now() - coastStart;
|
||||
const decay = Math.exp(-t / tau);
|
||||
const offset = totalDist * (1 - decay);
|
||||
const targetScroll = scrollStart2 + offset;
|
||||
|
||||
const maxScroll = getMaxScroll();
|
||||
|
||||
if (targetScroll < 0) {
|
||||
node.scrollTop = 0;
|
||||
const currentV = Math.abs(v0 * decay);
|
||||
const bounce = Math.min(40, currentV * 50);
|
||||
setOverscroll(-bounce);
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
if (targetScroll > maxScroll) {
|
||||
node.scrollTop = maxScroll;
|
||||
const currentV = Math.abs(v0 * decay);
|
||||
const bounce = Math.min(40, currentV * 50);
|
||||
setOverscroll(bounce);
|
||||
springBack();
|
||||
return;
|
||||
}
|
||||
|
||||
node.scrollTop = targetScroll;
|
||||
|
||||
// Stop when velocity < 0.5 px/sec (completely imperceptible)
|
||||
if (Math.abs(v0 * decay) > 0.0005) {
|
||||
animFrame = requestAnimationFrame(coast);
|
||||
}
|
||||
}
|
||||
coast();
|
||||
}
|
||||
|
||||
node.addEventListener("mousedown", onDown);
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("mousedown", onDown);
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
forceReset();
|
||||
},
|
||||
};
|
||||
}
|
||||
283
src/lib/utils/sounds.ts
Normal file
283
src/lib/utils/sounds.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Synthesized notification sounds using the Web Audio API.
|
||||
* No external audio files needed — all sounds are generated programmatically.
|
||||
*/
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new AudioContext();
|
||||
}
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
type SoundPreset = "bell" | "chime" | "soft" | "digital" | "harp" | "bowl" | "rain" | "whistle";
|
||||
|
||||
/**
|
||||
* Play a notification sound with the given preset and volume.
|
||||
* @param preset - One of: "bell", "chime", "soft", "digital"
|
||||
* @param volume - 0 to 100
|
||||
*/
|
||||
export function playSound(preset: SoundPreset, volume: number): void {
|
||||
const ctx = getAudioContext();
|
||||
const gain = ctx.createGain();
|
||||
gain.connect(ctx.destination);
|
||||
const vol = (volume / 100) * 0.3; // Scale to reasonable max
|
||||
|
||||
switch (preset) {
|
||||
case "bell":
|
||||
playBell(ctx, gain, vol);
|
||||
break;
|
||||
case "chime":
|
||||
playChime(ctx, gain, vol);
|
||||
break;
|
||||
case "soft":
|
||||
playSoft(ctx, gain, vol);
|
||||
break;
|
||||
case "digital":
|
||||
playDigital(ctx, gain, vol);
|
||||
break;
|
||||
case "harp":
|
||||
playHarp(ctx, gain, vol);
|
||||
break;
|
||||
case "bowl":
|
||||
playBowl(ctx, gain, vol);
|
||||
break;
|
||||
case "rain":
|
||||
playRain(ctx, gain, vol);
|
||||
break;
|
||||
case "whistle":
|
||||
playWhistle(ctx, gain, vol);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Warm bell — two sine tones with harmonics and slow decay */
|
||||
function playBell(ctx: AudioContext, destination: GainNode, vol: number) {
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Fundamental
|
||||
const osc1 = ctx.createOscillator();
|
||||
const g1 = ctx.createGain();
|
||||
osc1.type = "sine";
|
||||
osc1.frequency.setValueAtTime(830, now);
|
||||
g1.gain.setValueAtTime(vol, now);
|
||||
g1.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
|
||||
osc1.connect(g1);
|
||||
g1.connect(destination);
|
||||
osc1.start(now);
|
||||
osc1.stop(now + 1.5);
|
||||
|
||||
// Harmonic
|
||||
const osc2 = ctx.createOscillator();
|
||||
const g2 = ctx.createGain();
|
||||
osc2.type = "sine";
|
||||
osc2.frequency.setValueAtTime(1245, now);
|
||||
g2.gain.setValueAtTime(vol * 0.4, now);
|
||||
g2.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
|
||||
osc2.connect(g2);
|
||||
g2.connect(destination);
|
||||
osc2.start(now);
|
||||
osc2.stop(now + 1.0);
|
||||
}
|
||||
|
||||
/** Two-note ascending chime */
|
||||
function playChime(ctx: AudioContext, destination: GainNode, vol: number) {
|
||||
const now = ctx.currentTime;
|
||||
const notes = [523.25, 659.25]; // C5, E5
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(freq, now);
|
||||
const start = now + i * 0.15;
|
||||
g.gain.setValueAtTime(0, start);
|
||||
g.gain.linearRampToValueAtTime(vol, start + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, start + 0.8);
|
||||
osc.connect(g);
|
||||
g.connect(destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
/** Gentle soft ping — filtered triangle wave */
|
||||
function playSoft(ctx: AudioContext, destination: GainNode, vol: number) {
|
||||
const now = ctx.currentTime;
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const filter = ctx.createBiquadFilter();
|
||||
const g = ctx.createGain();
|
||||
|
||||
osc.type = "triangle";
|
||||
osc.frequency.setValueAtTime(600, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(400, now + 0.5);
|
||||
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(2000, now);
|
||||
filter.frequency.exponentialRampToValueAtTime(400, now + 0.8);
|
||||
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, now + 1.2);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(g);
|
||||
g.connect(destination);
|
||||
osc.start(now);
|
||||
osc.stop(now + 1.2);
|
||||
}
|
||||
|
||||
/** Digital blip — short square wave burst */
|
||||
function playDigital(ctx: AudioContext, destination: GainNode, vol: number) {
|
||||
const now = ctx.currentTime;
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = "square";
|
||||
osc.frequency.setValueAtTime(880, now);
|
||||
const start = now + i * 0.12;
|
||||
g.gain.setValueAtTime(0, start);
|
||||
g.gain.linearRampToValueAtTime(vol * 0.5, start + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, start + 0.15);
|
||||
osc.connect(g);
|
||||
g.connect(destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/** Harp — cascading arpeggiated sine tones (C5-E5-G5-C6) */
|
||||
function playHarp(ctx: AudioContext, destination: GainNode, vol: number) {
|
||||
const now = ctx.currentTime;
|
||||
const notes = [523.25, 659.25, 783.99, 1046.5]; // C5, E5, G5, C6
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(freq, now);
|
||||
const start = now + i * 0.09;
|
||||
g.gain.setValueAtTime(0, start);
|
||||
g.gain.linearRampToValueAtTime(vol * 0.8, start + 0.01);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, start + 1.2);
|
||||
osc.connect(g);
|
||||
g.connect(destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + 1.2);
|
||||
});
|
||||
}
|
||||
|
||||
/** Singing bowl — low sine with slow beating from detuned pair */
|
||||
function playBowl(ctx: AudioContext, destination: GainNode, vol: number) {
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Two slightly detuned sines create a beating/shimmering effect
|
||||
for (const freq of [293.66, 295.5]) { // ~D4 with 2Hz beat
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(freq, now);
|
||||
g.gain.setValueAtTime(vol, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, now + 2.5);
|
||||
osc.connect(g);
|
||||
g.connect(destination);
|
||||
osc.start(now);
|
||||
osc.stop(now + 2.5);
|
||||
}
|
||||
|
||||
// Upper harmonic shimmer
|
||||
const osc3 = ctx.createOscillator();
|
||||
const g3 = ctx.createGain();
|
||||
osc3.type = "sine";
|
||||
osc3.frequency.setValueAtTime(880, now);
|
||||
g3.gain.setValueAtTime(vol * 0.15, now);
|
||||
g3.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
|
||||
osc3.connect(g3);
|
||||
g3.connect(destination);
|
||||
osc3.start(now);
|
||||
osc3.stop(now + 1.5);
|
||||
}
|
||||
|
||||
/** Rain — filtered noise burst with gentle decay */
|
||||
function playRain(ctx: AudioContext, destination: GainNode, vol: number) {
|
||||
const now = ctx.currentTime;
|
||||
const bufferSize = ctx.sampleRate * 1;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
// White noise
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
|
||||
const noise = ctx.createBufferSource();
|
||||
noise.buffer = buffer;
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "bandpass";
|
||||
filter.frequency.setValueAtTime(3000, now);
|
||||
filter.frequency.exponentialRampToValueAtTime(800, now + 0.8);
|
||||
filter.Q.setValueAtTime(0.5, now);
|
||||
|
||||
const g = ctx.createGain();
|
||||
g.gain.setValueAtTime(vol * 0.6, now);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
|
||||
|
||||
noise.connect(filter);
|
||||
filter.connect(g);
|
||||
g.connect(destination);
|
||||
noise.start(now);
|
||||
noise.stop(now + 1.0);
|
||||
}
|
||||
|
||||
/** Whistle — gentle two-note sine glide */
|
||||
function playWhistle(ctx: AudioContext, destination: GainNode, vol: number) {
|
||||
const now = ctx.currentTime;
|
||||
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(880, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(1318.5, now + 0.3); // A5 → E6 glide up
|
||||
osc.frequency.setValueAtTime(1318.5, now + 0.5);
|
||||
osc.frequency.exponentialRampToValueAtTime(1046.5, now + 0.8); // E6 → C6 settle
|
||||
|
||||
g.gain.setValueAtTime(0, now);
|
||||
g.gain.linearRampToValueAtTime(vol * 0.5, now + 0.05);
|
||||
g.gain.setValueAtTime(vol * 0.5, now + 0.6);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, now + 1.0);
|
||||
|
||||
osc.connect(g);
|
||||
g.connect(destination);
|
||||
osc.start(now);
|
||||
osc.stop(now + 1.0);
|
||||
}
|
||||
|
||||
/** Play a completion sound — slightly different from start (descending) */
|
||||
export function playBreakEndSound(preset: SoundPreset, volume: number): void {
|
||||
const ctx = getAudioContext();
|
||||
const gain = ctx.createGain();
|
||||
gain.connect(ctx.destination);
|
||||
const vol = (volume / 100) * 0.3;
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Always a gentle descending two-note resolution
|
||||
const notes = [659.25, 523.25]; // E5, C5 (descending = "done" feeling)
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = preset === "digital" ? "square" : "sine";
|
||||
osc.frequency.setValueAtTime(freq, now);
|
||||
const start = now + i * 0.2;
|
||||
g.gain.setValueAtTime(0, start);
|
||||
g.gain.linearRampToValueAtTime(vol, start + 0.02);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, start + 0.6);
|
||||
osc.connect(g);
|
||||
g.connect(gain);
|
||||
osc.start(start);
|
||||
osc.stop(start + 0.6);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user