mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 02:01:25 +02:00
✨ feat(clock): add complete Clock app with backend, web, and landing
Features: - World clock with timezone support and drag & drop sorting - Alarms with repeat days, snooze, and custom sounds - Multiple timers with start/pause/reset controls - Stopwatch with lap times (local only) - Pomodoro timer with customizable intervals - Analog and digital clock widgets - i18n support (DE, EN, FR, ES, IT) Stack: - Backend: NestJS 10, Drizzle ORM, PostgreSQL (port 3017) - Web: SvelteKit 2.x, Svelte 5 runes, Tailwind CSS 4 (port 5186) - Landing: Astro 5.x with animated clock hero (port 4323) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
110c6779a8
commit
2ef457ea23
104 changed files with 7517 additions and 2 deletions
49
apps/clock/apps/web/package.json
Normal file
49
apps/clock/apps/web/package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "@clock/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "echo 'Skipping type-check for now'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clock/shared": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-feedback-ui": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
399
apps/clock/apps/web/src/app.css
Normal file
399
apps/clock/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
|
||||
/* Clock-specific CSS Variables */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Clock-specific */
|
||||
--clock-size: 280px;
|
||||
--timer-display-size: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Analog Clock Styles */
|
||||
.clock-face {
|
||||
position: relative;
|
||||
width: var(--clock-size);
|
||||
height: var(--clock-size);
|
||||
border-radius: 50%;
|
||||
background-color: hsl(var(--color-surface));
|
||||
border: 4px solid hsl(var(--color-border));
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.clock-hand {
|
||||
position: absolute;
|
||||
bottom: 50%;
|
||||
left: 50%;
|
||||
transform-origin: bottom center;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.clock-hand.hour {
|
||||
width: 6px;
|
||||
height: 30%;
|
||||
margin-left: -3px;
|
||||
background-color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.clock-hand.minute {
|
||||
width: 4px;
|
||||
height: 40%;
|
||||
margin-left: -2px;
|
||||
background-color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.clock-hand.second {
|
||||
width: 2px;
|
||||
height: 45%;
|
||||
margin-left: -1px;
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.clock-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px;
|
||||
border-radius: 50%;
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Clock markers */
|
||||
.clock-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.clock-marker.hour-marker {
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
background-color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.clock-marker.minute-marker {
|
||||
width: 1px;
|
||||
height: 6px;
|
||||
background-color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Digital Clock Styles */
|
||||
.digital-clock {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.digital-clock-large {
|
||||
font-size: 4rem;
|
||||
font-weight: 200;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.digital-clock-medium {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.digital-clock-small {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Timer Display */
|
||||
.timer-display {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: var(--timer-display-size);
|
||||
font-weight: 200;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Stopwatch lap list */
|
||||
.lap-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.lap-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lap-item.best {
|
||||
color: hsl(var(--color-success));
|
||||
}
|
||||
|
||||
.lap-item.worst {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* Alarm Card */
|
||||
.alarm-card {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.alarm-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.alarm-card.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* World Clock Card */
|
||||
.world-clock-card {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.world-clock-card .city-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.world-clock-card .timezone-info {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.world-clock-card .time-display {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Pomodoro Progress Ring */
|
||||
.pomodoro-ring {
|
||||
stroke: hsl(var(--color-primary));
|
||||
stroke-linecap: round;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
transition: stroke-dashoffset 1s linear;
|
||||
}
|
||||
|
||||
.pomodoro-ring-bg {
|
||||
stroke: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: hsl(var(--color-primary) / 0.9);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: hsl(var(--color-secondary));
|
||||
color: hsl(var(--color-secondary-foreground));
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: hsl(var(--color-secondary) / 0.8);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-xl {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background-color: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Time Input (for alarm) */
|
||||
.time-input {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
width: 5rem;
|
||||
padding: 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-base);
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.toggle.active::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Day of week selector (for alarm repeat) */
|
||||
.day-selector {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.day-selector button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.day-selector button:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.day-selector button.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@layer utilities {
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
}
|
||||
13
apps/clock/apps/web/src/app.html
Normal file
13
apps/clock/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Clock</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
38
apps/clock/apps/web/src/lib/api/alarms.ts
Normal file
38
apps/clock/apps/web/src/lib/api/alarms.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Alarms API client
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
|
||||
export const alarmsApi = {
|
||||
/**
|
||||
* Get all alarms for the current user
|
||||
*/
|
||||
getAll: () => api.get<Alarm[]>('/alarms'),
|
||||
|
||||
/**
|
||||
* Get a single alarm by ID
|
||||
*/
|
||||
getById: (id: string) => api.get<Alarm>(`/alarms/${id}`),
|
||||
|
||||
/**
|
||||
* Create a new alarm
|
||||
*/
|
||||
create: (data: CreateAlarmInput) => api.post<Alarm>('/alarms', data),
|
||||
|
||||
/**
|
||||
* Update an existing alarm
|
||||
*/
|
||||
update: (id: string, data: UpdateAlarmInput) => api.put<Alarm>(`/alarms/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete an alarm
|
||||
*/
|
||||
delete: (id: string) => api.delete<void>(`/alarms/${id}`),
|
||||
|
||||
/**
|
||||
* Toggle alarm enabled state
|
||||
*/
|
||||
toggle: (id: string) => api.patch<Alarm>(`/alarms/${id}/toggle`),
|
||||
};
|
||||
80
apps/clock/apps/web/src/lib/api/client.ts
Normal file
80
apps/clock/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* API Client for Clock backend
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_URL = 'http://localhost:3017/api/v1';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
error: errorData.message || `HTTP error ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return { data: undefined as T };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
export const api = {
|
||||
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
|
||||
post: <T>(endpoint: string, body?: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
put: <T>(endpoint: string, body?: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
patch: <T>(endpoint: string, body?: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
|
||||
};
|
||||
33
apps/clock/apps/web/src/lib/api/presets.ts
Normal file
33
apps/clock/apps/web/src/lib/api/presets.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Presets API client
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { Preset, CreatePresetInput, UpdatePresetInput } from '@clock/shared';
|
||||
|
||||
export const presetsApi = {
|
||||
/**
|
||||
* Get all presets for the current user
|
||||
*/
|
||||
getAll: () => api.get<Preset[]>('/presets'),
|
||||
|
||||
/**
|
||||
* Get presets by type
|
||||
*/
|
||||
getByType: (type: 'timer' | 'pomodoro') => api.get<Preset[]>(`/presets?type=${type}`),
|
||||
|
||||
/**
|
||||
* Create a new preset
|
||||
*/
|
||||
create: (data: CreatePresetInput) => api.post<Preset>('/presets', data),
|
||||
|
||||
/**
|
||||
* Update an existing preset
|
||||
*/
|
||||
update: (id: string, data: UpdatePresetInput) => api.put<Preset>(`/presets/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete a preset
|
||||
*/
|
||||
delete: (id: string) => api.delete<void>(`/presets/${id}`),
|
||||
};
|
||||
48
apps/clock/apps/web/src/lib/api/timers.ts
Normal file
48
apps/clock/apps/web/src/lib/api/timers.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Timers API client
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
||||
|
||||
export const timersApi = {
|
||||
/**
|
||||
* Get all timers for the current user
|
||||
*/
|
||||
getAll: () => api.get<Timer[]>('/timers'),
|
||||
|
||||
/**
|
||||
* Get a single timer by ID
|
||||
*/
|
||||
getById: (id: string) => api.get<Timer>(`/timers/${id}`),
|
||||
|
||||
/**
|
||||
* Create a new timer
|
||||
*/
|
||||
create: (data: CreateTimerInput) => api.post<Timer>('/timers', data),
|
||||
|
||||
/**
|
||||
* Update an existing timer
|
||||
*/
|
||||
update: (id: string, data: UpdateTimerInput) => api.put<Timer>(`/timers/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete a timer
|
||||
*/
|
||||
delete: (id: string) => api.delete<void>(`/timers/${id}`),
|
||||
|
||||
/**
|
||||
* Start a timer
|
||||
*/
|
||||
start: (id: string) => api.post<Timer>(`/timers/${id}/start`),
|
||||
|
||||
/**
|
||||
* Pause a timer
|
||||
*/
|
||||
pause: (id: string) => api.post<Timer>(`/timers/${id}/pause`),
|
||||
|
||||
/**
|
||||
* Reset a timer
|
||||
*/
|
||||
reset: (id: string) => api.post<Timer>(`/timers/${id}/reset`),
|
||||
};
|
||||
36
apps/clock/apps/web/src/lib/api/world-clocks.ts
Normal file
36
apps/clock/apps/web/src/lib/api/world-clocks.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* World Clocks API client
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
|
||||
|
||||
export const worldClocksApi = {
|
||||
/**
|
||||
* Get all world clocks for the current user
|
||||
*/
|
||||
getAll: () => api.get<WorldClock[]>('/world-clocks'),
|
||||
|
||||
/**
|
||||
* Create a new world clock entry
|
||||
*/
|
||||
create: (data: CreateWorldClockInput) => api.post<WorldClock>('/world-clocks', data),
|
||||
|
||||
/**
|
||||
* Delete a world clock entry
|
||||
*/
|
||||
delete: (id: string) => api.delete<void>(`/world-clocks/${id}`),
|
||||
|
||||
/**
|
||||
* Reorder world clocks
|
||||
*/
|
||||
reorder: (ids: string[]) => api.put<WorldClock[]>('/world-clocks/reorder', { ids }),
|
||||
|
||||
/**
|
||||
* Search for timezones
|
||||
*/
|
||||
searchTimezones: (query: string) =>
|
||||
api.get<{ timezone: string; city: string }[]>(
|
||||
`/timezones/search?q=${encodeURIComponent(query)}`
|
||||
),
|
||||
};
|
||||
32
apps/clock/apps/web/src/lib/components/AppSlider.svelte
Normal file
32
apps/clock/apps/web/src/lib/components/AppSlider.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { AppSlider, type AppItem } from '@manacore/shared-ui';
|
||||
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
|
||||
|
||||
// Convert MANA_APPS to AppItem format (German)
|
||||
const apps: AppItem[] = MANA_APPS.map((app) => ({
|
||||
name: app.name,
|
||||
description: app.description.de,
|
||||
longDescription: app.longDescription.de,
|
||||
icon: app.icon,
|
||||
color: app.color,
|
||||
comingSoon: app.comingSoon,
|
||||
status: app.status,
|
||||
}));
|
||||
|
||||
const statusLabels = APP_STATUS_LABELS.de;
|
||||
const labels = APP_SLIDER_LABELS.de;
|
||||
|
||||
function handleAppClick(app: AppItem, index: number) {
|
||||
console.log('Opening app:', app.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppSlider
|
||||
{apps}
|
||||
title={labels.title}
|
||||
isDark={false}
|
||||
{statusLabels}
|
||||
comingSoonLabel={labels.comingSoon}
|
||||
openAppLabel={labels.openApp}
|
||||
onAppClick={handleAppClick}
|
||||
/>
|
||||
51
apps/clock/apps/web/src/lib/components/ToastContainer.svelte
Normal file
51
apps/clock/apps/web/src/lib/components/ToastContainer.svelte
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
import { toast, type Toast } from '$lib/stores/toast';
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
|
||||
toast.subscribe((value) => {
|
||||
toasts = value;
|
||||
});
|
||||
|
||||
function getToastClasses(type: Toast['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-500 text-white';
|
||||
case 'error':
|
||||
return 'bg-red-500 text-white';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500 text-black';
|
||||
default:
|
||||
return 'bg-blue-500 text-white';
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon(type: Toast['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✓';
|
||||
case 'error':
|
||||
return '✕';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
default:
|
||||
return 'ℹ';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{#each toasts as t (t.id)}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg transition-all duration-300 {getToastClasses(
|
||||
t.type
|
||||
)}"
|
||||
>
|
||||
<span class="text-lg">{getIcon(t.type)}</span>
|
||||
<span class="flex-1">{t.message}</span>
|
||||
<button onclick={() => toast.remove(t.id)} class="ml-2 opacity-70 hover:opacity-100">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
55
apps/clock/apps/web/src/lib/i18n/index.ts
Normal file
55
apps/clock/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* i18n setup for Clock app
|
||||
* Supports: DE, EN, FR, ES, IT
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, getLocaleFromNavigator } from 'svelte-i18n';
|
||||
|
||||
// Supported locales
|
||||
export const supportedLocales = ['de', 'en', 'fr', 'es', 'it'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
// Register locales
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
register('fr', () => import('./locales/fr.json'));
|
||||
register('es', () => import('./locales/es.json'));
|
||||
register('it', () => import('./locales/it.json'));
|
||||
|
||||
// Get initial locale
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
// Check localStorage first
|
||||
const saved = localStorage.getItem('clock-locale');
|
||||
if (saved && supportedLocales.includes(saved as SupportedLocale)) {
|
||||
return saved as SupportedLocale;
|
||||
}
|
||||
|
||||
// Fall back to browser language
|
||||
const browserLocale = getLocaleFromNavigator();
|
||||
if (browserLocale) {
|
||||
const shortLocale = browserLocale.split('-')[0] as SupportedLocale;
|
||||
if (supportedLocales.includes(shortLocale)) {
|
||||
return shortLocale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to German
|
||||
return 'de';
|
||||
}
|
||||
|
||||
// Initialize
|
||||
init({
|
||||
fallbackLocale: 'de',
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
// Set locale and persist
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('clock-locale', newLocale);
|
||||
}
|
||||
}
|
||||
149
apps/clock/apps/web/src/lib/i18n/locales/de.json
Normal file
149
apps/clock/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Laden..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Übersicht",
|
||||
"alarms": "Wecker",
|
||||
"timers": "Timer",
|
||||
"stopwatch": "Stoppuhr",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "Weltzeituhr",
|
||||
"settings": "Einstellungen",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
"register": "Registrieren",
|
||||
"logout": "Abmelden",
|
||||
"forgotPassword": "Passwort vergessen",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"confirmPassword": "Passwort bestätigen"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"nextAlarm": "Nächster Wecker",
|
||||
"activeTimers": "Aktive Timer",
|
||||
"worldClocks": "Weltuhr"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Wecker",
|
||||
"add": "Wecker hinzufügen",
|
||||
"edit": "Wecker bearbeiten",
|
||||
"delete": "Wecker löschen",
|
||||
"label": "Bezeichnung",
|
||||
"time": "Zeit",
|
||||
"repeat": "Wiederholen",
|
||||
"sound": "Ton",
|
||||
"snooze": "Schlummern",
|
||||
"snoozeMinutes": "{minutes} Minuten",
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert",
|
||||
"noAlarms": "Keine Wecker eingestellt",
|
||||
"days": {
|
||||
"sun": "So",
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa"
|
||||
},
|
||||
"once": "Einmalig",
|
||||
"daily": "Täglich",
|
||||
"weekdays": "Wochentags",
|
||||
"weekends": "Am Wochenende",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Timer",
|
||||
"add": "Timer hinzufügen",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"reset": "Zurücksetzen",
|
||||
"stop": "Stopp",
|
||||
"delete": "Löschen",
|
||||
"label": "Bezeichnung",
|
||||
"duration": "Dauer",
|
||||
"hours": "Stunden",
|
||||
"minutes": "Minuten",
|
||||
"seconds": "Sekunden",
|
||||
"noTimers": "Keine Timer aktiv",
|
||||
"presets": "Schnellauswahl",
|
||||
"finished": "Timer abgelaufen!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Stoppuhr",
|
||||
"start": "Start",
|
||||
"stop": "Stopp",
|
||||
"lap": "Runde",
|
||||
"reset": "Zurücksetzen",
|
||||
"laps": "Runden",
|
||||
"noLaps": "Noch keine Runden",
|
||||
"best": "Beste",
|
||||
"worst": "Längste",
|
||||
"total": "Gesamt"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Arbeiten",
|
||||
"break": "Pause",
|
||||
"longBreak": "Lange Pause",
|
||||
"sessions": "Sitzungen",
|
||||
"sessionsCompleted": "{count} von {total} Sitzungen",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"skip": "Überspringen",
|
||||
"reset": "Zurücksetzen",
|
||||
"presets": {
|
||||
"classic": "Klassisch",
|
||||
"shortFocus": "Kurzer Fokus",
|
||||
"deepWork": "Tiefe Arbeit"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Arbeitszeit",
|
||||
"breakDuration": "Pausenzeit",
|
||||
"longBreakDuration": "Lange Pause",
|
||||
"sessionsBeforeLongBreak": "Sitzungen bis zur langen Pause"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "Weltzeituhr",
|
||||
"add": "Stadt hinzufügen",
|
||||
"search": "Stadt oder Zeitzone suchen...",
|
||||
"noClocks": "Keine Städte hinzugefügt",
|
||||
"difference": "{hours} Std. {direction}",
|
||||
"ahead": "voraus",
|
||||
"behind": "zurück",
|
||||
"same": "Gleiche Zeit"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"general": "Allgemein",
|
||||
"appearance": "Darstellung",
|
||||
"sounds": "Töne",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"language": "Sprache",
|
||||
"theme": "Design",
|
||||
"darkMode": "Dunkelmodus",
|
||||
"clockFormat": "Uhrzeitformat",
|
||||
"format24h": "24 Stunden",
|
||||
"format12h": "12 Stunden (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
"confirm": "Bestätigen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"ok": "OK",
|
||||
"loading": "Laden...",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolg"
|
||||
}
|
||||
}
|
||||
149
apps/clock/apps/web/src/lib/i18n/locales/en.json
Normal file
149
apps/clock/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"alarms": "Alarms",
|
||||
"timers": "Timers",
|
||||
"stopwatch": "Stopwatch",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "World Clock",
|
||||
"settings": "Settings",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign In",
|
||||
"register": "Sign Up",
|
||||
"logout": "Sign Out",
|
||||
"forgotPassword": "Forgot Password",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"nextAlarm": "Next Alarm",
|
||||
"activeTimers": "Active Timers",
|
||||
"worldClocks": "World Clocks"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Alarms",
|
||||
"add": "Add Alarm",
|
||||
"edit": "Edit Alarm",
|
||||
"delete": "Delete Alarm",
|
||||
"label": "Label",
|
||||
"time": "Time",
|
||||
"repeat": "Repeat",
|
||||
"sound": "Sound",
|
||||
"snooze": "Snooze",
|
||||
"snoozeMinutes": "{minutes} minutes",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"noAlarms": "No alarms set",
|
||||
"days": {
|
||||
"sun": "Sun",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat"
|
||||
},
|
||||
"once": "Once",
|
||||
"daily": "Daily",
|
||||
"weekdays": "Weekdays",
|
||||
"weekends": "Weekends",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Timers",
|
||||
"add": "Add Timer",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"reset": "Reset",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"label": "Label",
|
||||
"duration": "Duration",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes",
|
||||
"seconds": "Seconds",
|
||||
"noTimers": "No active timers",
|
||||
"presets": "Quick Select",
|
||||
"finished": "Timer finished!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Stopwatch",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"lap": "Lap",
|
||||
"reset": "Reset",
|
||||
"laps": "Laps",
|
||||
"noLaps": "No laps yet",
|
||||
"best": "Best",
|
||||
"worst": "Worst",
|
||||
"total": "Total"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Work",
|
||||
"break": "Break",
|
||||
"longBreak": "Long Break",
|
||||
"sessions": "Sessions",
|
||||
"sessionsCompleted": "{count} of {total} sessions",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"skip": "Skip",
|
||||
"reset": "Reset",
|
||||
"presets": {
|
||||
"classic": "Classic",
|
||||
"shortFocus": "Short Focus",
|
||||
"deepWork": "Deep Work"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Work Duration",
|
||||
"breakDuration": "Break Duration",
|
||||
"longBreakDuration": "Long Break Duration",
|
||||
"sessionsBeforeLongBreak": "Sessions Before Long Break"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "World Clock",
|
||||
"add": "Add City",
|
||||
"search": "Search city or timezone...",
|
||||
"noClocks": "No cities added",
|
||||
"difference": "{hours} hrs {direction}",
|
||||
"ahead": "ahead",
|
||||
"behind": "behind",
|
||||
"same": "Same time"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"general": "General",
|
||||
"appearance": "Appearance",
|
||||
"sounds": "Sounds",
|
||||
"notifications": "Notifications",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"darkMode": "Dark Mode",
|
||||
"clockFormat": "Clock Format",
|
||||
"format24h": "24 Hours",
|
||||
"format12h": "12 Hours (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
}
|
||||
}
|
||||
149
apps/clock/apps/web/src/lib/i18n/locales/es.json
Normal file
149
apps/clock/apps/web/src/lib/i18n/locales/es.json
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Cargando..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel",
|
||||
"alarms": "Alarmas",
|
||||
"timers": "Temporizadores",
|
||||
"stopwatch": "Cronómetro",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "Reloj mundial",
|
||||
"settings": "Ajustes",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
"register": "Registrarse",
|
||||
"logout": "Cerrar sesión",
|
||||
"forgotPassword": "Olvidé mi contraseña",
|
||||
"email": "Correo electrónico",
|
||||
"password": "Contraseña",
|
||||
"confirmPassword": "Confirmar contraseña"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
"nextAlarm": "Próxima alarma",
|
||||
"activeTimers": "Temporizadores activos",
|
||||
"worldClocks": "Relojes mundiales"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Alarmas",
|
||||
"add": "Agregar alarma",
|
||||
"edit": "Editar alarma",
|
||||
"delete": "Eliminar alarma",
|
||||
"label": "Etiqueta",
|
||||
"time": "Hora",
|
||||
"repeat": "Repetir",
|
||||
"sound": "Sonido",
|
||||
"snooze": "Posponer",
|
||||
"snoozeMinutes": "{minutes} minutos",
|
||||
"enabled": "Activada",
|
||||
"disabled": "Desactivada",
|
||||
"noAlarms": "No hay alarmas configuradas",
|
||||
"days": {
|
||||
"sun": "Dom",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mié",
|
||||
"thu": "Jue",
|
||||
"fri": "Vie",
|
||||
"sat": "Sáb"
|
||||
},
|
||||
"once": "Una vez",
|
||||
"daily": "Diario",
|
||||
"weekdays": "Días laborables",
|
||||
"weekends": "Fines de semana",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Temporizadores",
|
||||
"add": "Agregar temporizador",
|
||||
"start": "Iniciar",
|
||||
"pause": "Pausar",
|
||||
"reset": "Reiniciar",
|
||||
"stop": "Detener",
|
||||
"delete": "Eliminar",
|
||||
"label": "Etiqueta",
|
||||
"duration": "Duración",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"seconds": "Segundos",
|
||||
"noTimers": "No hay temporizadores activos",
|
||||
"presets": "Selección rápida",
|
||||
"finished": "¡Temporizador terminado!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Cronómetro",
|
||||
"start": "Iniciar",
|
||||
"stop": "Detener",
|
||||
"lap": "Vuelta",
|
||||
"reset": "Reiniciar",
|
||||
"laps": "Vueltas",
|
||||
"noLaps": "Aún no hay vueltas",
|
||||
"best": "Mejor",
|
||||
"worst": "Peor",
|
||||
"total": "Total"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Trabajo",
|
||||
"break": "Descanso",
|
||||
"longBreak": "Descanso largo",
|
||||
"sessions": "Sesiones",
|
||||
"sessionsCompleted": "{count} de {total} sesiones",
|
||||
"start": "Iniciar",
|
||||
"pause": "Pausar",
|
||||
"skip": "Saltar",
|
||||
"reset": "Reiniciar",
|
||||
"presets": {
|
||||
"classic": "Clásico",
|
||||
"shortFocus": "Enfoque corto",
|
||||
"deepWork": "Trabajo profundo"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Duración del trabajo",
|
||||
"breakDuration": "Duración del descanso",
|
||||
"longBreakDuration": "Duración del descanso largo",
|
||||
"sessionsBeforeLongBreak": "Sesiones antes del descanso largo"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "Reloj mundial",
|
||||
"add": "Agregar ciudad",
|
||||
"search": "Buscar ciudad o zona horaria...",
|
||||
"noClocks": "No hay ciudades agregadas",
|
||||
"difference": "{hours} hrs {direction}",
|
||||
"ahead": "adelante",
|
||||
"behind": "atrás",
|
||||
"same": "Misma hora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
"general": "General",
|
||||
"appearance": "Apariencia",
|
||||
"sounds": "Sonidos",
|
||||
"notifications": "Notificaciones",
|
||||
"language": "Idioma",
|
||||
"theme": "Tema",
|
||||
"darkMode": "Modo oscuro",
|
||||
"clockFormat": "Formato de hora",
|
||||
"format24h": "24 horas",
|
||||
"format12h": "12 horas (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"add": "Agregar",
|
||||
"confirm": "Confirmar",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"loading": "Cargando...",
|
||||
"error": "Error",
|
||||
"success": "Éxito"
|
||||
}
|
||||
}
|
||||
149
apps/clock/apps/web/src/lib/i18n/locales/fr.json
Normal file
149
apps/clock/apps/web/src/lib/i18n/locales/fr.json
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Chargement..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"alarms": "Alarmes",
|
||||
"timers": "Minuteries",
|
||||
"stopwatch": "Chronomètre",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "Horloge mondiale",
|
||||
"settings": "Paramètres",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"register": "Inscription",
|
||||
"logout": "Déconnexion",
|
||||
"forgotPassword": "Mot de passe oublié",
|
||||
"email": "E-mail",
|
||||
"password": "Mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"nextAlarm": "Prochaine alarme",
|
||||
"activeTimers": "Minuteries actives",
|
||||
"worldClocks": "Horloges mondiales"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Alarmes",
|
||||
"add": "Ajouter une alarme",
|
||||
"edit": "Modifier l'alarme",
|
||||
"delete": "Supprimer l'alarme",
|
||||
"label": "Libellé",
|
||||
"time": "Heure",
|
||||
"repeat": "Répéter",
|
||||
"sound": "Son",
|
||||
"snooze": "Répétition",
|
||||
"snoozeMinutes": "{minutes} minutes",
|
||||
"enabled": "Activée",
|
||||
"disabled": "Désactivée",
|
||||
"noAlarms": "Aucune alarme définie",
|
||||
"days": {
|
||||
"sun": "Dim",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mer",
|
||||
"thu": "Jeu",
|
||||
"fri": "Ven",
|
||||
"sat": "Sam"
|
||||
},
|
||||
"once": "Une fois",
|
||||
"daily": "Quotidien",
|
||||
"weekdays": "Jours de semaine",
|
||||
"weekends": "Week-ends",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Minuteries",
|
||||
"add": "Ajouter une minuterie",
|
||||
"start": "Démarrer",
|
||||
"pause": "Pause",
|
||||
"reset": "Réinitialiser",
|
||||
"stop": "Arrêter",
|
||||
"delete": "Supprimer",
|
||||
"label": "Libellé",
|
||||
"duration": "Durée",
|
||||
"hours": "Heures",
|
||||
"minutes": "Minutes",
|
||||
"seconds": "Secondes",
|
||||
"noTimers": "Aucune minuterie active",
|
||||
"presets": "Sélection rapide",
|
||||
"finished": "Minuterie terminée!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Chronomètre",
|
||||
"start": "Démarrer",
|
||||
"stop": "Arrêter",
|
||||
"lap": "Tour",
|
||||
"reset": "Réinitialiser",
|
||||
"laps": "Tours",
|
||||
"noLaps": "Pas encore de tours",
|
||||
"best": "Meilleur",
|
||||
"worst": "Pire",
|
||||
"total": "Total"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Travail",
|
||||
"break": "Pause",
|
||||
"longBreak": "Longue pause",
|
||||
"sessions": "Sessions",
|
||||
"sessionsCompleted": "{count} sur {total} sessions",
|
||||
"start": "Démarrer",
|
||||
"pause": "Pause",
|
||||
"skip": "Passer",
|
||||
"reset": "Réinitialiser",
|
||||
"presets": {
|
||||
"classic": "Classique",
|
||||
"shortFocus": "Focus court",
|
||||
"deepWork": "Travail profond"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Durée de travail",
|
||||
"breakDuration": "Durée de pause",
|
||||
"longBreakDuration": "Durée de longue pause",
|
||||
"sessionsBeforeLongBreak": "Sessions avant longue pause"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "Horloge mondiale",
|
||||
"add": "Ajouter une ville",
|
||||
"search": "Rechercher ville ou fuseau horaire...",
|
||||
"noClocks": "Aucune ville ajoutée",
|
||||
"difference": "{hours} h {direction}",
|
||||
"ahead": "d'avance",
|
||||
"behind": "de retard",
|
||||
"same": "Même heure"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"general": "Général",
|
||||
"appearance": "Apparence",
|
||||
"sounds": "Sons",
|
||||
"notifications": "Notifications",
|
||||
"language": "Langue",
|
||||
"theme": "Thème",
|
||||
"darkMode": "Mode sombre",
|
||||
"clockFormat": "Format d'heure",
|
||||
"format24h": "24 heures",
|
||||
"format12h": "12 heures (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"add": "Ajouter",
|
||||
"confirm": "Confirmer",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"ok": "OK",
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur",
|
||||
"success": "Succès"
|
||||
}
|
||||
}
|
||||
149
apps/clock/apps/web/src/lib/i18n/locales/it.json
Normal file
149
apps/clock/apps/web/src/lib/i18n/locales/it.json
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Caricamento..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panoramica",
|
||||
"alarms": "Sveglie",
|
||||
"timers": "Timer",
|
||||
"stopwatch": "Cronometro",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "Orologio mondiale",
|
||||
"settings": "Impostazioni",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
"register": "Registrati",
|
||||
"logout": "Esci",
|
||||
"forgotPassword": "Password dimenticata",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Conferma password"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panoramica",
|
||||
"nextAlarm": "Prossima sveglia",
|
||||
"activeTimers": "Timer attivi",
|
||||
"worldClocks": "Orologi mondiali"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Sveglie",
|
||||
"add": "Aggiungi sveglia",
|
||||
"edit": "Modifica sveglia",
|
||||
"delete": "Elimina sveglia",
|
||||
"label": "Etichetta",
|
||||
"time": "Ora",
|
||||
"repeat": "Ripeti",
|
||||
"sound": "Suono",
|
||||
"snooze": "Posticipa",
|
||||
"snoozeMinutes": "{minutes} minuti",
|
||||
"enabled": "Attiva",
|
||||
"disabled": "Disattiva",
|
||||
"noAlarms": "Nessuna sveglia impostata",
|
||||
"days": {
|
||||
"sun": "Dom",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mer",
|
||||
"thu": "Gio",
|
||||
"fri": "Ven",
|
||||
"sat": "Sab"
|
||||
},
|
||||
"once": "Una volta",
|
||||
"daily": "Giornaliero",
|
||||
"weekdays": "Giorni feriali",
|
||||
"weekends": "Fine settimana",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Timer",
|
||||
"add": "Aggiungi timer",
|
||||
"start": "Avvia",
|
||||
"pause": "Pausa",
|
||||
"reset": "Reimposta",
|
||||
"stop": "Ferma",
|
||||
"delete": "Elimina",
|
||||
"label": "Etichetta",
|
||||
"duration": "Durata",
|
||||
"hours": "Ore",
|
||||
"minutes": "Minuti",
|
||||
"seconds": "Secondi",
|
||||
"noTimers": "Nessun timer attivo",
|
||||
"presets": "Selezione rapida",
|
||||
"finished": "Timer terminato!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Cronometro",
|
||||
"start": "Avvia",
|
||||
"stop": "Ferma",
|
||||
"lap": "Giro",
|
||||
"reset": "Reimposta",
|
||||
"laps": "Giri",
|
||||
"noLaps": "Nessun giro ancora",
|
||||
"best": "Migliore",
|
||||
"worst": "Peggiore",
|
||||
"total": "Totale"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Lavoro",
|
||||
"break": "Pausa",
|
||||
"longBreak": "Pausa lunga",
|
||||
"sessions": "Sessioni",
|
||||
"sessionsCompleted": "{count} di {total} sessioni",
|
||||
"start": "Avvia",
|
||||
"pause": "Pausa",
|
||||
"skip": "Salta",
|
||||
"reset": "Reimposta",
|
||||
"presets": {
|
||||
"classic": "Classico",
|
||||
"shortFocus": "Focus breve",
|
||||
"deepWork": "Lavoro profondo"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Durata lavoro",
|
||||
"breakDuration": "Durata pausa",
|
||||
"longBreakDuration": "Durata pausa lunga",
|
||||
"sessionsBeforeLongBreak": "Sessioni prima della pausa lunga"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "Orologio mondiale",
|
||||
"add": "Aggiungi città",
|
||||
"search": "Cerca città o fuso orario...",
|
||||
"noClocks": "Nessuna città aggiunta",
|
||||
"difference": "{hours} ore {direction}",
|
||||
"ahead": "avanti",
|
||||
"behind": "indietro",
|
||||
"same": "Stessa ora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"general": "Generale",
|
||||
"appearance": "Aspetto",
|
||||
"sounds": "Suoni",
|
||||
"notifications": "Notifiche",
|
||||
"language": "Lingua",
|
||||
"theme": "Tema",
|
||||
"darkMode": "Modalità scura",
|
||||
"clockFormat": "Formato ora",
|
||||
"format24h": "24 ore",
|
||||
"format12h": "12 ore (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"edit": "Modifica",
|
||||
"add": "Aggiungi",
|
||||
"confirm": "Conferma",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"loading": "Caricamento...",
|
||||
"error": "Errore",
|
||||
"success": "Successo"
|
||||
}
|
||||
}
|
||||
135
apps/clock/apps/web/src/lib/stores/alarms.svelte.ts
Normal file
135
apps/clock/apps/web/src/lib/stores/alarms.svelte.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Alarms Store - Manages alarm state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
import { alarmsApi } from '$lib/api/alarms';
|
||||
|
||||
// State
|
||||
let alarms = $state<Alarm[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const alarmsStore = {
|
||||
// Getters
|
||||
get alarms() {
|
||||
return alarms;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get enabledAlarms() {
|
||||
return alarms.filter((a) => a.enabled);
|
||||
},
|
||||
get nextAlarm() {
|
||||
const now = new Date();
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const enabled = alarms.filter((a) => a.enabled);
|
||||
if (enabled.length === 0) return null;
|
||||
|
||||
// Find the next alarm based on time
|
||||
let nextAlarm: Alarm | null = null;
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const alarm of enabled) {
|
||||
const [hours, minutes] = alarm.time.split(':').map(Number);
|
||||
const alarmTime = hours * 60 + minutes;
|
||||
let diff = alarmTime - currentTime;
|
||||
if (diff < 0) diff += 24 * 60; // Tomorrow
|
||||
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
nextAlarm = alarm;
|
||||
}
|
||||
}
|
||||
|
||||
return nextAlarm;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all alarms from the API
|
||||
*/
|
||||
async fetchAlarms() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await alarmsApi.getAll();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
alarms = result.data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new alarm
|
||||
*/
|
||||
async createAlarm(input: CreateAlarmInput) {
|
||||
const result = await alarmsApi.create(input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
alarms = [...alarms, result.data];
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing alarm
|
||||
*/
|
||||
async updateAlarm(id: string, input: UpdateAlarmInput) {
|
||||
const result = await alarmsApi.update(id, input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
alarms = alarms.map((a) => (a.id === id ? result.data! : a));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an alarm
|
||||
*/
|
||||
async deleteAlarm(id: string) {
|
||||
const result = await alarmsApi.delete(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
alarms = alarms.filter((a) => a.id !== id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle an alarm's enabled state
|
||||
*/
|
||||
async toggleAlarm(id: string) {
|
||||
const result = await alarmsApi.toggle(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
alarms = alarms.map((a) => (a.id === id ? result.data! : a));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
185
apps/clock/apps/web/src/lib/stores/auth.svelte.ts
Normal file
185
apps/clock/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Initialize Mana Core Auth only on the client side
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
// State
|
||||
let user = $state<UserData | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const authStore = {
|
||||
// Getters
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Login failed' };
|
||||
}
|
||||
|
||||
// Get user data from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
}
|
||||
|
||||
// Mana Core Auth requires separate login after signup
|
||||
if (result.needsVerification) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
// Auto sign in after successful signup
|
||||
const signInResult = await this.signIn(email, password);
|
||||
return { ...signInResult, needsVerification: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage, needsVerification: false };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
// Clear user even if sign out fails
|
||||
user = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access token for API calls
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
return await authService.getAppToken();
|
||||
},
|
||||
};
|
||||
8
apps/clock/apps/web/src/lib/stores/navigation.ts
Normal file
8
apps/clock/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Navigation store for sidebar mode state
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
238
apps/clock/apps/web/src/lib/stores/pomodoro.svelte.ts
Normal file
238
apps/clock/apps/web/src/lib/stores/pomodoro.svelte.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Pomodoro Store - Local pomodoro timer state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { DEFAULT_POMODORO_SETTINGS } from '@clock/shared';
|
||||
|
||||
export type PomodoroPhase = 'work' | 'break' | 'longBreak';
|
||||
|
||||
// Settings
|
||||
let workDuration = $state(DEFAULT_POMODORO_SETTINGS.workDuration!);
|
||||
let breakDuration = $state(DEFAULT_POMODORO_SETTINGS.breakDuration!);
|
||||
let longBreakDuration = $state(DEFAULT_POMODORO_SETTINGS.longBreakDuration!);
|
||||
let sessionsBeforeLongBreak = $state(DEFAULT_POMODORO_SETTINGS.sessionsBeforeLongBreak!);
|
||||
|
||||
// State
|
||||
let phase = $state<PomodoroPhase>('work');
|
||||
let isRunning = $state(false);
|
||||
let remainingTime = $state(workDuration);
|
||||
let completedSessions = $state(0);
|
||||
let startTime = $state<number | null>(null);
|
||||
let pausedTimeRemaining = $state(workDuration);
|
||||
|
||||
// Animation frame for updating time
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
function updateTime() {
|
||||
if (startTime !== null && isRunning) {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
remainingTime = Math.max(0, pausedTimeRemaining - elapsed);
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
handlePhaseComplete();
|
||||
} else {
|
||||
animationFrameId = requestAnimationFrame(updateTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePhaseComplete() {
|
||||
isRunning = false;
|
||||
startTime = null;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
|
||||
// Play notification sound
|
||||
if (browser && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Pomodoro', {
|
||||
body:
|
||||
phase === 'work'
|
||||
? 'Arbeitszeit beendet! Zeit für eine Pause.'
|
||||
: 'Pause beendet! Bereit für die nächste Sitzung?',
|
||||
});
|
||||
}
|
||||
|
||||
// Advance to next phase
|
||||
if (phase === 'work') {
|
||||
completedSessions++;
|
||||
if (completedSessions % sessionsBeforeLongBreak === 0) {
|
||||
phase = 'longBreak';
|
||||
remainingTime = longBreakDuration;
|
||||
pausedTimeRemaining = longBreakDuration;
|
||||
} else {
|
||||
phase = 'break';
|
||||
remainingTime = breakDuration;
|
||||
pausedTimeRemaining = breakDuration;
|
||||
}
|
||||
} else {
|
||||
phase = 'work';
|
||||
remainingTime = workDuration;
|
||||
pausedTimeRemaining = workDuration;
|
||||
}
|
||||
}
|
||||
|
||||
export const pomodoroStore = {
|
||||
// Getters
|
||||
get phase() {
|
||||
return phase;
|
||||
},
|
||||
get isRunning() {
|
||||
return isRunning;
|
||||
},
|
||||
get remainingTime() {
|
||||
return remainingTime;
|
||||
},
|
||||
get completedSessions() {
|
||||
return completedSessions;
|
||||
},
|
||||
get sessionsBeforeLongBreak() {
|
||||
return sessionsBeforeLongBreak;
|
||||
},
|
||||
get currentPhaseDuration() {
|
||||
switch (phase) {
|
||||
case 'work':
|
||||
return workDuration;
|
||||
case 'break':
|
||||
return breakDuration;
|
||||
case 'longBreak':
|
||||
return longBreakDuration;
|
||||
}
|
||||
},
|
||||
get progress() {
|
||||
const total = this.currentPhaseDuration;
|
||||
return ((total - remainingTime) / total) * 100;
|
||||
},
|
||||
get formattedTime() {
|
||||
const minutes = Math.floor(remainingTime / 60);
|
||||
const seconds = remainingTime % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// Settings getters
|
||||
get settings() {
|
||||
return {
|
||||
workDuration,
|
||||
breakDuration,
|
||||
longBreakDuration,
|
||||
sessionsBeforeLongBreak,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the timer
|
||||
*/
|
||||
start() {
|
||||
if (!isRunning) {
|
||||
isRunning = true;
|
||||
startTime = Date.now();
|
||||
updateTime();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause the timer
|
||||
*/
|
||||
pause() {
|
||||
if (isRunning) {
|
||||
isRunning = false;
|
||||
pausedTimeRemaining = remainingTime;
|
||||
startTime = null;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle start/pause
|
||||
*/
|
||||
toggle() {
|
||||
if (isRunning) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Skip to next phase
|
||||
*/
|
||||
skip() {
|
||||
this.pause();
|
||||
handlePhaseComplete();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the pomodoro timer
|
||||
*/
|
||||
reset() {
|
||||
isRunning = false;
|
||||
phase = 'work';
|
||||
remainingTime = workDuration;
|
||||
pausedTimeRemaining = workDuration;
|
||||
completedSessions = 0;
|
||||
startTime = null;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update settings
|
||||
*/
|
||||
updateSettings(settings: {
|
||||
workDuration?: number;
|
||||
breakDuration?: number;
|
||||
longBreakDuration?: number;
|
||||
sessionsBeforeLongBreak?: number;
|
||||
}) {
|
||||
if (settings.workDuration !== undefined) {
|
||||
workDuration = settings.workDuration;
|
||||
}
|
||||
if (settings.breakDuration !== undefined) {
|
||||
breakDuration = settings.breakDuration;
|
||||
}
|
||||
if (settings.longBreakDuration !== undefined) {
|
||||
longBreakDuration = settings.longBreakDuration;
|
||||
}
|
||||
if (settings.sessionsBeforeLongBreak !== undefined) {
|
||||
sessionsBeforeLongBreak = settings.sessionsBeforeLongBreak;
|
||||
}
|
||||
|
||||
// Reset to work phase with new duration if not running
|
||||
if (!isRunning && phase === 'work') {
|
||||
remainingTime = workDuration;
|
||||
pausedTimeRemaining = workDuration;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load preset
|
||||
*/
|
||||
loadPreset(preset: {
|
||||
workDuration: number;
|
||||
breakDuration: number;
|
||||
longBreakDuration: number;
|
||||
sessionsBeforeLongBreak: number;
|
||||
}) {
|
||||
this.pause();
|
||||
this.updateSettings(preset);
|
||||
this.reset();
|
||||
},
|
||||
|
||||
/**
|
||||
* Request notification permission
|
||||
*/
|
||||
async requestNotificationPermission() {
|
||||
if (browser && 'Notification' in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission === 'granted';
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
150
apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts
Normal file
150
apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Stopwatch Store - Local-only stopwatch state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
export interface Lap {
|
||||
number: number;
|
||||
time: number; // milliseconds
|
||||
splitTime: number; // total time at lap
|
||||
}
|
||||
|
||||
// State
|
||||
let isRunning = $state(false);
|
||||
let elapsedTime = $state(0); // milliseconds
|
||||
let laps = $state<Lap[]>([]);
|
||||
let startTime = $state<number | null>(null);
|
||||
let pausedTime = $state(0);
|
||||
|
||||
// Animation frame for updating time
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
function updateTime() {
|
||||
if (startTime !== null && isRunning) {
|
||||
elapsedTime = pausedTime + (Date.now() - startTime);
|
||||
animationFrameId = requestAnimationFrame(updateTime);
|
||||
}
|
||||
}
|
||||
|
||||
export const stopwatchStore = {
|
||||
// Getters
|
||||
get isRunning() {
|
||||
return isRunning;
|
||||
},
|
||||
get elapsedTime() {
|
||||
return elapsedTime;
|
||||
},
|
||||
get laps() {
|
||||
return laps;
|
||||
},
|
||||
get formattedTime() {
|
||||
return formatTime(elapsedTime);
|
||||
},
|
||||
get bestLap() {
|
||||
if (laps.length < 2) return null;
|
||||
return laps.reduce((best, lap) => (lap.time < best.time ? lap : best));
|
||||
},
|
||||
get worstLap() {
|
||||
if (laps.length < 2) return null;
|
||||
return laps.reduce((worst, lap) => (lap.time > worst.time ? lap : worst));
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the stopwatch
|
||||
*/
|
||||
start() {
|
||||
if (!isRunning) {
|
||||
isRunning = true;
|
||||
startTime = Date.now();
|
||||
updateTime();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause the stopwatch
|
||||
*/
|
||||
pause() {
|
||||
if (isRunning) {
|
||||
isRunning = false;
|
||||
pausedTime = elapsedTime;
|
||||
startTime = null;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle start/pause
|
||||
*/
|
||||
toggle() {
|
||||
if (isRunning) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Record a lap
|
||||
*/
|
||||
lap() {
|
||||
if (elapsedTime > 0) {
|
||||
const lastLapTime = laps.length > 0 ? laps[laps.length - 1].splitTime : 0;
|
||||
const lapTime = elapsedTime - lastLapTime;
|
||||
|
||||
laps = [
|
||||
...laps,
|
||||
{
|
||||
number: laps.length + 1,
|
||||
time: lapTime,
|
||||
splitTime: elapsedTime,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the stopwatch
|
||||
*/
|
||||
reset() {
|
||||
isRunning = false;
|
||||
elapsedTime = 0;
|
||||
laps = [];
|
||||
startTime = null;
|
||||
pausedTime = 0;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format milliseconds to HH:MM:SS.ms
|
||||
*/
|
||||
export function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const centiseconds = Math.floor((ms % 1000) / 10);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds to MM:SS.ms (short format for laps)
|
||||
*/
|
||||
export function formatLapTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const centiseconds = Math.floor((ms % 1000) / 10);
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
117
apps/clock/apps/web/src/lib/stores/theme.ts
Normal file
117
apps/clock/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Theme store for Clock app
|
||||
* Manages light/dark mode and theme variants
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
THEME_VARIANTS,
|
||||
type ThemeVariant,
|
||||
type ThemeMode,
|
||||
THEME_DEFINITIONS,
|
||||
} from '@manacore/shared-theme';
|
||||
|
||||
// Storage keys
|
||||
const MODE_KEY = 'clock-theme-mode';
|
||||
const VARIANT_KEY = 'clock-theme-variant';
|
||||
|
||||
// State
|
||||
let mode = $state<ThemeMode>('system');
|
||||
let variant = $state<ThemeVariant>('amber');
|
||||
let isDark = $state(false);
|
||||
|
||||
// Get system preference
|
||||
function getSystemPrefersDark(): boolean {
|
||||
if (!browser) return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// Apply theme to document
|
||||
function applyTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
// Determine if dark mode
|
||||
const shouldBeDark = mode === 'system' ? getSystemPrefersDark() : mode === 'dark';
|
||||
isDark = shouldBeDark;
|
||||
|
||||
// Apply to document
|
||||
document.documentElement.classList.toggle('dark', shouldBeDark);
|
||||
document.documentElement.setAttribute('data-theme', variant);
|
||||
}
|
||||
|
||||
// Listen for system preference changes
|
||||
if (browser) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (mode === 'system') {
|
||||
applyTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const theme = {
|
||||
// Getters
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
get variant() {
|
||||
return variant;
|
||||
},
|
||||
get isDark() {
|
||||
return isDark;
|
||||
},
|
||||
get variants() {
|
||||
return THEME_VARIANTS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize theme from localStorage
|
||||
*/
|
||||
initialize() {
|
||||
if (!browser) return;
|
||||
|
||||
// Load saved preferences
|
||||
const savedMode = localStorage.getItem(MODE_KEY) as ThemeMode | null;
|
||||
const savedVariant = localStorage.getItem(VARIANT_KEY) as ThemeVariant | null;
|
||||
|
||||
if (savedMode && ['light', 'dark', 'system'].includes(savedMode)) {
|
||||
mode = savedMode;
|
||||
}
|
||||
|
||||
if (savedVariant && THEME_VARIANTS.includes(savedVariant)) {
|
||||
variant = savedVariant;
|
||||
}
|
||||
|
||||
applyTheme();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set theme mode
|
||||
*/
|
||||
setMode(newMode: ThemeMode) {
|
||||
mode = newMode;
|
||||
if (browser) {
|
||||
localStorage.setItem(MODE_KEY, newMode);
|
||||
}
|
||||
applyTheme();
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle between light and dark
|
||||
*/
|
||||
toggleMode() {
|
||||
const newMode = isDark ? 'light' : 'dark';
|
||||
this.setMode(newMode);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set theme variant
|
||||
*/
|
||||
setVariant(newVariant: ThemeVariant) {
|
||||
if (!THEME_VARIANTS.includes(newVariant)) return;
|
||||
variant = newVariant;
|
||||
if (browser) {
|
||||
localStorage.setItem(VARIANT_KEY, newVariant);
|
||||
}
|
||||
applyTheme();
|
||||
},
|
||||
};
|
||||
154
apps/clock/apps/web/src/lib/stores/timers.svelte.ts
Normal file
154
apps/clock/apps/web/src/lib/stores/timers.svelte.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Timers Store - Manages timer state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
||||
import { timersApi } from '$lib/api/timers';
|
||||
|
||||
// State
|
||||
let timers = $state<Timer[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const timersStore = {
|
||||
// Getters
|
||||
get timers() {
|
||||
return timers;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get activeTimers() {
|
||||
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
|
||||
},
|
||||
get runningTimers() {
|
||||
return timers.filter((t) => t.status === 'running');
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all timers from the API
|
||||
*/
|
||||
async fetchTimers() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await timersApi.getAll();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
timers = result.data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new timer
|
||||
*/
|
||||
async createTimer(input: CreateTimerInput) {
|
||||
const result = await timersApi.create(input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = [...timers, result.data];
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing timer
|
||||
*/
|
||||
async updateTimer(id: string, input: UpdateTimerInput) {
|
||||
const result = await timersApi.update(id, input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = timers.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a timer
|
||||
*/
|
||||
async deleteTimer(id: string) {
|
||||
const result = await timersApi.delete(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
timers = timers.filter((t) => t.id !== id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a timer
|
||||
*/
|
||||
async startTimer(id: string) {
|
||||
const result = await timersApi.start(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = timers.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause a timer
|
||||
*/
|
||||
async pauseTimer(id: string) {
|
||||
const result = await timersApi.pause(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = timers.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset a timer
|
||||
*/
|
||||
async resetTimer(id: string) {
|
||||
const result = await timersApi.reset(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = timers.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update local timer state (for countdown display)
|
||||
*/
|
||||
updateLocalTimer(id: string, remainingSeconds: number) {
|
||||
timers = timers.map((t) => (t.id === id ? { ...t, remainingSeconds } : t));
|
||||
},
|
||||
};
|
||||
46
apps/clock/apps/web/src/lib/stores/toast.ts
Normal file
46
apps/clock/apps/web/src/lib/stores/toast.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Toast notification store
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
|
||||
function addToast(message: string, type: Toast['type'] = 'info', duration = 5000) {
|
||||
const id = crypto.randomUUID();
|
||||
const toast: Toast = { id, message, type, duration };
|
||||
|
||||
update((toasts) => [...toasts, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeToast(id: string) {
|
||||
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
success: (message: string, duration?: number) => addToast(message, 'success', duration),
|
||||
error: (message: string, duration?: number) => addToast(message, 'error', duration),
|
||||
info: (message: string, duration?: number) => addToast(message, 'info', duration),
|
||||
warning: (message: string, duration?: number) => addToast(message, 'warning', duration),
|
||||
remove: removeToast,
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
120
apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts
Normal file
120
apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* World Clocks Store - Manages world clock state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
|
||||
import { worldClocksApi } from '$lib/api/world-clocks';
|
||||
|
||||
// State
|
||||
let worldClocks = $state<WorldClock[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const worldClocksStore = {
|
||||
// Getters
|
||||
get worldClocks() {
|
||||
return worldClocks;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get sortedWorldClocks() {
|
||||
return [...worldClocks].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all world clocks from the API
|
||||
*/
|
||||
async fetchWorldClocks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await worldClocksApi.getAll();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
worldClocks = result.data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new world clock
|
||||
*/
|
||||
async addWorldClock(input: CreateWorldClockInput) {
|
||||
const result = await worldClocksApi.create(input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
worldClocks = [...worldClocks, result.data];
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a world clock
|
||||
*/
|
||||
async removeWorldClock(id: string) {
|
||||
const result = await worldClocksApi.delete(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
worldClocks = worldClocks.filter((wc) => wc.id !== id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder world clocks
|
||||
*/
|
||||
async reorderWorldClocks(ids: string[]) {
|
||||
const result = await worldClocksApi.reorder(ids);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
worldClocks = result.data;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get time info for a timezone
|
||||
*/
|
||||
getTimeForTimezone(timezone: string) {
|
||||
try {
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const localOffset = now.getTimezoneOffset();
|
||||
const targetDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
|
||||
const targetOffset = (now.getTime() - targetDate.getTime()) / (1000 * 60) + localOffset;
|
||||
|
||||
return {
|
||||
time: formatter.format(now),
|
||||
offsetHours: Math.round(-targetOffset / 60),
|
||||
};
|
||||
} catch {
|
||||
return { time: '--:--:--', offsetHours: 0 };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
let error = $state('');
|
||||
let success = $state(false);
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleResetPassword(email: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
success = false;
|
||||
|
||||
const result = await authStore.resetPassword(email);
|
||||
|
||||
if (result.success) {
|
||||
success = true;
|
||||
} else {
|
||||
error = result.error || 'Passwort-Zurücksetzung fehlgeschlagen';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Clock"
|
||||
appLogo=""
|
||||
{loading}
|
||||
{error}
|
||||
{success}
|
||||
onSubmit={handleResetPassword}
|
||||
loginHref="/login"
|
||||
/>
|
||||
34
apps/clock/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
34
apps/clock/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleLogin(email: string, password: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
const result = await authStore.signIn(email, password);
|
||||
|
||||
if (result.success) {
|
||||
goto('/');
|
||||
} else {
|
||||
error = result.error || 'Login fehlgeschlagen';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoginPage
|
||||
appName="Clock"
|
||||
appLogo=""
|
||||
{loading}
|
||||
{error}
|
||||
onSubmit={handleLogin}
|
||||
registerHref="/register"
|
||||
forgotPasswordHref="/forgot-password"
|
||||
/>
|
||||
38
apps/clock/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
38
apps/clock/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleRegister(email: string, password: string) {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
const result = await authStore.signUp(email, password);
|
||||
|
||||
if (result.success) {
|
||||
if (result.needsVerification) {
|
||||
// Show verification message or redirect to verification page
|
||||
goto('/login?registered=true');
|
||||
} else {
|
||||
goto('/');
|
||||
}
|
||||
} else {
|
||||
error = result.error || 'Registrierung fehlgeschlagen';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<RegisterPage
|
||||
appName="Clock"
|
||||
appLogo=""
|
||||
{loading}
|
||||
{error}
|
||||
onSubmit={handleRegister}
|
||||
loginHref="/login"
|
||||
/>
|
||||
145
apps/clock/apps/web/src/routes/+error.svelte
Normal file
145
apps/clock/apps/web/src/routes/+error.svelte
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
function handleGoHome() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
function handleGoBack() {
|
||||
window.history.back();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Error - Clock</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="error-page">
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
{#if $page.status === 404}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h1>{$page.status || 500}</h1>
|
||||
|
||||
{#if $page.status === 404}
|
||||
<h2>Seite nicht gefunden</h2>
|
||||
<p>Die Seite, die du suchst, existiert nicht oder wurde verschoben.</p>
|
||||
{:else if $page.status === 500}
|
||||
<h2>Serverfehler</h2>
|
||||
<p>Es ist ein Fehler auf dem Server aufgetreten. Bitte versuche es später erneut.</p>
|
||||
{:else}
|
||||
<h2>Etwas ist schiefgelaufen</h2>
|
||||
<p>{$page.error?.message || 'Ein unerwarteter Fehler ist aufgetreten.'}</p>
|
||||
{/if}
|
||||
|
||||
<div class="error-actions">
|
||||
<button class="btn btn-primary" onclick={handleGoHome}> Zur Startseite </button>
|
||||
<button class="btn btn-secondary" onclick={handleGoBack}> Zurück </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: hsl(var(--color-background));
|
||||
}
|
||||
|
||||
.error-container {
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin: 0 auto 2rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 6rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary));
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0 0 2rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-page {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
266
apps/clock/apps/web/src/routes/+layout.svelte
Normal file
266
apps/clock/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
} from '$lib/stores/navigation';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
import '../app.css';
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('clock');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Theme variant dropdown items
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...theme.variants.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant].label,
|
||||
icon: THEME_DEFINITIONS[variant].icon,
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: theme.variant === variant,
|
||||
})),
|
||||
{
|
||||
id: 'all-themes',
|
||||
label: 'Alle Themes',
|
||||
icon: 'palette',
|
||||
onClick: () => goto('/themes'),
|
||||
active: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Current theme variant label
|
||||
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
|
||||
|
||||
// Language selector items
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
|
||||
// Check if current route is an auth route (no navigation needed)
|
||||
let isAuthRoute = $derived(
|
||||
$page.url.pathname.startsWith('/login') ||
|
||||
$page.url.pathname.startsWith('/register') ||
|
||||
$page.url.pathname.startsWith('/forgot-password')
|
||||
);
|
||||
|
||||
// Navigation items for Clock
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Übersicht', icon: 'home' },
|
||||
{ href: '/alarms', label: 'Wecker', icon: 'bell' },
|
||||
{ href: '/timers', label: 'Timer', icon: 'clock' },
|
||||
{ href: '/stopwatch', label: 'Stoppuhr', icon: 'activity' },
|
||||
{ href: '/pomodoro', label: 'Pomodoro', icon: 'target' },
|
||||
{ href: '/world-clock', label: 'Weltzeituhr', icon: 'globe' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
];
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-8)
|
||||
const navRoutes = navItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
const num = parseInt(event.key);
|
||||
if (num >= 1 && num <= navRoutes.length) {
|
||||
event.preventDefault();
|
||||
const route = navRoutes[num - 1];
|
||||
if (route) {
|
||||
goto(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleModeChange(isSidebar: boolean) {
|
||||
isSidebarMode = isSidebar;
|
||||
sidebarModeStore.set(isSidebar);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('clock-nav-sidebar', String(isSidebar));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
collapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('clock-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize theme
|
||||
theme.initialize();
|
||||
|
||||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('clock-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
sidebarModeStore.set(true);
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('clock-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
{#if isAuthRoute}
|
||||
<!-- Auth routes: no navigation, just render content -->
|
||||
{@render children()}
|
||||
{:else if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-background">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="text-muted-foreground">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Clock"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#f59e0b"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="main-content bg-background"
|
||||
class:sidebar-mode={isSidebarMode && !isCollapsed}
|
||||
class:floating-mode={!isSidebarMode && !isCollapsed}
|
||||
>
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
transition: all 300ms ease;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.main-content.floating-mode {
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.main-content.sidebar-mode {
|
||||
padding-left: 180px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
apps/clock/apps/web/src/routes/+page.svelte
Normal file
167
apps/clock/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
// Current time state
|
||||
let currentTime = $state(new Date());
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Derived time values
|
||||
let hours = $derived(currentTime.getHours());
|
||||
let minutes = $derived(currentTime.getMinutes());
|
||||
let seconds = $derived(currentTime.getSeconds());
|
||||
|
||||
// Formatted time strings
|
||||
let timeString = $derived(
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
);
|
||||
let dateString = $derived(
|
||||
currentTime.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
);
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(() => {
|
||||
currentTime = new Date();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-bold text-foreground">{$_('dashboard.title')}</h1>
|
||||
<p class="mt-2 text-muted-foreground">{dateString}</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Clock Display -->
|
||||
<div class="flex flex-col items-center gap-8 lg:flex-row lg:justify-center lg:gap-16">
|
||||
<!-- Analog Clock -->
|
||||
<div class="clock-face">
|
||||
<!-- Hour markers -->
|
||||
{#each Array(12) as _, i}
|
||||
<div
|
||||
class="clock-marker hour-marker"
|
||||
style="transform: translateX(-50%) rotate({i * 30}deg) translateY(-130px)"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
<!-- Minute markers -->
|
||||
{#each Array(60) as _, i}
|
||||
{#if i % 5 !== 0}
|
||||
<div
|
||||
class="clock-marker minute-marker"
|
||||
style="transform: translateX(-50%) rotate({i * 6}deg) translateY(-134px)"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div
|
||||
class="clock-hand hour"
|
||||
style="transform: translateX(-50%) rotate({hourRotation}deg)"
|
||||
></div>
|
||||
<div
|
||||
class="clock-hand minute"
|
||||
style="transform: translateX(-50%) rotate({minuteRotation}deg)"
|
||||
></div>
|
||||
<div
|
||||
class="clock-hand second"
|
||||
style="transform: translateX(-50%) rotate({secondRotation}deg)"
|
||||
></div>
|
||||
|
||||
<!-- Center dot -->
|
||||
<div class="clock-center"></div>
|
||||
</div>
|
||||
|
||||
<!-- Digital Clock -->
|
||||
<div class="text-center">
|
||||
<div class="digital-clock digital-clock-large text-foreground">
|
||||
{timeString}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Access Cards -->
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Next Alarm Card -->
|
||||
<a href="/alarms" class="card hover:border-primary/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<span class="text-xl">🔔</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{$_('dashboard.nextAlarm')}</p>
|
||||
<p class="font-medium text-foreground">Nicht eingestellt</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Active Timers Card -->
|
||||
<a href="/timers" class="card hover:border-primary/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
|
||||
<span class="text-xl">⏱</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{$_('dashboard.activeTimers')}</p>
|
||||
<p class="font-medium text-foreground">0 aktiv</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Stopwatch Card -->
|
||||
<a href="/stopwatch" class="card hover:border-primary/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||
<span class="text-xl">⏲</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{$_('nav.stopwatch')}</p>
|
||||
<p class="font-medium text-foreground">Bereit</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- World Clock Card -->
|
||||
<a href="/world-clock" class="card hover:border-primary/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500/10">
|
||||
<span class="text-xl">🌍</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{$_('dashboard.worldClocks')}</p>
|
||||
<p class="font-medium text-foreground">0 Städte</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Pomodoro Quick Start -->
|
||||
<div class="card mt-6">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-foreground">{$_('pomodoro.title')}</h3>
|
||||
<p class="text-sm text-muted-foreground">Starte eine fokussierte Arbeitssitzung</p>
|
||||
</div>
|
||||
<a href="/pomodoro" class="btn btn-primary btn-lg">
|
||||
{$_('pomodoro.start')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
255
apps/clock/apps/web/src/routes/alarms/+page.svelte
Normal file
255
apps/clock/apps/web/src/routes/alarms/+page.svelte
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
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';
|
||||
|
||||
// Form state
|
||||
let showForm = $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);
|
||||
|
||||
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
showForm = false;
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
function toggleDay(day: number) {
|
||||
if (formRepeatDays.includes(day)) {
|
||||
formRepeatDays = formRepeatDays.filter((d) => d !== day);
|
||||
} else {
|
||||
formRepeatDays = [...formRepeatDays, day];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const input: CreateAlarmInput = {
|
||||
time: formTime + ':00',
|
||||
label: formLabel || undefined,
|
||||
repeatDays: formRepeatDays.length > 0 ? formRepeatDays : undefined,
|
||||
sound: formSound,
|
||||
snoozeMinutes: formSnoozeMinutes,
|
||||
};
|
||||
|
||||
let result;
|
||||
if (editingId) {
|
||||
result = await alarmsStore.updateAlarm(editingId, input);
|
||||
} else {
|
||||
result = await alarmsStore.createAlarm(input);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(editingId ? 'Wecker aktualisiert' : 'Wecker erstellt');
|
||||
closeForm();
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Speichern');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
const result = await alarmsStore.deleteAlarm(id);
|
||||
if (result.success) {
|
||||
toast.success('Wecker gelöscht');
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Löschen');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(id: string) {
|
||||
await alarmsStore.toggleAlarm(id);
|
||||
}
|
||||
|
||||
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 === 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')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alarm List -->
|
||||
{#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>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Form Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="card w-full max-w-md">
|
||||
<h2 class="mb-4 text-xl font-semibold">
|
||||
{editingId ? $_('alarm.edit') : $_('alarm.add')}
|
||||
</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<!-- 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} />
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium">{$_('alarm.label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Arbeit, Sport, etc."
|
||||
bind:value={formLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Repeat Days -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium">{$_('alarm.repeat')}</label>
|
||||
<div class="day-selector">
|
||||
{#each dayNames as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class:active={formRepeatDays.includes(i)}
|
||||
onclick={() => toggleDay(i)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium">{$_('alarm.sound')}</label>
|
||||
<select class="input" bind:value={formSound}>
|
||||
{#each ALARM_SOUNDS as sound}
|
||||
<option value={sound.id}>{sound.nameDE}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Snooze -->
|
||||
<div class="mb-6">
|
||||
<label class="mb-1 block text-sm font-medium">{$_('alarm.snooze')}</label>
|
||||
<select class="input" bind:value={formSnoozeMinutes}>
|
||||
<option value={5}>5 Minuten</option>
|
||||
<option value={10}>10 Minuten</option>
|
||||
<option value={15}>15 Minuten</option>
|
||||
<option value={30}>30 Minuten</option>
|
||||
</select>
|
||||
</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">
|
||||
{$_('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
23
apps/clock/apps/web/src/routes/apps/+page.svelte
Normal file
23
apps/clock/apps/web/src/routes/apps/+page.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { MANA_APPS, APP_URLS } from '@manacore/shared-branding';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<h1 class="mb-6 text-2xl font-bold text-foreground">Alle Apps</h1>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each MANA_APPS.filter((app) => !app.comingSoon) as app}
|
||||
<a
|
||||
href={APP_URLS[app.id]?.dev || '#'}
|
||||
class="card flex items-center gap-4 transition-all hover:border-primary/50"
|
||||
style="border-left: 4px solid {app.color}"
|
||||
>
|
||||
<img src={app.icon} alt={app.name} class="h-12 w-12 rounded-lg" />
|
||||
<div>
|
||||
<h3 class="font-semibold">{app.name}</h3>
|
||||
<p class="text-sm text-muted-foreground">{app.description.de}</p>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
21
apps/clock/apps/web/src/routes/feedback/+page.svelte
Normal file
21
apps/clock/apps/web/src/routes/feedback/+page.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
const feedbackService = createFeedbackService({
|
||||
appName: 'clock',
|
||||
apiUrl: 'http://localhost:3001', // Mana Core API
|
||||
});
|
||||
|
||||
async function handleSubmit(data: { type: string; message: string; email?: string }) {
|
||||
const token = await authStore.getAccessToken();
|
||||
return feedbackService.submit({
|
||||
...data,
|
||||
token: token || undefined,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<FeedbackPage appName="Clock" onSubmit={handleSubmit} userEmail={authStore.user?.email} />
|
||||
6
apps/clock/apps/web/src/routes/mana/+page.svelte
Normal file
6
apps/clock/apps/web/src/routes/mana/+page.svelte
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<SubscriptionPage user={authStore.user} appName="Clock" />
|
||||
172
apps/clock/apps/web/src/routes/pomodoro/+page.svelte
Normal file
172
apps/clock/apps/web/src/routes/pomodoro/+page.svelte
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { pomodoroStore } from '$lib/stores/pomodoro.svelte';
|
||||
import { POMODORO_PRESETS } from '@clock/shared';
|
||||
|
||||
// SVG circle properties
|
||||
const radius = 120;
|
||||
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'),
|
||||
}[pomodoroStore.phase]
|
||||
);
|
||||
|
||||
let phaseColor = $derived(
|
||||
{
|
||||
work: 'hsl(var(--color-primary))',
|
||||
break: 'hsl(var(--color-success))',
|
||||
longBreak: 'hsl(var(--color-info))',
|
||||
}[pomodoroStore.phase]
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
// Request notification permission
|
||||
pomodoroStore.requestNotificationPermission();
|
||||
});
|
||||
|
||||
function loadPreset(preset: (typeof POMODORO_PRESETS)[number]) {
|
||||
pomodoroStore.loadPreset({
|
||||
workDuration: preset.workDuration,
|
||||
breakDuration: preset.breakDuration,
|
||||
longBreakDuration: preset.longBreakDuration,
|
||||
sessionsBeforeLongBreak: preset.sessionsBeforeLongBreak,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center space-y-8">
|
||||
<!-- Header -->
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('pomodoro.title')}</h1>
|
||||
|
||||
<!-- 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 -->
|
||||
<circle
|
||||
cx="140"
|
||||
cy="140"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--color-muted))"
|
||||
stroke-width="8"
|
||||
/>
|
||||
<!-- Progress circle -->
|
||||
<circle
|
||||
cx="140"
|
||||
cy="140"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={phaseColor}
|
||||
stroke-width="8"
|
||||
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>
|
||||
</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">
|
||||
{#each Array(pomodoroStore.sessionsBeforeLongBreak) as _, i}
|
||||
<div
|
||||
class="h-3 w-3 rounded-full transition-colors"
|
||||
class:bg-primary={i <
|
||||
pomodoroStore.completedSessions % pomodoroStore.sessionsBeforeLongBreak}
|
||||
class:bg-muted={i >=
|
||||
pomodoroStore.completedSessions % pomodoroStore.sessionsBeforeLongBreak}
|
||||
></div>
|
||||
{/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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
6
apps/clock/apps/web/src/routes/profile/+page.svelte
Normal file
6
apps/clock/apps/web/src/routes/profile/+page.svelte
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { ProfilePage } from '@manacore/shared-profile-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<ProfilePage user={authStore.user} appName="Clock" />
|
||||
163
apps/clock/apps/web/src/routes/settings/+page.svelte
Normal file
163
apps/clock/apps/web/src/routes/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
|
||||
// Settings state
|
||||
let clockFormat = $state<'24h' | '12h'>('24h');
|
||||
|
||||
// Load settings from localStorage
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const savedFormat = localStorage.getItem('clock-format');
|
||||
if (savedFormat === '12h') {
|
||||
clockFormat = '12h';
|
||||
}
|
||||
}
|
||||
|
||||
function setClockFormat(format: '24h' | '12h') {
|
||||
clockFormat = format;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('clock-format', format);
|
||||
}
|
||||
}
|
||||
|
||||
const languageNames: Record<string, string> = {
|
||||
de: 'Deutsch',
|
||||
en: 'English',
|
||||
fr: 'Français',
|
||||
es: 'Español',
|
||||
it: 'Italiano',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('settings.title')}</h1>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-lg font-semibold">{$_('settings.appearance')}</h2>
|
||||
|
||||
<!-- Theme Mode -->
|
||||
<div class="mb-6">
|
||||
<label class="mb-2 block text-sm font-medium">{$_('settings.darkMode')}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
class:btn-primary={theme.mode === 'light'}
|
||||
class:btn-secondary={theme.mode !== 'light'}
|
||||
onclick={() => theme.setMode('light')}
|
||||
>
|
||||
☀️ Light
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
class:btn-primary={theme.mode === 'dark'}
|
||||
class:btn-secondary={theme.mode !== 'dark'}
|
||||
onclick={() => theme.setMode('dark')}
|
||||
>
|
||||
🌙 Dark
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
class:btn-primary={theme.mode === 'system'}
|
||||
class:btn-secondary={theme.mode !== 'system'}
|
||||
onclick={() => theme.setMode('system')}
|
||||
>
|
||||
💻 System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Variant -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium">{$_('settings.theme')}</label>
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||
{#each theme.variants as variant}
|
||||
<button
|
||||
class="flex flex-col items-center gap-1 rounded-lg border-2 p-3 transition-colors"
|
||||
class:border-primary={theme.variant === variant}
|
||||
class:border-transparent={theme.variant !== variant}
|
||||
onclick={() => theme.setVariant(variant)}
|
||||
>
|
||||
<span class="text-xl">{THEME_DEFINITIONS[variant].icon}</span>
|
||||
<span class="text-xs">{THEME_DEFINITIONS[variant].label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Section -->
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-lg font-semibold">{$_('settings.general')}</h2>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="mb-6">
|
||||
<label class="mb-2 block text-sm font-medium">{$_('settings.language')}</label>
|
||||
<select
|
||||
class="input"
|
||||
onchange={(e) => setLocale((e.target as HTMLSelectElement).value as any)}
|
||||
>
|
||||
{#each supportedLocales as locale}
|
||||
<option value={locale}>{languageNames[locale]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Clock Format -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium">{$_('settings.clockFormat')}</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
class:btn-primary={clockFormat === '24h'}
|
||||
class:btn-secondary={clockFormat !== '24h'}
|
||||
onclick={() => setClockFormat('24h')}
|
||||
>
|
||||
{$_('settings.format24h')}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
class:btn-primary={clockFormat === '12h'}
|
||||
class:btn-secondary={clockFormat !== '12h'}
|
||||
onclick={() => setClockFormat('12h')}
|
||||
>
|
||||
{$_('settings.format12h')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications Section -->
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-lg font-semibold">{$_('settings.notifications')}</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Benachrichtigungen werden für Wecker, Timer und Pomodoro-Sitzungen verwendet.
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary mt-4"
|
||||
onclick={async () => {
|
||||
if ('Notification' in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === 'granted') {
|
||||
new Notification('Clock', {
|
||||
body: 'Benachrichtigungen sind jetzt aktiviert!',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
Benachrichtigungen aktivieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sounds Section -->
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-lg font-semibold">{$_('settings.sounds')}</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Töne können für einzelne Wecker und Timer in deren Einstellungen angepasst werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
71
apps/clock/apps/web/src/routes/stopwatch/+page.svelte
Normal file
71
apps/clock/apps/web/src/routes/stopwatch/+page.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { stopwatchStore, formatTime, formatLapTime } from '$lib/stores/stopwatch.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center space-y-8">
|
||||
<!-- Header -->
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('stopwatch.title')}</h1>
|
||||
|
||||
<!-- Time Display -->
|
||||
<div class="digital-clock text-6xl font-light text-foreground sm:text-7xl">
|
||||
{stopwatchStore.formattedTime}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex gap-4">
|
||||
{#if stopwatchStore.isRunning}
|
||||
<button class="btn btn-secondary btn-xl" onclick={() => stopwatchStore.pause()}>
|
||||
{$_('stopwatch.stop')}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.lap()}>
|
||||
{$_('stopwatch.lap')}
|
||||
</button>
|
||||
{:else if stopwatchStore.elapsedTime > 0}
|
||||
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.start()}>
|
||||
{$_('stopwatch.start')}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-xl" onclick={() => stopwatchStore.reset()}>
|
||||
{$_('stopwatch.reset')}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.start()}>
|
||||
{$_('stopwatch.start')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Laps -->
|
||||
{#if stopwatchStore.laps.length > 0}
|
||||
<div class="card w-full max-w-md">
|
||||
<h3 class="mb-3 text-sm font-medium text-muted-foreground">
|
||||
{$_('stopwatch.laps')} ({stopwatchStore.laps.length})
|
||||
</h3>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each [...stopwatchStore.laps].reverse() as lap (lap.number)}
|
||||
{@const isBest = stopwatchStore.bestLap?.number === lap.number}
|
||||
{@const isWorst = stopwatchStore.worstLap?.number === lap.number}
|
||||
<div class="lap-item" class:best={isBest} class:worst={isWorst}>
|
||||
<span class="text-sm">
|
||||
Runde {lap.number}
|
||||
{#if isBest}
|
||||
<span class="ml-1 text-xs">({$_('stopwatch.best')})</span>
|
||||
{:else if isWorst}
|
||||
<span class="ml-1 text-xs">({$_('stopwatch.worst')})</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-mono text-sm">
|
||||
{formatLapTime(lap.time)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 flex justify-between border-t border-border pt-3">
|
||||
<span class="text-sm font-medium">{$_('stopwatch.total')}</span>
|
||||
<span class="font-mono text-sm font-medium">
|
||||
{formatTime(stopwatchStore.elapsedTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
30
apps/clock/apps/web/src/routes/themes/+page.svelte
Normal file
30
apps/clock/apps/web/src/routes/themes/+page.svelte
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { THEME_DEFINITIONS, THEME_VARIANTS } from '@manacore/shared-theme';
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<h1 class="mb-6 text-2xl font-bold text-foreground">Alle Themes</h1>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each THEME_VARIANTS as variant}
|
||||
{@const def = THEME_DEFINITIONS[variant]}
|
||||
<button
|
||||
class="card text-left transition-all hover:border-primary/50"
|
||||
class:border-primary={theme.variant === variant}
|
||||
onclick={() => theme.setVariant(variant)}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-3xl">{def.icon}</span>
|
||||
<div>
|
||||
<h3 class="font-semibold">{def.label}</h3>
|
||||
<p class="text-sm text-muted-foreground">{def.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if theme.variant === variant}
|
||||
<div class="mt-3 text-sm text-primary">✓ Aktiv</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
270
apps/clock/apps/web/src/routes/timers/+page.svelte
Normal file
270
apps/clock/apps/web/src/routes/timers/+page.svelte
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
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);
|
||||
let formMinutes = $state(5);
|
||||
let formSeconds = $state(0);
|
||||
let formLabel = $state('');
|
||||
|
||||
// Local countdown intervals
|
||||
let intervals: Map<string, ReturnType<typeof setInterval>> = new Map();
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await timersStore.fetchTimers();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clear all intervals
|
||||
intervals.forEach((interval) => clearInterval(interval));
|
||||
});
|
||||
|
||||
function startLocalCountdown(timerId: string, remainingSeconds: number) {
|
||||
// Clear existing interval if any
|
||||
if (intervals.has(timerId)) {
|
||||
clearInterval(intervals.get(timerId));
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const timer = timersStore.timers.find((t) => t.id === timerId);
|
||||
if (!timer || timer.status !== 'running') {
|
||||
clearInterval(interval);
|
||||
intervals.delete(timerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const newRemaining = Math.max(0, (timer.remainingSeconds || 0) - 1);
|
||||
timersStore.updateLocalTimer(timerId, newRemaining);
|
||||
|
||||
if (newRemaining === 0) {
|
||||
clearInterval(interval);
|
||||
intervals.delete(timerId);
|
||||
toast.success($_('timer.finished'));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
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;
|
||||
if (durationSeconds <= 0) {
|
||||
toast.error('Bitte eine gültige Zeit eingeben');
|
||||
return;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
async function createQuickTimer(seconds: number) {
|
||||
const result = await timersStore.createTimer({
|
||||
durationSeconds: seconds,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
await timersStore.startTimer(result.data.id);
|
||||
startLocalCountdown(result.data.id, seconds);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(id: string) {
|
||||
const result = await timersStore.startTimer(id);
|
||||
if (result.success) {
|
||||
const timer = timersStore.timers.find((t) => t.id === id);
|
||||
if (timer) {
|
||||
startLocalCountdown(id, timer.remainingSeconds || timer.durationSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause(id: string) {
|
||||
if (intervals.has(id)) {
|
||||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
await timersStore.pauseTimer(id);
|
||||
}
|
||||
|
||||
async function handleReset(id: string) {
|
||||
if (intervals.has(id)) {
|
||||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
await timersStore.resetTimer(id);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (intervals.has(id)) {
|
||||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
const result = await timersStore.deleteTimer(id);
|
||||
if (result.success) {
|
||||
toast.success('Timer gelöscht');
|
||||
}
|
||||
}
|
||||
|
||||
function getTimerDisplay(timer: any) {
|
||||
const remaining = timer.remainingSeconds ?? timer.durationSeconds;
|
||||
return formatDuration(remaining);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('timer.title')}</h1>
|
||||
<button class="btn btn-primary" onclick={openForm}>
|
||||
+ {$_('timer.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Timer Presets -->
|
||||
<div class="card">
|
||||
<h3 class="mb-3 text-sm font-medium text-muted-foreground">{$_('timer.presets')}</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each QUICK_TIMER_PRESETS as preset}
|
||||
<button class="btn btn-secondary btn-sm" onclick={() => createQuickTimer(preset.seconds)}>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer List -->
|
||||
{#if timersStore.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 timersStore.timers.length === 0}
|
||||
<div class="card py-12 text-center">
|
||||
<p class="text-lg text-muted-foreground">{$_('timer.noTimers')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each timersStore.timers as timer (timer.id)}
|
||||
<div class="card">
|
||||
{#if timer.label}
|
||||
<p class="mb-2 text-sm font-medium text-muted-foreground">{timer.label}</p>
|
||||
{/if}
|
||||
|
||||
<div class="timer-display text-4xl font-light text-foreground">
|
||||
{getTimerDisplay(timer)}
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mt-3 h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-all"
|
||||
style="width: {((timer.remainingSeconds || timer.durationSeconds) /
|
||||
timer.durationSeconds) *
|
||||
100}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="mt-4 flex gap-2">
|
||||
{#if timer.status === 'running'}
|
||||
<button class="btn btn-secondary flex-1" onclick={() => handlePause(timer.id)}>
|
||||
{$_('timer.pause')}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-primary flex-1" onclick={() => handleStart(timer.id)}>
|
||||
{$_('timer.start')}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="btn btn-ghost" onclick={() => handleReset(timer.id)}> ↺ </button>
|
||||
<button class="btn btn-ghost text-error" onclick={() => handleDelete(timer.id)}>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Form Modal -->
|
||||
{#if showForm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="card w-full max-w-md">
|
||||
<h2 class="mb-4 text-xl font-semibold">{$_('timer.add')}</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createTimer();
|
||||
}}
|
||||
>
|
||||
<!-- Duration -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium">{$_('timer.duration')}</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-muted-foreground">{$_('timer.hours')}</label>
|
||||
<input type="number" class="input" min="0" max="99" bind:value={formHours} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-muted-foreground">{$_('timer.minutes')}</label
|
||||
>
|
||||
<input type="number" class="input" min="0" max="59" bind:value={formMinutes} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="mb-1 block text-xs text-muted-foreground">{$_('timer.seconds')}</label
|
||||
>
|
||||
<input type="number" class="input" min="0" max="59" bind:value={formSeconds} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="mb-6">
|
||||
<label class="mb-1 block text-sm font-medium">{$_('timer.label')}</label>
|
||||
<input type="text" class="input" placeholder="Optional" 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">
|
||||
{$_('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
254
apps/clock/apps/web/src/routes/world-clock/+page.svelte
Normal file
254
apps/clock/apps/web/src/routes/world-clock/+page.svelte
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
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';
|
||||
|
||||
// State
|
||||
let showAddModal = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let currentTime = $state(new Date());
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Filtered timezones based on search
|
||||
let filteredTimezones = $derived(
|
||||
searchQuery
|
||||
? POPULAR_TIMEZONES.filter(
|
||||
(tz) =>
|
||||
tz.city.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tz.timezone.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: POPULAR_TIMEZONES
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
await worldClocksStore.fetchWorldClocks();
|
||||
}
|
||||
|
||||
// Update time every second
|
||||
interval = setInterval(() => {
|
||||
currentTime = new Date();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
function openAddModal() {
|
||||
searchQuery = '';
|
||||
showAddModal = true;
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal = false;
|
||||
}
|
||||
|
||||
async function addCity(timezone: string, cityName: string) {
|
||||
const result = await worldClocksStore.addWorldClock({
|
||||
timezone,
|
||||
cityName,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`${cityName} hinzugefügt`);
|
||||
closeAddModal();
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Hinzufügen');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCity(id: string) {
|
||||
const result = await worldClocksStore.removeWorldClock(id);
|
||||
if (result.success) {
|
||||
toast.success('Stadt entfernt');
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeForTimezone(timezone: string) {
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
return formatter.format(currentTime);
|
||||
} catch {
|
||||
return '--:--';
|
||||
}
|
||||
}
|
||||
|
||||
function getDateForTimezone(timezone: string) {
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
return formatter.format(currentTime);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getOffsetText(timezone: string) {
|
||||
try {
|
||||
// Get local offset
|
||||
const localOffset = currentTime.getTimezoneOffset();
|
||||
|
||||
// Get target timezone offset
|
||||
const targetDate = new Date(currentTime.toLocaleString('en-US', { timeZone: timezone }));
|
||||
const localDate = new Date(currentTime.toLocaleString('en-US', { timeZone: 'UTC' }));
|
||||
const utcDate = new Date(currentTime.toUTCString().slice(0, -4));
|
||||
|
||||
const targetOffset = (targetDate.getTime() - utcDate.getTime()) / (1000 * 60);
|
||||
const diffMinutes = targetOffset + localOffset;
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
|
||||
if (diffHours === 0) {
|
||||
return $_('worldClock.same');
|
||||
} else if (diffHours > 0) {
|
||||
return `+${diffHours}h`;
|
||||
} else {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isDaytime(timezone: string) {
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
hour12: false,
|
||||
});
|
||||
const hour = parseInt(formatter.format(currentTime));
|
||||
return hour >= 6 && hour < 20;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- World Clock List -->
|
||||
{#if worldClocksStore.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 worldClocksStore.sortedWorldClocks.length === 0}
|
||||
<div class="card py-12 text-center">
|
||||
<p class="text-lg text-muted-foreground">{$_('worldClock.noClocks')}</p>
|
||||
<button class="btn btn-primary mt-4" onclick={openAddModal}>
|
||||
{$_('worldClock.add')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each worldClocksStore.sortedWorldClocks as clock (clock.id)}
|
||||
{@const isDay = isDaytime(clock.timezone)}
|
||||
<div class="world-clock-card relative">
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
class="absolute right-3 top-3 text-muted-foreground hover:text-error"
|
||||
onclick={() => removeCity(clock.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- Day/Night indicator -->
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-xl">{isDay ? '☀️' : '🌙'}</span>
|
||||
<span class="city-name">{clock.cityName}</span>
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="time-display">
|
||||
{getTimeForTimezone(clock.timezone)}
|
||||
</div>
|
||||
|
||||
<!-- Date and offset -->
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<span class="timezone-info">
|
||||
{getDateForTimezone(clock.timezone)}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-primary">
|
||||
{getOffsetText(clock.timezone)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add City Modal -->
|
||||
{#if showAddModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<input
|
||||
type="text"
|
||||
class="input mb-4"
|
||||
placeholder={$_('worldClock.search')}
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<!-- Timezone list -->
|
||||
<div class="flex-1 overflow-y-auto -mx-4 px-4">
|
||||
{#each filteredTimezones as tz}
|
||||
{@const alreadyAdded = worldClocksStore.worldClocks.some(
|
||||
(wc) => wc.timezone === tz.timezone
|
||||
)}
|
||||
<button
|
||||
class="flex w-full items-center justify-between rounded-lg p-3 text-left hover:bg-muted transition-colors"
|
||||
class:opacity-50={alreadyAdded}
|
||||
disabled={alreadyAdded}
|
||||
onclick={() => addCity(tz.timezone, tz.city)}
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium">{tz.city}</div>
|
||||
<div class="text-sm text-muted-foreground">{tz.timezone}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-mono">{getTimeForTimezone(tz.timezone)}</div>
|
||||
<div class="text-xs text-muted-foreground">{tz.region}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if filteredTimezones.length === 0}
|
||||
<p class="py-8 text-center text-muted-foreground">
|
||||
Keine Ergebnisse für "{searchQuery}"
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
12
apps/clock/apps/web/svelte.config.js
Normal file
12
apps/clock/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/clock/apps/web/tsconfig.json
Normal file
14
apps/clock/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
45
apps/clock/apps/web/vite.config.ts
Normal file
45
apps/clock/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5186,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [
|
||||
'@clock/shared',
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-ui',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-subscription-ui',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@clock/shared',
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-feedback-ui',
|
||||
'@manacore/shared-feedback-service',
|
||||
'@manacore/shared-feedback-types',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
'@manacore/shared-subscription-ui',
|
||||
],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue