mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
✨ feat(clock): improve UI across alarms, timers, pomodoro, and world clock pages
- Enhanced alarm page with preset suggestions and better layout - Simplified timers page with cleaner controls - Improved pomodoro with visual progress indicators - World clock now shows interactive map with city markers - Extended app.css with new utility classes and animations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f80b864ba8
commit
9dee75e06e
5 changed files with 750 additions and 622 deletions
|
|
@ -157,7 +157,122 @@
|
|||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* Alarm Card */
|
||||
/* Quick Create Form */
|
||||
.quick-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.time-input-inline {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
width: 5rem;
|
||||
padding: 0.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.time-input-inline:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.time-input-inline::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.label-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.label-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.day-selector-compact {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.day-selector-compact button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.day-selector-compact button:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.day-selector-compact button.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
/* Alarm Grid (responsive) */
|
||||
.alarm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Alarm Tile (Grid layout) */
|
||||
.alarm-tile {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.alarm-tile:hover {
|
||||
background-color: hsl(var(--color-muted) / 0.5);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.alarm-tile:active {
|
||||
background-color: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.alarm-tile.active {
|
||||
opacity: 1;
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.alarm-tile.active:hover {
|
||||
opacity: 1;
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
/* Alarm Card (legacy, for edit form) */
|
||||
.alarm-card {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
|
|
@ -317,7 +432,105 @@
|
|||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
/* Time Input Small (for timer) */
|
||||
.time-input-sm {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
width: 3.5rem;
|
||||
padding: 0.375rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Time Input Large (for quick create) */
|
||||
.time-input-large {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 200;
|
||||
text-align: center;
|
||||
width: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-input-large:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.time-input-large::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Toggle Switch (iOS-style) */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
background-color: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-base);
|
||||
border: none;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-base);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-knob {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Toggle Switch Small (for grid tiles) */
|
||||
.toggle-switch-sm {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background-color: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-full);
|
||||
transition: background-color var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch-sm.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.toggle-switch-sm .toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-base);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch-sm.active .toggle-knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Legacy toggle (deprecated) */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
|
|
|
|||
|
|
@ -1,81 +1,127 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PageHeader } from '@manacore/shared-ui';
|
||||
import { alarmsStore } from '$lib/stores/alarms.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import type { CreateAlarmInput } from '@clock/shared';
|
||||
import { ALARM_SOUNDS } from '@clock/shared';
|
||||
import type { CreateAlarmInput, Alarm } from '@clock/shared';
|
||||
import { ALARM_SOUNDS, DEFAULT_ALARM_PRESETS } from '@clock/shared';
|
||||
|
||||
// Form state
|
||||
let showForm = $state(false);
|
||||
// Quick create form (inline)
|
||||
let newTime = $state('07:00');
|
||||
let newLabel = $state('');
|
||||
let newRepeatDays = $state<number[]>([]);
|
||||
let showOptions = $state(false);
|
||||
|
||||
// Edit modal state
|
||||
let showEditModal = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let formTime = $state('07:00');
|
||||
let formLabel = $state('');
|
||||
let formRepeatDays = $state<number[]>([]);
|
||||
let formSound = $state('default');
|
||||
let formSnoozeMinutes = $state(5);
|
||||
let editTime = $state('07:00');
|
||||
let editLabel = $state('');
|
||||
let editRepeatDays = $state<number[]>([]);
|
||||
let editSound = $state('default');
|
||||
let editSnoozeMinutes = $state(5);
|
||||
|
||||
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
// Find existing alarm for a preset time
|
||||
function findAlarmForPreset(presetTime: string): Alarm | undefined {
|
||||
return alarmsStore.alarms.find((a) => a.time.slice(0, 5) === presetTime);
|
||||
}
|
||||
|
||||
// Toggle a preset alarm
|
||||
async function togglePreset(presetTime: string, presetLabel: string) {
|
||||
const existingAlarm = findAlarmForPreset(presetTime);
|
||||
|
||||
if (existingAlarm) {
|
||||
await alarmsStore.toggleAlarm(existingAlarm.id);
|
||||
} else {
|
||||
const result = await alarmsStore.createAlarm({
|
||||
time: presetTime + ':00',
|
||||
label: presetLabel,
|
||||
enabled: true,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success('Wecker erstellt');
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Erstellen');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick create new alarm
|
||||
async function handleQuickCreate() {
|
||||
const result = await alarmsStore.createAlarm({
|
||||
time: newTime + ':00',
|
||||
label: newLabel || undefined,
|
||||
repeatDays: newRepeatDays.length > 0 ? newRepeatDays : undefined,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Wecker erstellt');
|
||||
// Reset form
|
||||
newTime = '07:00';
|
||||
newLabel = '';
|
||||
newRepeatDays = [];
|
||||
showOptions = false;
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Erstellen');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNewDay(day: number) {
|
||||
if (newRepeatDays.includes(day)) {
|
||||
newRepeatDays = newRepeatDays.filter((d) => d !== day);
|
||||
} else {
|
||||
newRepeatDays = [...newRepeatDays, day];
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await alarmsStore.fetchAlarms();
|
||||
}
|
||||
});
|
||||
|
||||
function openNewForm() {
|
||||
editingId = null;
|
||||
formTime = '07:00';
|
||||
formLabel = '';
|
||||
formRepeatDays = [];
|
||||
formSound = 'default';
|
||||
formSnoozeMinutes = 5;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function openEditForm(alarm: any) {
|
||||
function openEditModal(alarm: Alarm) {
|
||||
editingId = alarm.id;
|
||||
formTime = alarm.time.slice(0, 5); // HH:MM
|
||||
formLabel = alarm.label || '';
|
||||
formRepeatDays = alarm.repeatDays || [];
|
||||
formSound = alarm.sound || 'default';
|
||||
formSnoozeMinutes = alarm.snoozeMinutes || 5;
|
||||
showForm = true;
|
||||
editTime = alarm.time.slice(0, 5);
|
||||
editLabel = alarm.label || '';
|
||||
editRepeatDays = alarm.repeatDays || [];
|
||||
editSound = alarm.sound || 'default';
|
||||
editSnoozeMinutes = alarm.snoozeMinutes || 5;
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm = false;
|
||||
function closeEditModal() {
|
||||
showEditModal = false;
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
function toggleDay(day: number) {
|
||||
if (formRepeatDays.includes(day)) {
|
||||
formRepeatDays = formRepeatDays.filter((d) => d !== day);
|
||||
function toggleEditDay(day: number) {
|
||||
if (editRepeatDays.includes(day)) {
|
||||
editRepeatDays = editRepeatDays.filter((d) => d !== day);
|
||||
} else {
|
||||
formRepeatDays = [...formRepeatDays, day];
|
||||
editRepeatDays = [...editRepeatDays, day];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const input: CreateAlarmInput = {
|
||||
time: formTime + ':00',
|
||||
label: formLabel || undefined,
|
||||
repeatDays: formRepeatDays.length > 0 ? formRepeatDays : undefined,
|
||||
sound: formSound,
|
||||
snoozeMinutes: formSnoozeMinutes,
|
||||
};
|
||||
async function handleEditSubmit() {
|
||||
if (!editingId) return;
|
||||
|
||||
let result;
|
||||
if (editingId) {
|
||||
result = await alarmsStore.updateAlarm(editingId, input);
|
||||
} else {
|
||||
result = await alarmsStore.createAlarm(input);
|
||||
}
|
||||
const result = await alarmsStore.updateAlarm(editingId, {
|
||||
time: editTime + ':00',
|
||||
label: editLabel || undefined,
|
||||
repeatDays: editRepeatDays.length > 0 ? editRepeatDays : undefined,
|
||||
sound: editSound,
|
||||
snoozeMinutes: editSnoozeMinutes,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(editingId ? 'Wecker aktualisiert' : 'Wecker erstellt');
|
||||
closeForm();
|
||||
toast.success('Wecker aktualisiert');
|
||||
closeEditModal();
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Speichern');
|
||||
}
|
||||
|
|
@ -97,98 +143,134 @@
|
|||
function getRepeatText(days: number[] | null) {
|
||||
if (!days || days.length === 0) return 'Einmalig';
|
||||
if (days.length === 7) return 'Täglich';
|
||||
if (
|
||||
days.length === 5 &&
|
||||
days.includes(1) &&
|
||||
days.includes(2) &&
|
||||
days.includes(3) &&
|
||||
days.includes(4) &&
|
||||
days.includes(5)
|
||||
)
|
||||
return 'Wochentags';
|
||||
if (days.length === 5 && [1, 2, 3, 4, 5].every((d) => days.includes(d))) return 'Wochentags';
|
||||
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Am Wochenende';
|
||||
return days.map((d) => dayNames[d]).join(', ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('alarm.title')}</h1>
|
||||
<button class="btn btn-primary" onclick={openNewForm}>
|
||||
+ {$_('alarm.add')}
|
||||
<PageHeader title={$_('alarm.title')} size="md" centered />
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Quick Create Form -->
|
||||
<div class="quick-create">
|
||||
<input type="time" class="time-input-inline" bind:value={newTime} />
|
||||
<input type="text" class="label-input" placeholder="Bezeichnung" bind:value={newLabel} />
|
||||
<button
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
|
||||
onclick={() => (showOptions = !showOptions)}
|
||||
title="Wiederholung"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
class:text-primary={newRepeatDays.length > 0}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick={handleQuickCreate}> + </button>
|
||||
</div>
|
||||
|
||||
<!-- Alarm List -->
|
||||
{#if showOptions}
|
||||
<div class="day-selector-compact">
|
||||
{#each dayNames as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class:active={newRepeatDays.includes(i)}
|
||||
onclick={() => toggleNewDay(i)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if alarmsStore.loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if alarmsStore.alarms.length === 0}
|
||||
<div class="card py-12 text-center">
|
||||
<p class="text-lg text-muted-foreground">{$_('alarm.noAlarms')}</p>
|
||||
<button class="btn btn-primary mt-4" onclick={openNewForm}>
|
||||
{$_('alarm.add')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each alarmsStore.alarms as alarm (alarm.id)}
|
||||
<div class="alarm-card" class:disabled={!alarm.enabled}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<button class="text-left w-full" onclick={() => openEditForm(alarm)}>
|
||||
<div class="text-3xl font-light text-foreground">
|
||||
{alarm.time.slice(0, 5)}
|
||||
</div>
|
||||
{#if alarm.label}
|
||||
<p class="mt-1 text-sm font-medium text-foreground">{alarm.label}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{getRepeatText(alarm.repeatDays)}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="text-muted-foreground hover:text-error"
|
||||
onclick={() => handleDelete(alarm.id)}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
<button
|
||||
class="toggle"
|
||||
class:active={alarm.enabled}
|
||||
onclick={() => handleToggle(alarm.id)}
|
||||
></button>
|
||||
</div>
|
||||
<!-- Default Alarm Presets (Grid) -->
|
||||
<div class="alarm-grid">
|
||||
{#each DEFAULT_ALARM_PRESETS as preset}
|
||||
{@const existingAlarm = findAlarmForPreset(preset.time)}
|
||||
{@const isActive = existingAlarm?.enabled ?? false}
|
||||
<div
|
||||
class="alarm-tile"
|
||||
class:active={isActive}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => togglePreset(preset.time, preset.label)}
|
||||
onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)}
|
||||
>
|
||||
<div class="text-xl font-light text-foreground tabular-nums text-center">
|
||||
{preset.time}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
|
||||
{existingAlarm?.label || preset.label}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Custom Alarms (Grid) -->
|
||||
{@const customAlarms = alarmsStore.alarms.filter(
|
||||
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
|
||||
)}
|
||||
{#if customAlarms.length > 0}
|
||||
<div class="mt-4">
|
||||
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||
{$_('alarm.custom')}
|
||||
</h2>
|
||||
<div class="alarm-grid">
|
||||
{#each customAlarms as alarm (alarm.id)}
|
||||
<div
|
||||
class="alarm-tile"
|
||||
class:active={alarm.enabled}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleToggle(alarm.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)}
|
||||
>
|
||||
<div class="text-xl font-light text-foreground tabular-nums text-center">
|
||||
{alarm.time.slice(0, 5)}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
|
||||
{alarm.label || getRepeatText(alarm.repeatDays)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Form Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<!-- Edit Modal -->
|
||||
{#if showEditModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="card w-full max-w-md">
|
||||
<h2 class="mb-4 text-xl font-semibold">
|
||||
{editingId ? $_('alarm.edit') : $_('alarm.add')}
|
||||
</h2>
|
||||
<h2 class="mb-4 text-xl font-semibold">{$_('alarm.edit')}</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
handleEditSubmit();
|
||||
}}
|
||||
>
|
||||
<!-- Time -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium">{$_('alarm.time')}</label>
|
||||
<input type="time" class="input time-input" bind:value={formTime} />
|
||||
<input type="time" class="input time-input" bind:value={editTime} />
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
|
|
@ -198,7 +280,7 @@
|
|||
type="text"
|
||||
class="input"
|
||||
placeholder="Arbeit, Sport, etc."
|
||||
bind:value={formLabel}
|
||||
bind:value={editLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -209,8 +291,8 @@
|
|||
{#each dayNames as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class:active={formRepeatDays.includes(i)}
|
||||
onclick={() => toggleDay(i)}
|
||||
class:active={editRepeatDays.includes(i)}
|
||||
onclick={() => toggleEditDay(i)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
|
|
@ -221,7 +303,7 @@
|
|||
<!-- Sound -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium">{$_('alarm.sound')}</label>
|
||||
<select class="input" bind:value={formSound}>
|
||||
<select class="input" bind:value={editSound}>
|
||||
{#each ALARM_SOUNDS as sound}
|
||||
<option value={sound.id}>{sound.nameDE}</option>
|
||||
{/each}
|
||||
|
|
@ -231,7 +313,7 @@
|
|||
<!-- Snooze -->
|
||||
<div class="mb-6">
|
||||
<label class="mb-1 block text-sm font-medium">{$_('alarm.snooze')}</label>
|
||||
<select class="input" bind:value={formSnoozeMinutes}>
|
||||
<select class="input" bind:value={editSnoozeMinutes}>
|
||||
<option value={5}>5 Minuten</option>
|
||||
<option value={10}>10 Minuten</option>
|
||||
<option value={15}>15 Minuten</option>
|
||||
|
|
@ -241,7 +323,7 @@
|
|||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button type="button" class="btn btn-secondary flex-1" onclick={closeForm}>
|
||||
<button type="button" class="btn btn-secondary flex-1" onclick={closeEditModal}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary flex-1">
|
||||
|
|
|
|||
|
|
@ -1,33 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PageHeader } from '@manacore/shared-ui';
|
||||
import { pomodoroStore } from '$lib/stores/pomodoro.svelte';
|
||||
import { POMODORO_PRESETS } from '@clock/shared';
|
||||
|
||||
// SVG circle properties
|
||||
const radius = 120;
|
||||
const radius = 90;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
let strokeDashoffset = $derived(circumference - (pomodoroStore.progress / 100) * circumference);
|
||||
|
||||
let phaseLabel = $derived(
|
||||
{
|
||||
work: $_('pomodoro.work'),
|
||||
break: $_('pomodoro.break'),
|
||||
longBreak: $_('pomodoro.longBreak'),
|
||||
work: 'Arbeit',
|
||||
break: 'Pause',
|
||||
longBreak: 'Lange Pause',
|
||||
}[pomodoroStore.phase]
|
||||
);
|
||||
|
||||
let phaseColor = $derived(
|
||||
{
|
||||
work: 'hsl(var(--color-primary))',
|
||||
break: 'hsl(var(--color-success))',
|
||||
longBreak: 'hsl(var(--color-info))',
|
||||
break: 'hsl(142, 71%, 45%)',
|
||||
longBreak: 'hsl(199, 89%, 48%)',
|
||||
}[pomodoroStore.phase]
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
// Request notification permission
|
||||
pomodoroStore.requestNotificationPermission();
|
||||
});
|
||||
|
||||
|
|
@ -41,87 +40,44 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center space-y-8">
|
||||
<!-- Header -->
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('pomodoro.title')}</h1>
|
||||
<PageHeader title={$_('pomodoro.title')} size="md" centered />
|
||||
|
||||
<!-- Phase indicator -->
|
||||
<div class="text-center">
|
||||
<span
|
||||
class="inline-block rounded-full px-4 py-1 text-sm font-medium"
|
||||
style="background-color: {phaseColor}; color: white;"
|
||||
>
|
||||
{phaseLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Ring -->
|
||||
<div class="relative">
|
||||
<svg width="280" height="280" class="-rotate-90">
|
||||
<!-- Background circle -->
|
||||
<div class="pomodoro-container">
|
||||
<!-- Progress Ring with Time -->
|
||||
<div class="pomodoro-ring-wrapper">
|
||||
<svg width="200" height="200" class="-rotate-90">
|
||||
<circle
|
||||
cx="140"
|
||||
cy="140"
|
||||
cx="100"
|
||||
cy="100"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--color-muted))"
|
||||
stroke-width="8"
|
||||
stroke-width="6"
|
||||
/>
|
||||
<!-- Progress circle -->
|
||||
<circle
|
||||
cx="140"
|
||||
cy="140"
|
||||
cx="100"
|
||||
cy="100"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={phaseColor}
|
||||
stroke-width="8"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={strokeDashoffset}
|
||||
class="transition-all duration-1000 ease-linear"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Time display -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="digital-clock text-5xl font-light text-foreground">
|
||||
{pomodoroStore.formattedTime}
|
||||
</span>
|
||||
<span class="mt-2 text-sm text-muted-foreground">
|
||||
{$_('pomodoro.sessionsCompleted', {
|
||||
values: {
|
||||
count: pomodoroStore.completedSessions,
|
||||
total: pomodoroStore.sessionsBeforeLongBreak,
|
||||
},
|
||||
})}
|
||||
</span>
|
||||
<div class="pomodoro-time">
|
||||
<span class="text-4xl font-light tabular-nums">{pomodoroStore.formattedTime}</span>
|
||||
<span class="text-xs text-muted-foreground mt-1">{phaseLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex gap-4">
|
||||
{#if pomodoroStore.isRunning}
|
||||
<button class="btn btn-secondary btn-xl" onclick={() => pomodoroStore.pause()}>
|
||||
{$_('pomodoro.pause')}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-primary btn-xl" onclick={() => pomodoroStore.start()}>
|
||||
{$_('pomodoro.start')}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn btn-ghost btn-xl" onclick={() => pomodoroStore.skip()}>
|
||||
{$_('pomodoro.skip')}
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xl" onclick={() => pomodoroStore.reset()}>
|
||||
{$_('pomodoro.reset')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sessions Progress -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Session dots -->
|
||||
<div class="flex justify-center gap-1.5 mb-4">
|
||||
{#each Array(pomodoroStore.sessionsBeforeLongBreak) as _, i}
|
||||
<div
|
||||
class="h-3 w-3 rounded-full transition-colors"
|
||||
class="h-2 w-2 rounded-full transition-colors"
|
||||
class:bg-primary={i <
|
||||
pomodoroStore.completedSessions % pomodoroStore.sessionsBeforeLongBreak}
|
||||
class:bg-muted={i >=
|
||||
|
|
@ -130,43 +86,51 @@
|
|||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Presets -->
|
||||
<div class="card w-full max-w-md">
|
||||
<h3 class="mb-3 text-sm font-medium text-muted-foreground">{$_('timer.presets')}</h3>
|
||||
<div class="grid gap-2 sm:grid-cols-3">
|
||||
{#each POMODORO_PRESETS as preset}
|
||||
<button class="btn btn-secondary btn-sm text-left" onclick={() => loadPreset(preset)}>
|
||||
<div>
|
||||
<div class="font-medium">{preset.nameDE}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{preset.workDuration / 60}:{preset.breakDuration / 60} min
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-center gap-2 mb-6">
|
||||
{#if pomodoroStore.isRunning}
|
||||
<button class="btn btn-secondary" onclick={() => pomodoroStore.pause()}> Pause </button>
|
||||
{:else}
|
||||
<button class="btn btn-primary" onclick={() => pomodoroStore.start()}> Start </button>
|
||||
{/if}
|
||||
<button class="btn btn-ghost" onclick={() => pomodoroStore.skip()}> Skip </button>
|
||||
<button class="btn btn-ghost" onclick={() => pomodoroStore.reset()}> Reset </button>
|
||||
</div>
|
||||
|
||||
<!-- Current Settings -->
|
||||
<div class="card w-full max-w-md">
|
||||
<h3 class="mb-3 text-sm font-medium text-muted-foreground">Aktuelle Einstellungen</h3>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-muted-foreground">{$_('pomodoro.settings.workDuration')}:</span>
|
||||
<span class="ml-1 font-medium">{pomodoroStore.settings.workDuration / 60} min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">{$_('pomodoro.settings.breakDuration')}:</span>
|
||||
<span class="ml-1 font-medium">{pomodoroStore.settings.breakDuration / 60} min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">{$_('pomodoro.settings.longBreakDuration')}:</span>
|
||||
<span class="ml-1 font-medium">{pomodoroStore.settings.longBreakDuration / 60} min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted-foreground">Sitzungen:</span>
|
||||
<span class="ml-1 font-medium">{pomodoroStore.settings.sessionsBeforeLongBreak}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Presets -->
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
{#each POMODORO_PRESETS as preset}
|
||||
<button class="alarm-tile text-center" onclick={() => loadPreset(preset)}>
|
||||
<span class="text-sm font-medium">{preset.nameDE}</span>
|
||||
<span class="text-[10px] text-muted-foreground block">
|
||||
{preset.workDuration / 60}/{preset.breakDuration / 60} min
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pomodoro-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.pomodoro-ring-wrapper {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pomodoro-time {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,19 +2,18 @@
|
|||
import { onMount, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { PageHeader } from '@manacore/shared-ui';
|
||||
import { timersStore } from '$lib/stores/timers.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { QUICK_TIMER_PRESETS, formatDuration } from '@clock/shared';
|
||||
|
||||
// Form state
|
||||
let showForm = $state(false);
|
||||
let formHours = $state(0);
|
||||
// Form state (inline on page)
|
||||
let formMinutes = $state(5);
|
||||
let formSeconds = $state(0);
|
||||
let formLabel = $state('');
|
||||
|
||||
// Local timers (for quick timers without backend)
|
||||
// Local timers
|
||||
interface LocalTimer {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
@ -24,56 +23,20 @@
|
|||
createdAt: Date;
|
||||
}
|
||||
let localTimers = $state<LocalTimer[]>([]);
|
||||
|
||||
// Recently used presets (stored in localStorage)
|
||||
let recentPresets = $state<{ label: string; seconds: number }[]>([]);
|
||||
const RECENT_STORAGE_KEY = 'clock-recent-timers';
|
||||
const MAX_RECENT = 5;
|
||||
|
||||
// Local countdown intervals
|
||||
let intervals: Map<string, ReturnType<typeof setInterval>> = new Map();
|
||||
|
||||
// Combined timers (backend + local)
|
||||
let allTimers = $derived([...timersStore.timers, ...localTimers]);
|
||||
|
||||
onMount(async () => {
|
||||
// Load recent presets from localStorage
|
||||
if (browser) {
|
||||
const saved = localStorage.getItem(RECENT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
recentPresets = JSON.parse(saved);
|
||||
} catch {
|
||||
recentPresets = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch backend timers if authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
await timersStore.fetchTimers();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clear all intervals
|
||||
intervals.forEach((interval) => clearInterval(interval));
|
||||
});
|
||||
|
||||
function saveRecentPreset(preset: { label: string; seconds: number }) {
|
||||
// Add to recent, removing duplicates
|
||||
recentPresets = [
|
||||
preset,
|
||||
...recentPresets.filter((p) => p.seconds !== preset.seconds),
|
||||
].slice(0, MAX_RECENT);
|
||||
|
||||
if (browser) {
|
||||
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(recentPresets));
|
||||
}
|
||||
}
|
||||
|
||||
function startLocalCountdown(timerId: string, isLocal: boolean = false) {
|
||||
// Clear existing interval if any
|
||||
if (intervals.has(timerId)) {
|
||||
clearInterval(intervals.get(timerId));
|
||||
}
|
||||
|
|
@ -90,7 +53,11 @@
|
|||
const newRemaining = Math.max(0, timer.remainingSeconds - 1);
|
||||
localTimers = localTimers.map((t) =>
|
||||
t.id === timerId
|
||||
? { ...t, remainingSeconds: newRemaining, status: newRemaining === 0 ? 'finished' : 'running' }
|
||||
? {
|
||||
...t,
|
||||
remainingSeconds: newRemaining,
|
||||
status: newRemaining === 0 ? 'finished' : 'running',
|
||||
}
|
||||
: t
|
||||
);
|
||||
|
||||
|
|
@ -98,7 +65,6 @@
|
|||
clearInterval(interval);
|
||||
intervals.delete(timerId);
|
||||
toast.success($_('timer.finished'));
|
||||
// Play notification sound
|
||||
if (browser && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Timer', { body: 'Timer abgelaufen!' });
|
||||
}
|
||||
|
|
@ -125,69 +91,30 @@
|
|||
intervals.set(timerId, interval);
|
||||
}
|
||||
|
||||
function openForm() {
|
||||
formHours = 0;
|
||||
formMinutes = 5;
|
||||
formSeconds = 0;
|
||||
formLabel = '';
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
async function createTimer() {
|
||||
const durationSeconds = formHours * 3600 + formMinutes * 60 + formSeconds;
|
||||
function createAndStartTimer() {
|
||||
const durationSeconds = formMinutes * 60 + formSeconds;
|
||||
if (durationSeconds <= 0) {
|
||||
toast.error('Bitte eine gültige Zeit eingeben');
|
||||
return;
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
const result = await timersStore.createTimer({
|
||||
durationSeconds,
|
||||
label: formLabel || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Timer erstellt');
|
||||
closeForm();
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Erstellen');
|
||||
}
|
||||
} else {
|
||||
// Create local timer
|
||||
const newTimer: LocalTimer = {
|
||||
id: crypto.randomUUID(),
|
||||
label: formLabel || formatDuration(durationSeconds),
|
||||
durationSeconds,
|
||||
remainingSeconds: durationSeconds,
|
||||
status: 'idle',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
localTimers = [...localTimers, newTimer];
|
||||
toast.success('Timer erstellt');
|
||||
closeForm();
|
||||
}
|
||||
}
|
||||
|
||||
function createQuickTimer(seconds: number, label: string) {
|
||||
// Save to recent
|
||||
saveRecentPreset({ label, seconds });
|
||||
|
||||
// Create local timer and start immediately
|
||||
const newTimer: LocalTimer = {
|
||||
id: crypto.randomUUID(),
|
||||
label: label,
|
||||
durationSeconds: seconds,
|
||||
remainingSeconds: seconds,
|
||||
label: formLabel || formatDuration(durationSeconds),
|
||||
durationSeconds,
|
||||
remainingSeconds: durationSeconds,
|
||||
status: 'running',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
localTimers = [...localTimers, newTimer];
|
||||
startLocalCountdown(newTimer.id, true);
|
||||
toast.success(`Timer ${label} gestartet`);
|
||||
toast.success('Timer gestartet');
|
||||
formLabel = '';
|
||||
}
|
||||
|
||||
function setPreset(seconds: number) {
|
||||
formMinutes = Math.floor(seconds / 60);
|
||||
formSeconds = seconds % 60;
|
||||
}
|
||||
|
||||
async function handleStart(id: string, isLocal: boolean) {
|
||||
|
|
@ -198,12 +125,7 @@
|
|||
startLocalCountdown(id, true);
|
||||
} else {
|
||||
const result = await timersStore.startTimer(id);
|
||||
if (result.success) {
|
||||
const timer = timersStore.timers.find((t) => t.id === id);
|
||||
if (timer) {
|
||||
startLocalCountdown(id, false);
|
||||
}
|
||||
}
|
||||
if (result.success) startLocalCountdown(id, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,11 +134,8 @@
|
|||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
localTimers = localTimers.map((t) =>
|
||||
t.id === id ? { ...t, status: 'paused' as const } : t
|
||||
);
|
||||
localTimers = localTimers.map((t) => (t.id === id ? { ...t, status: 'paused' as const } : t));
|
||||
} else {
|
||||
await timersStore.pauseTimer(id);
|
||||
}
|
||||
|
|
@ -227,7 +146,6 @@
|
|||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
localTimers = localTimers.map((t) =>
|
||||
t.id === id ? { ...t, remainingSeconds: t.durationSeconds, status: 'idle' as const } : t
|
||||
|
|
@ -242,15 +160,10 @@
|
|||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
localTimers = localTimers.filter((t) => t.id !== id);
|
||||
toast.success('Timer gelöscht');
|
||||
} else {
|
||||
const result = await timersStore.deleteTimer(id);
|
||||
if (result.success) {
|
||||
toast.success('Timer gelöscht');
|
||||
}
|
||||
await timersStore.deleteTimer(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -267,189 +180,120 @@
|
|||
function isLocalTimer(timer: any): boolean {
|
||||
return localTimers.some((t) => t.id === timer.id);
|
||||
}
|
||||
|
||||
// Preset icons based on duration
|
||||
function getPresetIcon(seconds: number): string {
|
||||
if (seconds <= 60) return '⚡';
|
||||
if (seconds <= 300) return '☕';
|
||||
if (seconds <= 900) return '📝';
|
||||
if (seconds <= 1800) return '💪';
|
||||
if (seconds <= 2700) return '🎯';
|
||||
return '🏃';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl space-y-8 pb-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-foreground">{$_('timer.title')}</h1>
|
||||
<p class="mt-2 text-muted-foreground">Schnelle Timer für jeden Anlass</p>
|
||||
</div>
|
||||
<PageHeader title={$_('timer.title')} size="md" centered />
|
||||
|
||||
<!-- Quick Timer Presets - Hero Section -->
|
||||
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 p-8">
|
||||
<h2 class="mb-6 text-center text-lg font-semibold text-foreground">
|
||||
{$_('timer.presets')}
|
||||
</h2>
|
||||
<div class="grid grid-cols-4 gap-4 sm:grid-cols-4 md:grid-cols-8">
|
||||
{#each QUICK_TIMER_PRESETS as preset}
|
||||
<button
|
||||
class="group flex flex-col items-center gap-2 rounded-2xl bg-background p-4 shadow-sm transition-all hover:scale-105 hover:shadow-md active:scale-95"
|
||||
onclick={() => createQuickTimer(preset.seconds, preset.label)}
|
||||
>
|
||||
<span class="text-2xl transition-transform group-hover:scale-110">
|
||||
{getPresetIcon(preset.seconds)}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-foreground">{preset.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<div class="space-y-4">
|
||||
<!-- Quick Create Form -->
|
||||
<div class="quick-create">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
class="time-input-inline w-12 text-center"
|
||||
min="0"
|
||||
max="99"
|
||||
bind:value={formMinutes}
|
||||
/>
|
||||
<span class="text-muted-foreground">:</span>
|
||||
<input
|
||||
type="number"
|
||||
class="time-input-inline w-12 text-center"
|
||||
min="0"
|
||||
max="59"
|
||||
bind:value={formSeconds}
|
||||
/>
|
||||
</div>
|
||||
<input type="text" class="label-input" placeholder="Bezeichnung" bind:value={formLabel} />
|
||||
<button class="btn btn-primary btn-sm" onclick={createAndStartTimer}> Start </button>
|
||||
</div>
|
||||
|
||||
<!-- Recently Used Section -->
|
||||
{#if recentPresets.length > 0}
|
||||
<div class="card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-foreground">Zuletzt verwendet</h3>
|
||||
<button
|
||||
class="text-sm text-muted-foreground hover:text-foreground"
|
||||
onclick={() => {
|
||||
recentPresets = [];
|
||||
if (browser) localStorage.removeItem(RECENT_STORAGE_KEY);
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each recentPresets as preset}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-full bg-muted px-4 py-2 text-sm font-medium transition-all hover:bg-primary hover:text-primary-foreground"
|
||||
onclick={() => createQuickTimer(preset.seconds, preset.label)}
|
||||
>
|
||||
<span class="text-lg">{getPresetIcon(preset.seconds)}</span>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Custom Timer Button -->
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="btn btn-primary btn-lg flex items-center gap-2 px-8"
|
||||
onclick={openForm}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{$_('timer.add')}
|
||||
</button>
|
||||
<!-- Quick Presets -->
|
||||
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
|
||||
{#each QUICK_TIMER_PRESETS as preset}
|
||||
<button class="alarm-tile text-center" onclick={() => setPreset(preset.seconds)}>
|
||||
<span class="text-lg font-light tabular-nums">{preset.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Active Timers -->
|
||||
<!-- Loading State -->
|
||||
{#if timersStore.loading}
|
||||
<div class="flex justify-center py-12">
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary border-r-transparent"></div>
|
||||
<div class="flex justify-center py-8">
|
||||
<div
|
||||
class="h-6 w-6 animate-spin rounded-full border-2 border-primary border-r-transparent"
|
||||
></div>
|
||||
</div>
|
||||
{:else if allTimers.length === 0}
|
||||
<div class="card bg-muted/30 py-16 text-center">
|
||||
<div class="mb-4 text-5xl opacity-50">⏱️</div>
|
||||
<p class="text-lg text-muted-foreground">{$_('timer.noTimers')}</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Klicke auf einen Preset oben oder erstelle einen eigenen Timer
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">
|
||||
Aktive Timer ({allTimers.length})
|
||||
</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{:else if allTimers.length > 0}
|
||||
<!-- Active Timers -->
|
||||
<div>
|
||||
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||
Aktiv ({allTimers.length})
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{#each allTimers as timer (timer.id)}
|
||||
{@const isLocal = isLocalTimer(timer)}
|
||||
<div
|
||||
class="card relative overflow-hidden transition-all {timer.status === 'running' ? 'ring-2 ring-primary' : ''} {timer.status === 'finished' ? 'bg-green-500/5' : ''}"
|
||||
>
|
||||
<!-- Progress background -->
|
||||
<div
|
||||
class="absolute inset-0 bg-primary/10 transition-all duration-1000"
|
||||
style="width: {getProgress(timer)}%"
|
||||
></div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Label -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-muted-foreground">
|
||||
{timer.label || 'Timer'}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
class:bg-primary={timer.status === 'running'}
|
||||
class:text-primary-foreground={timer.status === 'running'}
|
||||
class:bg-muted={timer.status !== 'running'}
|
||||
<div class="alarm-tile" class:active={timer.status === 'running'}>
|
||||
<div class="flex items-start justify-between mb-1">
|
||||
<span
|
||||
class="text-xl font-light tabular-nums"
|
||||
class:text-primary={timer.status === 'running'}
|
||||
class:text-green-500={timer.status === 'finished'}
|
||||
>
|
||||
{getTimerDisplay(timer)}
|
||||
</span>
|
||||
<button
|
||||
class="text-muted-foreground hover:text-error p-0.5 -mr-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(timer.id, isLocal);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
{timer.status === 'running' ? 'Läuft' : timer.status === 'paused' ? 'Pausiert' : timer.status === 'finished' ? 'Fertig' : 'Bereit'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Time Display -->
|
||||
<div class="mb-4 text-center">
|
||||
<span
|
||||
class="font-mono text-5xl font-light tracking-tight {timer.status === 'running' ? 'text-primary' : ''} {timer.status === 'finished' ? 'text-green-500' : ''}"
|
||||
>
|
||||
{getTimerDisplay(timer)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-4 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-1000 {timer.status === 'finished' ? 'bg-green-500' : 'bg-primary'}"
|
||||
style="width: {getProgress(timer)}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if timer.status === 'running'}
|
||||
<button
|
||||
class="btn btn-secondary flex-1"
|
||||
onclick={() => handlePause(timer.id, isLocal)}
|
||||
>
|
||||
⏸️ {$_('timer.pause')}
|
||||
</button>
|
||||
{:else if timer.status === 'finished'}
|
||||
<button
|
||||
class="btn btn-primary flex-1"
|
||||
onclick={() => handleReset(timer.id, isLocal)}
|
||||
>
|
||||
🔄 Neu starten
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-primary flex-1"
|
||||
onclick={() => handleStart(timer.id, isLocal)}
|
||||
>
|
||||
▶️ {$_('timer.start')}
|
||||
</button>
|
||||
{/if}
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground truncate mb-2">{timer.label}</div>
|
||||
<div class="h-1 bg-muted rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-1000"
|
||||
class:bg-primary={timer.status !== 'finished'}
|
||||
class:bg-green-500={timer.status === 'finished'}
|
||||
style="width: {getProgress(timer)}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
{#if timer.status === 'running'}
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onclick={() => handleReset(timer.id, isLocal)}
|
||||
title="Zurücksetzen"
|
||||
class="btn btn-secondary btn-sm flex-1 text-xs"
|
||||
onclick={() => handlePause(timer.id, isLocal)}
|
||||
>
|
||||
↺
|
||||
Pause
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-ghost text-destructive hover:bg-destructive/10"
|
||||
onclick={() => handleDelete(timer.id, isLocal)}
|
||||
title="Löschen"
|
||||
class="btn btn-primary btn-sm flex-1 text-xs"
|
||||
onclick={() => handleStart(timer.id, isLocal)}
|
||||
>
|
||||
🗑️
|
||||
{timer.status === 'finished' ? 'Neu' : 'Start'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-xs"
|
||||
onclick={() => handleReset(timer.id, isLocal)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -457,117 +301,3 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Form Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div class="card w-full max-w-md animate-in fade-in zoom-in-95">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">{$_('timer.add')}</h2>
|
||||
<button class="btn btn-ghost btn-sm" onclick={closeForm}>✕</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createTimer();
|
||||
}}
|
||||
>
|
||||
<!-- Duration -->
|
||||
<div class="mb-6">
|
||||
<label class="mb-3 block text-sm font-medium">{$_('timer.duration')}</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs text-muted-foreground text-center">{$_('timer.hours')}</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input text-center text-2xl font-light"
|
||||
min="0"
|
||||
max="99"
|
||||
bind:value={formHours}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs text-muted-foreground text-center">{$_('timer.minutes')}</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input text-center text-2xl font-light"
|
||||
min="0"
|
||||
max="59"
|
||||
bind:value={formMinutes}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs text-muted-foreground text-center">{$_('timer.seconds')}</label>
|
||||
<input
|
||||
type="number"
|
||||
class="input text-center text-2xl font-light"
|
||||
min="0"
|
||||
max="59"
|
||||
bind:value={formSeconds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick presets in modal -->
|
||||
<div class="mb-6">
|
||||
<label class="mb-2 block text-xs text-muted-foreground">Schnellauswahl</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each [1, 3, 5, 10, 15, 30] as mins}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-muted px-3 py-1 text-sm hover:bg-primary hover:text-primary-foreground"
|
||||
onclick={() => {
|
||||
formHours = 0;
|
||||
formMinutes = mins;
|
||||
formSeconds = 0;
|
||||
}}
|
||||
>
|
||||
{mins} min
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="mb-6">
|
||||
<label class="mb-1.5 block text-sm font-medium">{$_('timer.label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="z.B. Tee kochen, Pause, Meeting..."
|
||||
bind:value={formLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button type="button" class="btn btn-secondary flex-1" onclick={closeForm}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary flex-1">
|
||||
Timer erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.animate-in {
|
||||
animation: animate-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PageHeader } from '@manacore/shared-ui';
|
||||
import { worldClocksStore } from '$lib/stores/world-clocks.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from '$lib/stores/toast';
|
||||
import { POPULAR_TIMEZONES } from '@clock/shared';
|
||||
import WorldMap from '$lib/components/WorldMap.svelte';
|
||||
|
||||
// State
|
||||
let showAddModal = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let currentTime = $state(new Date());
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
let showMap = $state(true);
|
||||
|
||||
// Selected city timezones for map highlighting
|
||||
let selectedTimezones = $derived(worldClocksStore.worldClocks.map((wc) => wc.timezone));
|
||||
|
||||
// Handle map city click
|
||||
function handleMapCityClick(timezone: string, cityName: string) {
|
||||
const alreadyAdded = worldClocksStore.worldClocks.some((wc) => wc.timezone === timezone);
|
||||
if (alreadyAdded) {
|
||||
toast.info(`${cityName} ist bereits hinzugefügt`);
|
||||
} else {
|
||||
addCity(timezone, cityName);
|
||||
}
|
||||
}
|
||||
|
||||
// Filtered timezones based on search
|
||||
let filteredTimezones = $derived(
|
||||
|
|
@ -139,14 +155,53 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('worldClock.title')}</h1>
|
||||
<button class="btn btn-primary" onclick={openAddModal}>
|
||||
+ {$_('worldClock.add')}
|
||||
</button>
|
||||
</div>
|
||||
<PageHeader title={$_('worldClock.title')} size="md" centered>
|
||||
{#snippet actions()}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm px-2"
|
||||
onclick={() => (showMap = !showMap)}
|
||||
title={showMap ? 'Karte ausblenden' : 'Karte anzeigen'}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick={openAddModal}>
|
||||
+ {$_('worldClock.add')}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="world-clock-page">
|
||||
<!-- World Map (Full Width) -->
|
||||
{#if showMap}
|
||||
<div class="map-section">
|
||||
<div class="map-container">
|
||||
<WorldMap
|
||||
selectedCities={selectedTimezones}
|
||||
onCityClick={handleMapCityClick}
|
||||
{currentTime}
|
||||
/>
|
||||
</div>
|
||||
<p class="text-center text-xs text-muted-foreground py-2">
|
||||
Klicke auf eine Stadt um sie hinzuzufügen
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- World Clock List -->
|
||||
{#if worldClocksStore.loading}
|
||||
|
|
@ -169,15 +224,26 @@
|
|||
<div class="world-clock-card relative">
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
class="absolute right-3 top-3 text-muted-foreground hover:text-error"
|
||||
class="absolute right-3 top-3 text-muted-foreground hover:text-error p-0.5"
|
||||
onclick={() => removeCity(clock.id)}
|
||||
>
|
||||
✕
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Day/Night indicator -->
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-xl">{isDay ? '☀️' : '🌙'}</span>
|
||||
<span class="text-xs text-muted-foreground">{isDay ? 'Tag' : 'Nacht'}</span>
|
||||
<span class="city-name">{clock.cityName}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -206,8 +272,19 @@
|
|||
<div class="card w-full max-w-md max-h-[80vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">{$_('worldClock.add')}</h2>
|
||||
<button class="text-muted-foreground hover:text-foreground" onclick={closeAddModal}>
|
||||
✕
|
||||
<button class="text-muted-foreground hover:text-foreground p-0.5" onclick={closeAddModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -252,3 +329,65 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.world-clock-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.map-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 -1rem 1rem -1rem;
|
||||
background: hsl(var(--color-card));
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-container :global(.world-map-container) {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.map-section {
|
||||
margin: 0 -1.5rem 1.5rem -1.5rem;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
.world-clock-card {
|
||||
background: hsl(var(--color-card));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.city-name {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: hsl(var(--color-foreground));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timezone-info {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue