mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
refactor: consolidate codebase — remove archived code, deduplicate packages, standardize middleware
- Delete 17 server-archived/ directories (consolidated into apps/api/) - Delete apps-archived/ (clock, wisekeep) and services-archived/ (it-landing, ollama-metrics-proxy) - Fix type safety: replace all `any` casts in cross-app-queries.ts with proper Local* types - Remove duplicate shared-auth-stores package (identical copy of shared-auth-ui/stores/) - Remove duplicate theme store from shared-stores (canonical version in shared-theme) - Migrate memoro-server rate-limiter to shared-hono/rateLimitMiddleware - Migrate uload-server JWT auth + error handler to shared-hono (authMiddleware, errorHandler) - Migrate arcade-server error handling to shared-hono - Merge shared-profile-ui and shared-app-onboarding into shared-ui - Unify /clock route into /times/clock, remove redirect stubs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7ee57b7afd
commit
d8ce4eaf34
309 changed files with 172 additions and 21667 deletions
27
apps-archived/clock/apps/landing/.gitignore
vendored
27
apps-archived/clock/apps/landing/.gitignore
vendored
|
|
@ -1,27 +0,0 @@
|
|||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.astro",
|
||||
"options": {
|
||||
"parser": "astro"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()],
|
||||
output: 'static',
|
||||
build: {
|
||||
inlineStylesheets: 'auto',
|
||||
},
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@components': '/src/components',
|
||||
'@layouts': '/src/layouts',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"name": "@clock/landing",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 4323",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check || echo 'Astro check skipped'",
|
||||
"format": "prettier --write .",
|
||||
"clean": "rm -rf dist .astro node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.16.0",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/tailwind": "^6.0.2",
|
||||
"@tailwindcss/typography": "^0.5.18",
|
||||
"@types/node": "^20.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-astro": "^1.0.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^3.4.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#f59e0b">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 227 B |
|
|
@ -1,60 +0,0 @@
|
|||
---
|
||||
// Call to Action section
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden bg-dark-bg">
|
||||
<!-- Background gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-primary-950/30 via-dark-bg to-primary-950/30">
|
||||
</div>
|
||||
|
||||
<div class="container relative">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="mb-6 text-3xl font-bold md:text-4xl lg:text-5xl">
|
||||
Bereit, produktiver zu werden?
|
||||
</h2>
|
||||
<p class="mb-10 text-lg text-gray-400">
|
||||
Starte kostenlos und erlebe, wie einfach Zeitmanagement sein kann. Keine Kreditkarte
|
||||
erforderlich.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<a href="#" class="btn btn-primary text-lg">
|
||||
Jetzt kostenlos starten
|
||||
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#features" class="btn btn-secondary"> Mehr erfahren </a>
|
||||
</div>
|
||||
|
||||
<!-- Benefits list -->
|
||||
<div class="mt-12 flex flex-wrap items-center justify-center gap-6 text-sm text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Kostenlos starten</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Keine Kreditkarte</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Jederzeit kundbar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
---
|
||||
// Features section for Clock landing page
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>`,
|
||||
title: 'Pomodoro Timer',
|
||||
description:
|
||||
'Arbeite in fokussierten 25-Minuten-Intervallen mit automatischen Pausen. Steigere deine Konzentration.',
|
||||
},
|
||||
{
|
||||
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>`,
|
||||
title: 'Zeiterfassung',
|
||||
description:
|
||||
'Tracke deine Arbeitszeit auf Projekte und Aufgaben. Detaillierte Berichte und Statistiken.',
|
||||
},
|
||||
{
|
||||
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>`,
|
||||
title: 'Focus Sessions',
|
||||
description:
|
||||
'Starte dedizierte Focus-Sessions und eliminiere Ablenkungen. Perfekt fur Deep Work.',
|
||||
},
|
||||
{
|
||||
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
||||
</svg>`,
|
||||
title: 'Erinnerungen',
|
||||
description: 'Sanfte Erinnerungen fur Pausen und Arbeitsende. Schutze deine Work-Life-Balance.',
|
||||
},
|
||||
{
|
||||
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>`,
|
||||
title: 'Detaillierte Reports',
|
||||
description:
|
||||
'Wochentliche und monatliche Ubersichten. Exportiere deine Daten als CSV oder PDF.',
|
||||
},
|
||||
{
|
||||
icon: `<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>`,
|
||||
title: 'Alle Gerate',
|
||||
description: 'Web-App, iOS und Android. Deine Zeit wird uber alle Gerate synchronisiert.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section id="features" class="bg-dark-surface">
|
||||
<div class="container">
|
||||
<!-- Section header -->
|
||||
<div class="mx-auto mb-16 max-w-3xl text-center">
|
||||
<span class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400">
|
||||
Funktionen
|
||||
</span>
|
||||
<h2 class="mb-6 text-3xl font-bold md:text-4xl lg:text-5xl">Produktiver arbeiten</h2>
|
||||
<p class="text-lg text-gray-400">
|
||||
Clock bietet alle Tools, die du fur effektives Zeitmanagement brauchst.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features grid -->
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{
|
||||
features.map((feature) => (
|
||||
<div class="group rounded-xl border border-dark-border bg-dark-card p-6 transition-all duration-300 hover:border-primary-500/50 hover:bg-dark-card/80">
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-primary-500/10 text-primary-400 transition-colors group-hover:bg-primary-500/20">
|
||||
<Fragment set:html={feature.icon} />
|
||||
</div>
|
||||
<h3 class="mb-3 text-xl font-semibold">{feature.title}</h3>
|
||||
<p class="text-gray-400">{feature.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
---
|
||||
// Footer component
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const links = {
|
||||
product: [
|
||||
{ name: 'Funktionen', href: '#features' },
|
||||
{ name: 'Preise', href: '#pricing' },
|
||||
{ name: 'Changelog', href: '/changelog' },
|
||||
{ name: 'Roadmap', href: '/roadmap' },
|
||||
],
|
||||
legal: [
|
||||
{ name: 'Impressum', href: '/impressum' },
|
||||
{ name: 'Datenschutz', href: '/datenschutz' },
|
||||
{ name: 'AGB', href: '/agb' },
|
||||
],
|
||||
support: [
|
||||
{ name: 'FAQ', href: '/faq' },
|
||||
{ name: 'Kontakt', href: '/kontakt' },
|
||||
{ name: 'Status', href: '/status' },
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<footer class="border-t border-dark-border bg-dark-bg py-12">
|
||||
<div class="container">
|
||||
<div class="grid gap-8 md:grid-cols-4">
|
||||
<!-- Brand -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
class="h-8 w-8 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span class="text-xl font-bold">Clock</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">Time Tracking & Focus fur bessere Produktivitat.</p>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div>
|
||||
<h4 class="mb-4 font-semibold">Produkt</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-400">
|
||||
{
|
||||
links.product.map((link) => (
|
||||
<li>
|
||||
<a href={link.href} class="transition-colors hover:text-white">
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="mb-4 font-semibold">Rechtliches</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-400">
|
||||
{
|
||||
links.legal.map((link) => (
|
||||
<li>
|
||||
<a href={link.href} class="transition-colors hover:text-white">
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="mb-4 font-semibold">Support</h4>
|
||||
<ul class="space-y-2 text-sm text-gray-400">
|
||||
{
|
||||
links.support.map((link) => (
|
||||
<li>
|
||||
<a href={link.href} class="transition-colors hover:text-white">
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div
|
||||
class="mt-12 flex flex-col items-center justify-between gap-4 border-t border-dark-border pt-8 md:flex-row"
|
||||
>
|
||||
<p class="text-sm text-gray-500">
|
||||
© {currentYear} Clock. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
Ein <a href="https://mana.how" class="text-primary-400 hover:underline">Manacore</a> Produkt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
---
|
||||
// Hero section for Clock landing page
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden py-20 md:py-32">
|
||||
<!-- Background gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-primary-950/30 via-dark-bg to-dark-bg"></div>
|
||||
|
||||
<!-- Grid pattern -->
|
||||
<div class="absolute inset-0 bg-[url('/grid.svg')] bg-center opacity-10"></div>
|
||||
|
||||
<div class="container relative">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<!-- Badge -->
|
||||
<div
|
||||
class="mb-8 inline-flex items-center gap-2 rounded-full border border-primary-500/30 bg-primary-500/10 px-4 py-2 text-sm text-primary-400"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>Time Tracking & Focus</span>
|
||||
</div>
|
||||
|
||||
<!-- Headline -->
|
||||
<h1 class="mb-6 text-4xl font-bold leading-tight md:text-6xl lg:text-7xl">
|
||||
Nutze deine Zeit
|
||||
<span class="gradient-text">effektiv</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subheadline -->
|
||||
<p class="mx-auto mb-10 max-w-2xl text-lg text-gray-400 md:text-xl">
|
||||
Pomodoro-Timer, Zeiterfassung und Focus-Sessions - steigere deine Produktivitat und behalte
|
||||
den Uberblick uber deine Arbeitszeit.
|
||||
</p>
|
||||
|
||||
<!-- CTA Buttons -->
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<a href="#" class="btn btn-primary group text-lg">
|
||||
Kostenlos starten
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#features" class="btn btn-secondary"> Funktionen entdecken </a>
|
||||
</div>
|
||||
|
||||
<!-- Social proof -->
|
||||
<div class="mt-16 flex flex-col items-center gap-4">
|
||||
<div class="flex -space-x-2">
|
||||
{
|
||||
[1, 2, 3, 4, 5].map((i) => (
|
||||
<div class="h-10 w-10 rounded-full border-2 border-dark-bg bg-gradient-to-br from-primary-400 to-primary-600" />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
<span class="font-semibold text-white">300+</span> Nutzer tracken ihre Zeit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview mockup -->
|
||||
<div class="relative mx-auto mt-16 max-w-5xl">
|
||||
<div
|
||||
class="absolute -inset-4 rounded-2xl bg-gradient-to-r from-primary-500/20 via-transparent to-primary-500/20 blur-3xl"
|
||||
>
|
||||
</div>
|
||||
<div class="relative rounded-xl border border-dark-border bg-dark-card p-2 shadow-2xl">
|
||||
<div class="flex gap-2 px-4 py-3">
|
||||
<div class="h-3 w-3 rounded-full bg-red-500"></div>
|
||||
<div class="h-3 w-3 rounded-full bg-yellow-500"></div>
|
||||
<div class="h-3 w-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div class="aspect-[16/9] overflow-hidden rounded-lg bg-dark-surface">
|
||||
<!-- Timer preview placeholder -->
|
||||
<div class="flex h-full flex-col items-center justify-center p-6">
|
||||
<div class="mb-8 text-8xl font-bold text-primary-400">25:00</div>
|
||||
<div class="mb-6 flex gap-4">
|
||||
<button class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-medium">Start</button>
|
||||
<button class="rounded-lg bg-dark-card px-6 py-2 text-sm">Pause</button>
|
||||
<button class="rounded-lg bg-dark-card px-6 py-2 text-sm">Reset</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span class="rounded-full bg-primary-500/20 px-3 py-1 text-xs text-primary-400"
|
||||
>Focus</span
|
||||
>
|
||||
<span class="rounded-full bg-dark-card px-3 py-1 text-xs">Short Break</span>
|
||||
<span class="rounded-full bg-dark-card px-3 py-1 text-xs">Long Break</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
import Analytics from '@manacore/shared-landing-ui/atoms/Analytics.astro';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = 'Clock - Time Tracking & Focus',
|
||||
description = 'Track your time, stay focused, and boost productivity. Pomodoro timer, time tracking, and focus sessions. Start free today.',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de" class="scroll-smooth">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
|
||||
<!-- Preconnect to Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
{
|
||||
import.meta.env.PUBLIC_UMAMI_WEBSITE_ID && (
|
||||
<script
|
||||
defer
|
||||
src="https://stats.mana.how/script.js"
|
||||
data-website-id={import.meta.env.PUBLIC_UMAMI_WEBSITE_ID}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<slot />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
---
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import Hero from '@components/Hero.astro';
|
||||
import Features from '@components/Features.astro';
|
||||
import CTA from '@components/CTA.astro';
|
||||
import Footer from '@components/Footer.astro';
|
||||
|
||||
// Pricing data
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: 'Pomodoro Timer', included: true },
|
||||
{ text: 'Basis-Zeiterfassung', included: true },
|
||||
{ text: '7 Tage Historie', included: true },
|
||||
{ text: 'Web-App Zugang', included: true },
|
||||
{ text: 'Projekte & Tags', included: false },
|
||||
{ text: 'Detaillierte Reports', included: false },
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: '#',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '3,99',
|
||||
period: '/Monat',
|
||||
description: 'Fur Freelancer & Profis',
|
||||
features: [
|
||||
{ text: 'Alles aus Free', included: true },
|
||||
{ text: 'Unbegrenzte Historie', included: true },
|
||||
{ text: 'Projekte & Tags', included: true },
|
||||
{ text: 'Detaillierte Reports', included: true },
|
||||
{ text: 'CSV/PDF Export', included: true },
|
||||
{ text: 'Mobile Apps', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro starten',
|
||||
href: '#',
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Beliebt',
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
price: '7,99',
|
||||
period: '/Nutzer/Monat',
|
||||
description: 'Fur Teams & Agenturen',
|
||||
features: [
|
||||
{ text: 'Alles aus Pro', included: true },
|
||||
{ text: 'Team-Dashboard', included: true },
|
||||
{ text: 'Projekt-Budgets', included: true },
|
||||
{ text: 'Kunden-Reports', included: true },
|
||||
{ text: 'API-Zugang', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Team erstellen',
|
||||
href: '#',
|
||||
},
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Clock - Time Tracking & Focus"
|
||||
description="Tracke deine Zeit, bleib fokussiert und steigere deine Produktivitat. Pomodoro Timer, Zeiterfassung und Focus Sessions. Kostenlos starten."
|
||||
>
|
||||
<Hero />
|
||||
<Features />
|
||||
|
||||
<section id="pricing" class="bg-dark-bg">
|
||||
<div class="container">
|
||||
<div class="mx-auto mb-16 max-w-3xl text-center">
|
||||
<span
|
||||
class="mb-4 inline-block text-sm font-medium uppercase tracking-wider text-primary-400"
|
||||
>
|
||||
Preise
|
||||
</span>
|
||||
<h2 class="mb-6 text-3xl font-bold md:text-4xl">Einfache, transparente Preise</h2>
|
||||
<p class="text-lg text-gray-400">Starte kostenlos, upgrade wenn du mehr brauchst</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto grid max-w-5xl gap-8 md:grid-cols-3">
|
||||
{
|
||||
pricingPlans.map((plan) => (
|
||||
<div
|
||||
class={`relative rounded-xl border p-6 ${plan.highlighted ? 'border-primary-500 bg-primary-500/10' : 'border-dark-border bg-dark-card'}`}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary-500 px-3 py-1 text-xs font-medium text-white">
|
||||
{plan.badge}
|
||||
</div>
|
||||
)}
|
||||
<h3 class="mb-2 text-xl font-semibold">{plan.name}</h3>
|
||||
<p class="mb-4 text-sm text-gray-400">{plan.description}</p>
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-bold">{plan.price}€</span>
|
||||
<span class="text-gray-500">{plan.period}</span>
|
||||
</div>
|
||||
<ul class="mb-8 space-y-3">
|
||||
{plan.features.map((feature) => (
|
||||
<li
|
||||
class={`flex items-center gap-2 text-sm ${feature.included ? 'text-white' : 'text-gray-600'}`}
|
||||
>
|
||||
{feature.included ? (
|
||||
<svg
|
||||
class="h-5 w-5 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{feature.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={plan.cta.href}
|
||||
class={`btn w-full ${plan.highlighted ? 'btn-primary' : 'btn-secondary'}`}
|
||||
>
|
||||
{plan.cta.text}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CTA />
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-background-page: #0a0a0a;
|
||||
--color-background-card: #1a1a1a;
|
||||
--color-background-card-hover: #242424;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #d1d5db;
|
||||
--color-text-muted: #9ca3af;
|
||||
--color-border: #262626;
|
||||
--color-border-hover: #3f3f3f;
|
||||
--color-primary: #f59e0b;
|
||||
--color-primary-hover: #d97706;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-dark-bg text-white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-dark-bg;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-dark-border rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-600;
|
||||
}
|
||||
|
||||
/* Section padding */
|
||||
section {
|
||||
@apply py-16 md:py-24;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-primary-400 to-primary-600 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-lg px-6 py-3 font-medium transition-all duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-500 text-white hover:bg-primary-600;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border border-dark-border bg-dark-card text-white hover:bg-dark-surface;
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
|
||||
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Clock app theme - amber/orange
|
||||
primary: {
|
||||
DEFAULT: '#f59e0b',
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
950: '#451a03',
|
||||
},
|
||||
dark: {
|
||||
bg: '#0a0a0a',
|
||||
surface: '#111111',
|
||||
card: '#1a1a1a',
|
||||
border: '#262626',
|
||||
},
|
||||
background: {
|
||||
page: 'var(--color-background-page, #0a0a0a)',
|
||||
card: 'var(--color-background-card, #1a1a1a)',
|
||||
'card-hover': 'var(--color-background-card-hover, #242424)',
|
||||
},
|
||||
text: {
|
||||
primary: 'var(--color-text-primary, #ffffff)',
|
||||
secondary: 'var(--color-text-secondary, #d1d5db)',
|
||||
muted: 'var(--color-text-muted, #9ca3af)',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: 'var(--color-border, #262626)',
|
||||
hover: 'var(--color-border-hover, #3f3f3f)',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
};
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# Cloudflare Pages configuration for Clock Landing
|
||||
# Deployed via Wrangler CLI (Direct Upload)
|
||||
# Custom domain: clocks.mana.how
|
||||
|
||||
name = "clocks-landing"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM sveltekit-base:local AS builder
|
||||
|
||||
ARG PUBLIC_BACKEND_URL=http://mana-auth
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001
|
||||
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
|
||||
COPY apps/clock/packages/shared ./apps/clock/packages/shared
|
||||
COPY apps/clock/apps/web ./apps/clock/apps/web
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app/apps/clock/apps/web
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app/apps/clock/apps/web
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
COPY --from=builder /app/apps/clock/apps/web/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps/clock/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/clock/apps/web/package.json ./
|
||||
|
||||
EXPOSE 5013
|
||||
ENV NODE_ENV=production PORT=5013 HOST=0.0.0.0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5013/health || exit 1
|
||||
|
||||
CMD ["node", "build"]
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
{
|
||||
"name": "@clock/web",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"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": "svelte-kit sync && svelte-check --threshold error"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-pwa": "workspace:*",
|
||||
"@manacore/shared-vite-config": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@types/topojson-specification": "^1.0.5",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clock/shared": "workspace:*",
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-stores": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-error-tracking": "workspace:*",
|
||||
"@manacore/feedback": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/help": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/subscriptions": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"d3": "^7.9.0",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"topojson-client": "^3.1.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../../packages/shared-ui/src";
|
||||
@source "../../../../packages/shared-auth-ui/src";
|
||||
@source "../../../../packages/shared-branding/src";
|
||||
@source "../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../packages/shared-theme-ui/src/pages";
|
||||
2
apps-archived/clock/apps/web/src/app.d.ts
vendored
2
apps-archived/clock/apps/web/src/app.d.ts
vendored
|
|
@ -1,2 +0,0 @@
|
|||
declare const __BUILD_HASH__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!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" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
initErrorTracking({
|
||||
serviceName: 'clock-web',
|
||||
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
|
||||
environment: import.meta.env.MODE,
|
||||
});
|
||||
|
||||
export const handleError: HandleClientError = ({ error }) => {
|
||||
handleSvelteError(error);
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { setSecurityHeaders } from '@manacore/shared-utils/security-headers';
|
||||
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
|
||||
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
|
||||
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
|
||||
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
});
|
||||
|
||||
setSecurityHeaders(response, {
|
||||
connectSrc: [PUBLIC_MANA_CORE_AUTH_URL_CLIENT, PUBLIC_BACKEND_URL_CLIENT],
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* Alarms API - Direct API calls for alarms
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
|
||||
export const alarmsApi = {
|
||||
getAll: () => api.get<Alarm[]>('/alarms'),
|
||||
getById: (id: string) => api.get<Alarm>(`/alarms/${id}`),
|
||||
create: (input: CreateAlarmInput) => api.post<Alarm>('/alarms', input),
|
||||
update: (id: string, input: UpdateAlarmInput) => api.patch<Alarm>(`/alarms/${id}`, input),
|
||||
delete: (id: string) => api.delete(`/alarms/${id}`),
|
||||
toggle: (id: string) => api.post<Alarm>(`/alarms/${id}/toggle`),
|
||||
};
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/**
|
||||
* API Client for Clock backend
|
||||
* Uses @manacore/shared-api-client for consistent error handling
|
||||
*/
|
||||
|
||||
import { createApiClient, type ApiResult } from '@manacore/shared-api-client';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_URL = 'http://localhost:3017';
|
||||
|
||||
/**
|
||||
* Clock API client instance
|
||||
* - Auto token handling via authStore.getValidToken()
|
||||
* - Consistent ApiResult<T> response format
|
||||
* - Automatic retry on server errors (configurable)
|
||||
*/
|
||||
export const api = createApiClient({
|
||||
baseUrl: API_URL,
|
||||
apiPrefix: '/api/v1',
|
||||
getAuthToken: () => authStore.getValidToken(),
|
||||
timeout: 30000,
|
||||
debug: import.meta.env.DEV,
|
||||
});
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { ApiResult };
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/**
|
||||
* Timers API - Direct API calls for timers
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
||||
|
||||
export const timersApi = {
|
||||
getAll: () => api.get<Timer[]>('/timers'),
|
||||
getById: (id: string) => api.get<Timer>(`/timers/${id}`),
|
||||
create: (input: CreateTimerInput) => api.post<Timer>('/timers', input),
|
||||
update: (id: string, input: UpdateTimerInput) => api.patch<Timer>(`/timers/${id}`, input),
|
||||
delete: (id: string) => api.delete(`/timers/${id}`),
|
||||
start: (id: string) => api.post<Timer>(`/timers/${id}/start`),
|
||||
pause: (id: string) => api.post<Timer>(`/timers/${id}/pause`),
|
||||
reset: (id: string) => api.post<Timer>(`/timers/${id}/reset`),
|
||||
};
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
action?: 'save' | 'sync' | 'feature';
|
||||
itemCount?: number;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { open, action = 'save', itemCount = 0, onClose }: Props = $props();
|
||||
|
||||
// Messages based on action type
|
||||
const messages = {
|
||||
save: {
|
||||
title: 'Daten speichern',
|
||||
description: 'Melde dich an, um deine Wecker und Timer dauerhaft in der Cloud zu speichern.',
|
||||
},
|
||||
sync: {
|
||||
title: 'Daten synchronisieren',
|
||||
description: 'Melde dich an, um deine Wecker und Timer auf allen Geräten zu synchronisieren.',
|
||||
},
|
||||
feature: {
|
||||
title: 'Funktion freischalten',
|
||||
description: 'Diese Funktion ist nur für angemeldete Benutzer verfügbar.',
|
||||
},
|
||||
};
|
||||
|
||||
const currentMessage = $derived(messages[action] || messages.save);
|
||||
|
||||
function handleLogin() {
|
||||
if (browser) {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function handleRegister() {
|
||||
if (browser) {
|
||||
sessionStorage.setItem('auth-return-url', window.location.pathname);
|
||||
}
|
||||
goto('/register');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal-backdrop" onclick={onClose}>
|
||||
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2>{currentMessage.title}</h2>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Schliessen">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>{currentMessage.description}</p>
|
||||
|
||||
{#if itemCount > 0}
|
||||
<div class="migration-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
<span
|
||||
>Du hast {itemCount}
|
||||
{itemCount === 1 ? 'Element' : 'Elemente'} in deiner Session. Diese werden nach der Anmeldung
|
||||
in deinen Account übertragen.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" onclick={onClose}> Später </button>
|
||||
<button class="btn btn-primary" onclick={handleLogin}> Anmelden </button>
|
||||
<button class="btn btn-outline" onclick={handleRegister}> Registrieren </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--color-surface-elevated-2);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
max-width: 28rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-foreground, #1f2937);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
border-radius: 0.375rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-foreground, #1f2937);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.migration-info {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--color-primary-50, #fef3c7);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary-700, #b45309);
|
||||
}
|
||||
|
||||
.migration-info svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary, #f59e0b);
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-primary-600, #d97706);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-muted, #f3f4f6);
|
||||
color: var(--color-muted-foreground, #6b7280);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-muted-200, #e5e7eb);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
color: var(--color-foreground, #1f2937);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--color-muted, #f3f4f6);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* WorldMap - Interactive world map component for world clock
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { POPULAR_TIMEZONES } from '@clock/shared';
|
||||
|
||||
interface Props {
|
||||
selectedTimezones?: string[];
|
||||
onCityClick?: (timezone: string, cityName: string) => void;
|
||||
}
|
||||
|
||||
let { selectedTimezones = [], onCityClick }: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let mapLoaded = $state(false);
|
||||
|
||||
// Get cities from popular timezones
|
||||
const cities = POPULAR_TIMEZONES.map((tz) => ({
|
||||
timezone: tz.timezone,
|
||||
city: tz.city,
|
||||
lat: tz.lat,
|
||||
lng: tz.lng,
|
||||
}));
|
||||
|
||||
function handleCityClick(timezone: string, cityName: string) {
|
||||
onCityClick?.(timezone, cityName);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
mapLoaded = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="world-map" bind:this={mapContainer}>
|
||||
{#if mapLoaded}
|
||||
<div class="map-placeholder">
|
||||
<svg viewBox="0 0 800 400" class="map-svg">
|
||||
<!-- Simple world outline -->
|
||||
<rect x="0" y="0" width="800" height="400" fill="hsl(var(--muted))" opacity="0.3" />
|
||||
|
||||
<!-- City markers -->
|
||||
{#each cities as city}
|
||||
{@const x = ((city.lng + 180) / 360) * 800}
|
||||
{@const y = ((90 - city.lat) / 180) * 400}
|
||||
{@const isSelected = selectedTimezones.includes(city.timezone)}
|
||||
<g class="city-marker" onclick={() => handleCityClick(city.timezone, city.city)}>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isSelected ? 8 : 5}
|
||||
fill={isSelected ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'}
|
||||
class="cursor-pointer hover:opacity-80 transition-all"
|
||||
/>
|
||||
{#if isSelected}
|
||||
<text
|
||||
{x}
|
||||
y={y - 12}
|
||||
text-anchor="middle"
|
||||
font-size="10"
|
||||
fill="hsl(var(--foreground))"
|
||||
class="pointer-events-none"
|
||||
>
|
||||
{city.city}
|
||||
</text>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="map-loading">
|
||||
<span class="text-muted-foreground">Karte wird geladen...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.world-map {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
background: hsl(var(--card));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.city-marker {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
daysLived: number;
|
||||
lifeExpectancyYears?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { daysLived, lifeExpectancyYears = 80, size = 280 }: Props = $props();
|
||||
|
||||
// Calculate progress
|
||||
let totalDays = $derived(Math.ceil(lifeExpectancyYears * 365.25));
|
||||
let percentage = $derived(Math.min((daysLived / totalDays) * 100, 100));
|
||||
let remainingDays = $derived(Math.max(totalDays - daysLived, 0));
|
||||
|
||||
// SVG calculations
|
||||
let strokeWidth = 12;
|
||||
let radius = $derived((size - strokeWidth) / 2);
|
||||
let circumference = $derived(2 * Math.PI * radius);
|
||||
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
|
||||
|
||||
// Animation
|
||||
let animatedOffset = $state(circumference);
|
||||
let mounted = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
// Animate on mount
|
||||
requestAnimationFrame(() => {
|
||||
animatedOffset = dashOffset;
|
||||
});
|
||||
});
|
||||
|
||||
// Update animation when values change
|
||||
$effect(() => {
|
||||
if (mounted) {
|
||||
animatedOffset = dashOffset;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="circular-container">
|
||||
<div class="circular-wrapper" style="width: {size}px; height: {size}px;">
|
||||
<svg width={size} height={size} viewBox="0 0 {size} {size}" class="circular-svg">
|
||||
<!-- Background circle -->
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--color-muted-foreground) / 0.15)"
|
||||
stroke-width={strokeWidth}
|
||||
/>
|
||||
|
||||
<!-- Progress circle -->
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--color-primary))"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={animatedOffset}
|
||||
transform="rotate(-90 {size / 2} {size / 2})"
|
||||
class="progress-circle"
|
||||
/>
|
||||
|
||||
<!-- Markers for decades -->
|
||||
{#each Array(8) as _, i}
|
||||
{@const angle = (i / 8) * 360 - 90}
|
||||
{@const markerRadius = radius + strokeWidth / 2 + 8}
|
||||
{@const x = size / 2 + markerRadius * Math.cos((angle * Math.PI) / 180)}
|
||||
{@const y = size / 2 + markerRadius * Math.sin((angle * Math.PI) / 180)}
|
||||
<text {x} {y} text-anchor="middle" dominant-baseline="middle" class="decade-marker">
|
||||
{i * 10}
|
||||
</text>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="center-content">
|
||||
<span class="percentage">{percentage.toFixed(1)}%</span>
|
||||
<span class="label">gelebt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="circular-stats">
|
||||
<div class="stat-row">
|
||||
<div class="stat">
|
||||
<span class="stat-value lived">{daysLived.toLocaleString('de-DE')}</span>
|
||||
<span class="stat-label">Tage gelebt</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat">
|
||||
<span class="stat-value remaining">{remainingDays.toLocaleString('de-DE')}</span>
|
||||
<span class="stat-label">Tage verbleibend</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="expectancy-note">Basierend auf {lifeExpectancyYears} Jahren Lebenserwartung</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.circular-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.circular-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.circular-svg {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.progress-circle {
|
||||
transition: stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.decade-marker {
|
||||
font-size: 0.625rem;
|
||||
fill: hsl(var(--color-muted-foreground));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.center-content {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 200;
|
||||
color: hsl(var(--color-foreground));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.circular-stats {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 2.5rem;
|
||||
background: hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-value.lived {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.stat-value.remaining {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.expectancy-note {
|
||||
font-size: 0.625rem;
|
||||
color: hsl(var(--color-muted-foreground) / 0.7);
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AlarmsSkeleton - Loading skeleton for alarms page
|
||||
*/
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="alarms-skeleton" role="status" aria-label="Alarme werden geladen...">
|
||||
<!-- Presets section -->
|
||||
<div class="presets-grid">
|
||||
{#each Array(6) as _, i}
|
||||
<div class="preset-item" style="opacity: {Math.max(0.4, 1 - i * 0.1)};">
|
||||
<SkeletonBox width="100%" height="64px" borderRadius="12px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Alarm list -->
|
||||
<div class="alarm-list">
|
||||
{#each Array(3) as _, i}
|
||||
<div class="alarm-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
|
||||
<div class="alarm-content">
|
||||
<SkeletonBox width="80px" height="32px" />
|
||||
<SkeletonBox width="120px" height="16px" />
|
||||
</div>
|
||||
<SkeletonBox width="48px" height="24px" borderRadius="12px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alarms-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.alarm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.alarm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.alarm-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.presets-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* AppLoadingSkeleton - Full page loading skeleton for initial app load
|
||||
* Shows a minimal skeleton layout while auth is being checked
|
||||
*/
|
||||
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
|
||||
<!-- Header placeholder -->
|
||||
<div class="header-skeleton">
|
||||
<SkeletonBox width="120px" height="32px" borderRadius="8px" />
|
||||
<div class="header-nav">
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
</div>
|
||||
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
|
||||
</div>
|
||||
|
||||
<!-- Content placeholder - Clock specific -->
|
||||
<div class="content-skeleton">
|
||||
<!-- Clock display placeholder -->
|
||||
<div class="clock-placeholder">
|
||||
<SkeletonBox width="300px" height="300px" borderRadius="50%" />
|
||||
</div>
|
||||
|
||||
<!-- Controls placeholder -->
|
||||
<div class="controls-placeholder">
|
||||
<SkeletonBox width="200px" height="48px" borderRadius="12px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-loading-skeleton {
|
||||
min-height: 100vh;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 80px);
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.clock-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controls-placeholder {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* TimersSkeleton - Loading skeleton for timers page
|
||||
*/
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="timers-skeleton" role="status" aria-label="Timer werden geladen...">
|
||||
<!-- Quick presets -->
|
||||
<div class="presets-row">
|
||||
{#each Array(4) as _, i}
|
||||
<SkeletonBox width="80px" height="40px" borderRadius="20px" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Timer form -->
|
||||
<div class="timer-form">
|
||||
<SkeletonBox width="100%" height="80px" borderRadius="12px" />
|
||||
<SkeletonBox width="120px" height="44px" borderRadius="8px" />
|
||||
</div>
|
||||
|
||||
<!-- Active timers -->
|
||||
<div class="timers-list">
|
||||
{#each Array(2) as _, i}
|
||||
<div class="timer-item" style="opacity: {Math.max(0.4, 1 - i * 0.2)};">
|
||||
<div class="timer-display">
|
||||
<SkeletonBox width="150px" height="40px" />
|
||||
<SkeletonBox width="80px" height="16px" />
|
||||
</div>
|
||||
<div class="timer-controls">
|
||||
<SkeletonBox width="44px" height="44px" borderRadius="50%" />
|
||||
<SkeletonBox width="44px" height="44px" borderRadius="50%" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timers-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.presets-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.timers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.timer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timer-controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* WorldClockSkeleton - Loading skeleton for world clock page
|
||||
*/
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="world-clock-skeleton" role="status" aria-label="Weltuhren werden geladen...">
|
||||
<!-- Map skeleton -->
|
||||
<div class="map-skeleton">
|
||||
<SkeletonBox width="100%" height="300px" borderRadius="12px" />
|
||||
</div>
|
||||
|
||||
<!-- Clock list -->
|
||||
<div class="clocks-list">
|
||||
{#each Array(4) as _, i}
|
||||
<div class="clock-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
|
||||
<div class="clock-info">
|
||||
<SkeletonBox width="120px" height="24px" />
|
||||
<SkeletonBox width="80px" height="16px" />
|
||||
</div>
|
||||
<SkeletonBox width="100px" height="36px" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.world-clock-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.map-skeleton {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.clocks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clock-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.clock-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* Clock App Skeleton Components
|
||||
*
|
||||
* App-specific skeleton loaders for loading states.
|
||||
*/
|
||||
|
||||
// App Loading Skeleton
|
||||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
|
||||
// Feature Skeletons
|
||||
export { default as AlarmsSkeleton } from './AlarmsSkeleton.svelte';
|
||||
export { default as TimersSkeleton } from './TimersSkeleton.svelte';
|
||||
export { default as WorldClockSkeleton } from './WorldClockSkeleton.svelte';
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { getClockHelpContent } from './index';
|
||||
|
||||
describe('Clock Help Content', () => {
|
||||
it('returns valid German content', () => {
|
||||
const content = getClockHelpContent('de');
|
||||
|
||||
expect(content.faq.length).toBeGreaterThan(0);
|
||||
content.faq.forEach((faq) => {
|
||||
expect(faq.id).toBeTruthy();
|
||||
expect(faq.question).toBeTruthy();
|
||||
expect(faq.answer).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(content.features).toBeDefined();
|
||||
expect(content.contact).toBeDefined();
|
||||
expect(content.contact.supportEmail).toBe('support@mana.how');
|
||||
});
|
||||
|
||||
it('returns valid English content', () => {
|
||||
const content = getClockHelpContent('en');
|
||||
|
||||
expect(content.faq.length).toBeGreaterThan(0);
|
||||
content.faq.forEach((faq) => {
|
||||
expect(faq.id).toBeTruthy();
|
||||
expect(faq.question).toBeTruthy();
|
||||
expect(faq.answer).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(content.features).toBeDefined();
|
||||
expect(content.contact).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns same number of FAQ items for both languages', () => {
|
||||
const de = getClockHelpContent('de');
|
||||
const en = getClockHelpContent('en');
|
||||
|
||||
expect(de.faq.length).toBe(en.faq.length);
|
||||
expect(de.features.length).toBe(en.features.length);
|
||||
});
|
||||
|
||||
it('has unique FAQ IDs', () => {
|
||||
const content = getClockHelpContent('de');
|
||||
const ids = content.faq.map((f) => f.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
/**
|
||||
* Help content for Clock app
|
||||
*/
|
||||
|
||||
import type { HelpContent } from '@manacore/help';
|
||||
import { getPrivacyFAQs } from '@manacore/help';
|
||||
|
||||
export function getClockHelpContent(locale: string): HelpContent {
|
||||
const isDE = locale === 'de';
|
||||
|
||||
return {
|
||||
faq: [
|
||||
{
|
||||
id: 'faq-create-alarms',
|
||||
question: isDE ? 'Wie erstelle ich Wecker?' : 'How do I create alarms?',
|
||||
answer: isDE
|
||||
? '<p>Du kannst Wecker auf verschiedene Arten erstellen:</p><ul><li><strong>Schnellwecker</strong>: Drücke <kbd>A</kbd> oder klicke auf das + Symbol im Wecker-Tab</li><li><strong>Uhrzeit wählen</strong>: Stelle Stunde und Minute ein und wähle die gewünschten Wochentage</li><li><strong>Label</strong>: Gib deinem Wecker einen Namen, z.B. "Morgenroutine"</li><li><strong>Klingelton</strong>: Wähle aus verschiedenen Tönen oder nutze einen sanften Weckton</li></ul>'
|
||||
: '<p>You can create alarms in several ways:</p><ul><li><strong>Quick alarm</strong>: Press <kbd>A</kbd> or click the + icon in the Alarms tab</li><li><strong>Set time</strong>: Choose hour and minute and select the desired weekdays</li><li><strong>Label</strong>: Give your alarm a name, e.g. "Morning routine"</li><li><strong>Ringtone</strong>: Choose from various sounds or use a gentle wake-up tone</li></ul>',
|
||||
category: 'features',
|
||||
order: 1,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE ? ['wecker', 'erstellen', 'neu'] : ['alarm', 'create', 'new'],
|
||||
},
|
||||
{
|
||||
id: 'faq-timers',
|
||||
question: isDE
|
||||
? 'Wie funktionieren Timer und Stoppuhr?'
|
||||
: 'How do timers and the stopwatch work?',
|
||||
answer: isDE
|
||||
? '<p>Clock bietet zwei Zeitmesser:</p><ul><li><strong>Timer</strong>: Stelle eine Countdown-Zeit ein und starte ihn. Du kannst mehrere Timer gleichzeitig laufen lassen. Drücke <kbd>T</kbd> für einen neuen Timer.</li><li><strong>Stoppuhr</strong>: Messe verstrichene Zeit mit Rundenzeiten. Starte, pausiere und setze zurück.</li></ul><p>Beide laufen auch im Hintergrund weiter und benachrichtigen dich, wenn die Zeit abgelaufen ist.</p>'
|
||||
: '<p>Clock offers two time measurement tools:</p><ul><li><strong>Timer</strong>: Set a countdown duration and start it. You can run multiple timers simultaneously. Press <kbd>T</kbd> for a new timer.</li><li><strong>Stopwatch</strong>: Measure elapsed time with lap splits. Start, pause, and reset.</li></ul><p>Both continue running in the background and notify you when time is up.</p>',
|
||||
category: 'features',
|
||||
order: 2,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE ? ['timer', 'stoppuhr', 'countdown'] : ['timer', 'stopwatch', 'countdown'],
|
||||
},
|
||||
{
|
||||
id: 'faq-pomodoro',
|
||||
question: isDE ? 'Was ist die Pomodoro-Technik?' : 'What is the Pomodoro technique?',
|
||||
answer: isDE
|
||||
? '<p>Die <strong>Pomodoro-Technik</strong> ist eine Zeitmanagement-Methode:</p><ol><li>Arbeite <strong>25 Minuten</strong> konzentriert (ein "Pomodoro")</li><li>Mache eine <strong>5-Minuten-Pause</strong></li><li>Nach 4 Pomodoros: <strong>15-30 Minuten</strong> längere Pause</li></ol><p>In Clock kannst du die Intervalle anpassen, deinen Fortschritt verfolgen und Statistiken über deine Produktivität einsehen.</p>'
|
||||
: '<p>The <strong>Pomodoro technique</strong> is a time management method:</p><ol><li>Work for <strong>25 minutes</strong> with focus (one "Pomodoro")</li><li>Take a <strong>5-minute break</strong></li><li>After 4 Pomodoros: take a <strong>15-30 minute</strong> longer break</li></ol><p>In Clock you can customize the intervals, track your progress, and view statistics about your productivity.</p>',
|
||||
category: 'features',
|
||||
order: 3,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE
|
||||
? ['pomodoro', 'produktivität', 'fokus', 'technik']
|
||||
: ['pomodoro', 'productivity', 'focus', 'technique'],
|
||||
},
|
||||
{
|
||||
id: 'faq-life-clock',
|
||||
question: isDE ? 'Was ist die Life Clock?' : 'What is the Life Clock?',
|
||||
answer: isDE
|
||||
? '<p>Die <strong>Life Clock</strong> ist eine einzigartige Visualisierung deiner Lebenszeit:</p><ul><li>Gib dein Geburtsdatum und deine geschätzte Lebenserwartung ein</li><li>Sieh, wie viel deiner Zeit bereits vergangen ist und wie viel noch vor dir liegt</li><li>Verschiedene Darstellungen: Wochen, Monate oder Jahre als Raster</li></ul><p>Die Life Clock soll dich motivieren, deine Zeit bewusst zu nutzen — keine Angst, sondern <strong>Inspiration</strong>.</p>'
|
||||
: '<p>The <strong>Life Clock</strong> is a unique visualization of your lifetime:</p><ul><li>Enter your birth date and estimated life expectancy</li><li>See how much of your time has passed and how much lies ahead</li><li>Various display modes: weeks, months, or years as a grid</li></ul><p>The Life Clock is meant to motivate you to use your time mindfully — not fear, but <strong>inspiration</strong>.</p>',
|
||||
category: 'features',
|
||||
order: 4,
|
||||
language: isDE ? 'de' : 'en',
|
||||
tags: isDE
|
||||
? ['life-clock', 'lebenszeit', 'visualisierung']
|
||||
: ['life-clock', 'lifetime', 'visualization'],
|
||||
},
|
||||
...getPrivacyFAQs(locale, {
|
||||
dataTypeDE: 'Daten',
|
||||
dataTypeEN: 'data',
|
||||
extraBulletsDE: [
|
||||
'<strong>Lokale Speicherung</strong>: Wecker und Timer werden lokal auf deinem Gerät gespeichert',
|
||||
],
|
||||
extraBulletsEN: [
|
||||
'<strong>Local storage</strong>: Alarms and timers are stored locally on your device',
|
||||
],
|
||||
}),
|
||||
],
|
||||
features: [
|
||||
{
|
||||
id: 'feature-alarms',
|
||||
title: isDE ? 'Wecker' : 'Alarms',
|
||||
description: isDE
|
||||
? 'Erstelle wiederkehrende und einmalige Wecker mit individuellen Tönen'
|
||||
: 'Create recurring and one-time alarms with custom sounds',
|
||||
icon: '⏰',
|
||||
category: 'core',
|
||||
highlights: isDE
|
||||
? ['Wiederkehrende Wecker', 'Individuelle Töne', 'Schlummerfunktion', 'Labels']
|
||||
: ['Recurring alarms', 'Custom sounds', 'Snooze function', 'Labels'],
|
||||
content: '',
|
||||
order: 1,
|
||||
language: isDE ? 'de' : 'en',
|
||||
},
|
||||
{
|
||||
id: 'feature-timers-stopwatch',
|
||||
title: isDE ? 'Timer & Stoppuhr' : 'Timers & Stopwatch',
|
||||
description: isDE
|
||||
? 'Mehrere gleichzeitige Timer und eine Stoppuhr mit Rundenzeiten'
|
||||
: 'Multiple simultaneous timers and a stopwatch with lap times',
|
||||
icon: '⏱️',
|
||||
category: 'core',
|
||||
highlights: isDE
|
||||
? ['Mehrere Timer', 'Rundenzeiten', 'Hintergrund-Benachrichtigung', 'Voreinstellungen']
|
||||
: ['Multiple timers', 'Lap times', 'Background notifications', 'Presets'],
|
||||
content: '',
|
||||
order: 2,
|
||||
language: isDE ? 'de' : 'en',
|
||||
},
|
||||
{
|
||||
id: 'feature-pomodoro',
|
||||
title: 'Pomodoro',
|
||||
description: isDE
|
||||
? 'Steigere deine Produktivität mit der Pomodoro-Technik und Statistiken'
|
||||
: 'Boost your productivity with the Pomodoro technique and statistics',
|
||||
icon: '🍅',
|
||||
category: 'advanced',
|
||||
highlights: isDE
|
||||
? ['Anpassbare Intervalle', 'Sitzungs-Tracking', 'Statistiken', 'Benachrichtigungen']
|
||||
: ['Customizable intervals', 'Session tracking', 'Statistics', 'Notifications'],
|
||||
content: '',
|
||||
order: 3,
|
||||
language: isDE ? 'de' : 'en',
|
||||
},
|
||||
{
|
||||
id: 'feature-world-clock',
|
||||
title: isDE ? 'Weltzeituhr' : 'World Clock',
|
||||
description: isDE
|
||||
? 'Behalte die Uhrzeit in verschiedenen Zeitzonen im Blick'
|
||||
: 'Keep track of the time across different time zones',
|
||||
icon: '🌍',
|
||||
category: 'core',
|
||||
highlights: isDE
|
||||
? ['Alle Zeitzonen', 'Zeitvergleich', 'Favoriten', 'Analoges Zifferblatt']
|
||||
: ['All time zones', 'Time comparison', 'Favorites', 'Analog clock face'],
|
||||
content: '',
|
||||
order: 4,
|
||||
language: isDE ? 'de' : 'en',
|
||||
},
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
id: 'shortcuts-general',
|
||||
category: 'general',
|
||||
title: isDE ? 'Allgemein' : 'General',
|
||||
language: isDE ? 'de' : 'en',
|
||||
order: 1,
|
||||
shortcuts: [
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + K',
|
||||
action: isDE ? 'Kommandoleiste öffnen' : 'Open command bar',
|
||||
},
|
||||
{
|
||||
shortcut: 'A',
|
||||
action: isDE ? 'Neuer Wecker' : 'New alarm',
|
||||
},
|
||||
{
|
||||
shortcut: 'T',
|
||||
action: isDE ? 'Neuer Timer' : 'New timer',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'shortcuts-navigation',
|
||||
category: 'navigation',
|
||||
title: 'Navigation',
|
||||
language: isDE ? 'de' : 'en',
|
||||
order: 2,
|
||||
shortcuts: [
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 1',
|
||||
action: isDE ? 'Wecker öffnen' : 'Open Alarms',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 2',
|
||||
action: isDE ? 'Timer öffnen' : 'Open Timers',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 3',
|
||||
action: isDE ? 'Stoppuhr öffnen' : 'Open Stopwatch',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 4',
|
||||
action: isDE ? 'Pomodoro öffnen' : 'Open Pomodoro',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 5',
|
||||
action: isDE ? 'Weltzeituhr öffnen' : 'Open World Clock',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 6',
|
||||
action: isDE ? 'Life Clock öffnen' : 'Open Life Clock',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 7',
|
||||
action: isDE ? 'Statistiken öffnen' : 'Open Statistics',
|
||||
},
|
||||
{
|
||||
shortcut: 'Cmd/Ctrl + 8',
|
||||
action: isDE ? 'Einstellungen öffnen' : 'Open Settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
gettingStarted: [],
|
||||
changelog: [],
|
||||
contact: {
|
||||
id: 'contact-support',
|
||||
title: isDE ? 'Support kontaktieren' : 'Contact Support',
|
||||
content: isDE
|
||||
? '<p>Unser Support-Team hilft dir bei allen Fragen rund um Clock.</p>'
|
||||
: '<p>Our support team is here to help you with any questions about Clock.</p>',
|
||||
language: isDE ? 'de' : 'en',
|
||||
order: 1,
|
||||
supportEmail: 'support@mana.how',
|
||||
documentationUrl: 'https://mana.how/docs',
|
||||
responseTime: isDE ? 'Normalerweise innerhalb von 24 Stunden' : 'Usually within 24 hours',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
/**
|
||||
* Guest seed data for the Clock app.
|
||||
*
|
||||
* These records are loaded into IndexedDB when a new guest visits the app.
|
||||
* They provide sample alarms and world clocks to showcase the app.
|
||||
*/
|
||||
|
||||
import type { LocalAlarm, LocalWorldClock } from './local-store';
|
||||
|
||||
export const guestAlarms: LocalAlarm[] = [
|
||||
{
|
||||
id: 'alarm-weekday-morning',
|
||||
label: 'Wecker Wochentags',
|
||||
time: '07:00',
|
||||
enabled: true,
|
||||
repeatDays: [1, 2, 3, 4, 5], // Mon-Fri
|
||||
snoozeMinutes: 5,
|
||||
sound: null,
|
||||
vibrate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const guestWorldClocks: LocalWorldClock[] = [
|
||||
{
|
||||
id: 'wc-new-york',
|
||||
timezone: 'America/New_York',
|
||||
cityName: 'New York',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: 'wc-tokyo',
|
||||
timezone: 'Asia/Tokyo',
|
||||
cityName: 'Tokio',
|
||||
sortOrder: 1,
|
||||
},
|
||||
];
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* Clock App — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* This is the single source of truth for all Clock data.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import { guestAlarms, guestWorldClocks } from './guest-seed';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface LocalAlarm extends BaseRecord {
|
||||
label: string | null;
|
||||
time: string; // HH:mm format
|
||||
enabled: boolean;
|
||||
repeatDays: number[] | null; // [0-6] where 0 = Sunday
|
||||
snoozeMinutes: number | null;
|
||||
sound: string | null;
|
||||
vibrate: boolean | null;
|
||||
}
|
||||
|
||||
export interface LocalTimer extends BaseRecord {
|
||||
label: string | null;
|
||||
durationSeconds: number;
|
||||
remainingSeconds: number | null;
|
||||
status: 'idle' | 'running' | 'paused' | 'finished';
|
||||
startedAt: string | null;
|
||||
pausedAt: string | null;
|
||||
sound: string | null;
|
||||
}
|
||||
|
||||
export interface LocalWorldClock extends BaseRecord {
|
||||
timezone: string; // IANA timezone e.g. 'America/New_York'
|
||||
cityName: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const clockStore = createLocalStore({
|
||||
appId: 'clock',
|
||||
collections: [
|
||||
{
|
||||
name: 'alarms',
|
||||
indexes: ['enabled', 'time'],
|
||||
guestSeed: guestAlarms,
|
||||
},
|
||||
{
|
||||
name: 'timers',
|
||||
indexes: ['status'],
|
||||
},
|
||||
{
|
||||
name: 'worldClocks',
|
||||
indexes: ['sortOrder', 'timezone'],
|
||||
guestSeed: guestWorldClocks,
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const alarmCollection = clockStore.collection<LocalAlarm>('alarms');
|
||||
export const timerCollection = clockStore.collection<LocalTimer>('timers');
|
||||
export const worldClockCollection = clockStore.collection<LocalWorldClock>('worldClocks');
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/**
|
||||
* Reactive Queries & Pure Helpers for Clock
|
||||
*
|
||||
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
|
||||
* (local writes, sync updates, other tabs). Components call these hooks
|
||||
* at init time; no manual fetch/refresh needed.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import {
|
||||
alarmCollection,
|
||||
timerCollection,
|
||||
worldClockCollection,
|
||||
type LocalAlarm,
|
||||
type LocalTimer,
|
||||
type LocalWorldClock,
|
||||
} from './local-store';
|
||||
import type { Alarm, Timer, WorldClock } from '@clock/shared';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
export function toAlarm(local: LocalAlarm): Alarm {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
label: local.label,
|
||||
time: local.time,
|
||||
enabled: local.enabled,
|
||||
repeatDays: local.repeatDays,
|
||||
snoozeMinutes: local.snoozeMinutes,
|
||||
sound: local.sound,
|
||||
vibrate: local.vibrate ?? null,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toTimer(local: LocalTimer): Timer {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
label: local.label,
|
||||
durationSeconds: local.durationSeconds,
|
||||
remainingSeconds: local.remainingSeconds,
|
||||
status: local.status,
|
||||
startedAt: local.startedAt,
|
||||
pausedAt: local.pausedAt,
|
||||
sound: local.sound,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toWorldClock(local: LocalWorldClock): WorldClock {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: 'local',
|
||||
timezone: local.timezone,
|
||||
cityName: local.cityName,
|
||||
sortOrder: local.sortOrder,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Query Hooks (call during component init) ─────────
|
||||
|
||||
/** All alarms, auto-updates on any change. */
|
||||
export function useAllAlarms() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await alarmCollection.getAll();
|
||||
return locals.map(toAlarm);
|
||||
}, [] as Alarm[]);
|
||||
}
|
||||
|
||||
/** All timers, auto-updates on any change. */
|
||||
export function useAllTimers() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await timerCollection.getAll();
|
||||
return locals.map(toTimer);
|
||||
}, [] as Timer[]);
|
||||
}
|
||||
|
||||
/** All world clocks, sorted by sortOrder. Auto-updates on any change. */
|
||||
export function useAllWorldClocks() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await worldClockCollection.getAll(undefined, {
|
||||
sortBy: 'sortOrder',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
return locals.map(toWorldClock);
|
||||
}, [] as WorldClock[]);
|
||||
}
|
||||
|
||||
// ─── Pure Filter Functions (for $derived) ──────────────────
|
||||
|
||||
export function filterEnabledAlarms(alarms: Alarm[]): Alarm[] {
|
||||
return alarms.filter((a) => a.enabled);
|
||||
}
|
||||
|
||||
export function filterActiveTimers(timers: Timer[]): Timer[] {
|
||||
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
|
||||
}
|
||||
|
||||
export function sortWorldClocksByOrder(clocks: WorldClock[]): WorldClock[] {
|
||||
return [...clocks].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
// List of supported locales
|
||||
export const supportedLocales = ['de', 'en'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
// Default locale
|
||||
const defaultLocale = 'de';
|
||||
|
||||
// Register all available locales
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
|
||||
// Get initial locale from browser or localStorage
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
// Check localStorage first
|
||||
const stored = localStorage.getItem('clock_locale');
|
||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||
return stored as SupportedLocale;
|
||||
}
|
||||
|
||||
// Fall back to browser language
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (supportedLocales.includes(browserLang as SupportedLocale)) {
|
||||
return browserLang as SupportedLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
// Initialize i18n at module scope (required for SSR)
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
// Set locale and persist to localStorage
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('clock_locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for locale to be loaded (useful for SSR)
|
||||
export { waitLocale };
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock"
|
||||
},
|
||||
"common": {
|
||||
"back": "Zurück",
|
||||
"cancel": "Abbrechen",
|
||||
"loading": "Lade..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Startseite",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"clock": {
|
||||
"title": "Life Clock",
|
||||
"remaining": "Verbleibende Zeit",
|
||||
"elapsed": "Vergangene Zeit"
|
||||
},
|
||||
"messages": {
|
||||
"saved": "Gespeichert",
|
||||
"error": "Ein Fehler ist aufgetreten"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock"
|
||||
},
|
||||
"common": {
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"clock": {
|
||||
"title": "Life Clock",
|
||||
"remaining": "Time remaining",
|
||||
"elapsed": "Time elapsed"
|
||||
},
|
||||
"messages": {
|
||||
"saved": "Saved",
|
||||
"error": "An error occurred"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
/**
|
||||
* Alarms Store — Mutation-Only Service
|
||||
*
|
||||
* All reads are handled by useLiveQuery() hooks in queries.ts.
|
||||
* This store only provides write operations (create, update, delete, toggle).
|
||||
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
|
||||
*/
|
||||
|
||||
import { alarmCollection, type LocalAlarm } from '$lib/data/local-store';
|
||||
import { toAlarm } from '$lib/data/queries';
|
||||
import type { CreateAlarmInput, UpdateAlarmInput, Alarm } from '@clock/shared';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const alarmsStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new alarm -- writes to IndexedDB instantly.
|
||||
*/
|
||||
async createAlarm(input: CreateAlarmInput) {
|
||||
error = null;
|
||||
try {
|
||||
const newLocal: LocalAlarm = {
|
||||
id: crypto.randomUUID(),
|
||||
label: input.label ?? null,
|
||||
time: input.time,
|
||||
enabled: input.enabled ?? true,
|
||||
repeatDays: input.repeatDays ?? null,
|
||||
snoozeMinutes: input.snoozeMinutes ?? null,
|
||||
sound: input.sound ?? null,
|
||||
vibrate: input.vibrate ?? null,
|
||||
};
|
||||
|
||||
const inserted = await alarmCollection.insert(newLocal);
|
||||
return { success: true, data: toAlarm(inserted) };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create alarm';
|
||||
console.error('Failed to create alarm:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an alarm -- writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateAlarm(id: string, input: UpdateAlarmInput) {
|
||||
error = null;
|
||||
try {
|
||||
const updateData: Partial<LocalAlarm> = {};
|
||||
if (input.label !== undefined) updateData.label = input.label ?? null;
|
||||
if (input.time !== undefined) updateData.time = input.time;
|
||||
if (input.enabled !== undefined) updateData.enabled = input.enabled;
|
||||
if (input.repeatDays !== undefined) updateData.repeatDays = input.repeatDays ?? null;
|
||||
if (input.snoozeMinutes !== undefined) updateData.snoozeMinutes = input.snoozeMinutes ?? null;
|
||||
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
|
||||
if (input.vibrate !== undefined) updateData.vibrate = input.vibrate ?? null;
|
||||
|
||||
const updated = await alarmCollection.update(id, updateData);
|
||||
if (updated) {
|
||||
return { success: true, data: toAlarm(updated) };
|
||||
}
|
||||
return { success: false, error: 'Alarm not found' };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update alarm';
|
||||
console.error('Failed to update alarm:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle alarm enabled state.
|
||||
*/
|
||||
async toggleAlarm(id: string, currentAlarms: Alarm[]) {
|
||||
const alarm = currentAlarms.find((a) => a.id === id);
|
||||
if (!alarm) return { success: false, error: 'Alarm not found' };
|
||||
|
||||
return this.updateAlarm(id, { enabled: !alarm.enabled });
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an alarm -- removes from IndexedDB instantly.
|
||||
*/
|
||||
async deleteAlarm(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await alarmCollection.delete(id);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete alarm';
|
||||
console.error('Failed to delete alarm:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
|
||||
import { userSettings } from './user-settings.svelte';
|
||||
|
||||
/**
|
||||
* Clock-specific onboarding steps
|
||||
*/
|
||||
const clockOnboardingSteps: AppOnboardingStep[] = [
|
||||
{
|
||||
id: 'features',
|
||||
type: 'info',
|
||||
question: 'Willkommen bei Clock!',
|
||||
description: 'Das kann Clock für dich tun:',
|
||||
emoji: '🕐',
|
||||
gradient: { from: 'blue-500', to: 'blue-700' },
|
||||
bullets: [
|
||||
'Flexible Timer & Stoppuhr',
|
||||
'Pomodoro-Technik für produktives Arbeiten',
|
||||
'Voreingestellte Timer-Dauern',
|
||||
'Minimalistisches Design',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'defaultTimer',
|
||||
type: 'select',
|
||||
question: 'Welche Timer-Dauer nutzt du am häufigsten?',
|
||||
description: 'Du kannst Timer jederzeit individuell einstellen.',
|
||||
emoji: '⏱️',
|
||||
gradient: { from: 'blue-500', to: 'blue-700' },
|
||||
options: [
|
||||
{
|
||||
id: '5',
|
||||
label: '5 Minuten',
|
||||
description: 'Für kurze Pausen',
|
||||
emoji: '⚡',
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
label: '15 Minuten',
|
||||
description: 'Für konzentrierte Einheiten',
|
||||
emoji: '🎯',
|
||||
},
|
||||
{
|
||||
id: '25',
|
||||
label: '25 Minuten',
|
||||
description: 'Pomodoro-Technik (Empfohlen)',
|
||||
emoji: '🍅',
|
||||
},
|
||||
{
|
||||
id: '45',
|
||||
label: '45 Minuten',
|
||||
description: 'Für längere Arbeitsphasen',
|
||||
emoji: '🧘',
|
||||
},
|
||||
],
|
||||
defaultValue: '25',
|
||||
},
|
||||
{
|
||||
id: 'welcome',
|
||||
type: 'info',
|
||||
question: 'Deine Uhr ist bereit!',
|
||||
description: 'Hier sind einige Tipps:',
|
||||
emoji: '🎉',
|
||||
gradient: { from: 'primary', to: 'primary/70' },
|
||||
bullets: [
|
||||
'Nutze die Stoppuhr für freie Zeitmessung',
|
||||
'Stelle Wecker für wichtige Erinnerungen',
|
||||
'Die Weltuhr zeigt mehrere Zeitzonen gleichzeitig',
|
||||
'Drücke Cmd/Ctrl+K für die Schnellsuche',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Clock app onboarding store
|
||||
*/
|
||||
export const clockOnboarding = createAppOnboardingStore({
|
||||
appId: 'clock',
|
||||
steps: clockOnboardingSteps,
|
||||
userSettings,
|
||||
onComplete: async () => {},
|
||||
onSkip: async () => {},
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* Auth Store — uses centralized Mana auth factory.
|
||||
*/
|
||||
|
||||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore({
|
||||
devBackendPort: 3017,
|
||||
});
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||
|
||||
export const { isNavCollapsed } = createSimpleNavigationStores({
|
||||
storageKey: 'clock',
|
||||
});
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
/**
|
||||
* Session Alarms Store - Manages alarms in sessionStorage for guest users
|
||||
* This allows users to try the app without signing in.
|
||||
* Data is stored in sessionStorage (lost when tab closes).
|
||||
*/
|
||||
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
|
||||
const STORAGE_KEY = 'clock-session-alarms';
|
||||
|
||||
// State
|
||||
let alarms = $state<Alarm[]>([]);
|
||||
|
||||
// Generate session ID
|
||||
function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
// Load from sessionStorage
|
||||
function loadFromStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
alarms = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load session alarms:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to sessionStorage
|
||||
function saveToStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(alarms));
|
||||
} catch (e) {
|
||||
console.error('Failed to save session alarms:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
if (typeof window !== 'undefined') {
|
||||
loadFromStorage();
|
||||
}
|
||||
|
||||
export const sessionAlarmsStore = {
|
||||
// Getters
|
||||
get alarms() {
|
||||
return alarms;
|
||||
},
|
||||
get enabledAlarms() {
|
||||
return alarms.filter((a) => a.enabled);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new session alarm
|
||||
*/
|
||||
createAlarm(input: CreateAlarmInput): Alarm {
|
||||
const now = new Date().toISOString();
|
||||
const alarm: Alarm = {
|
||||
id: generateSessionId(),
|
||||
userId: 'guest',
|
||||
label: input.label || null,
|
||||
time: input.time,
|
||||
enabled: input.enabled ?? true,
|
||||
repeatDays: input.repeatDays || null,
|
||||
snoozeMinutes: input.snoozeMinutes || null,
|
||||
sound: input.sound || null,
|
||||
vibrate: input.vibrate ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
alarms = [...alarms, alarm];
|
||||
saveToStorage();
|
||||
|
||||
return alarm;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a session alarm
|
||||
*/
|
||||
updateAlarm(id: string, input: UpdateAlarmInput): Alarm | null {
|
||||
const index = alarms.findIndex((a) => a.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const updated: Alarm = {
|
||||
...alarms[index],
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
alarms = alarms.map((a) => (a.id === id ? updated : a));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle alarm enabled state
|
||||
*/
|
||||
toggleAlarm(id: string): Alarm | null {
|
||||
const alarm = alarms.find((a) => a.id === id);
|
||||
if (!alarm) return null;
|
||||
|
||||
return this.updateAlarm(id, { enabled: !alarm.enabled });
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a session alarm
|
||||
*/
|
||||
deleteAlarm(id: string): void {
|
||||
alarms = alarms.filter((a) => a.id !== id);
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if ID is a session alarm
|
||||
*/
|
||||
isSessionAlarm(id: string): boolean {
|
||||
return id.startsWith('session_');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all alarms for migration
|
||||
*/
|
||||
getAllAlarms(): Alarm[] {
|
||||
return [...alarms];
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all session data
|
||||
*/
|
||||
clear(): void {
|
||||
alarms = [];
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of session alarms
|
||||
*/
|
||||
get count(): number {
|
||||
return alarms.length;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
/**
|
||||
* Session Timers Store - Manages timers in sessionStorage for guest users
|
||||
* This allows users to try the app without signing in.
|
||||
* Data is stored in sessionStorage (lost when tab closes).
|
||||
*/
|
||||
|
||||
import type { Timer, CreateTimerInput, UpdateTimerInput, TimerStatus } from '@clock/shared';
|
||||
|
||||
const STORAGE_KEY = 'clock-session-timers';
|
||||
|
||||
// State
|
||||
let timers = $state<Timer[]>([]);
|
||||
|
||||
// Generate session ID
|
||||
function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
// Load from sessionStorage
|
||||
function loadFromStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
timers = JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load session timers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to sessionStorage
|
||||
function saveToStorage(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(timers));
|
||||
} catch (e) {
|
||||
console.error('Failed to save session timers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
if (typeof window !== 'undefined') {
|
||||
loadFromStorage();
|
||||
}
|
||||
|
||||
export const sessionTimersStore = {
|
||||
// Getters
|
||||
get timers() {
|
||||
return timers;
|
||||
},
|
||||
get activeTimers() {
|
||||
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new session timer
|
||||
*/
|
||||
createTimer(input: CreateTimerInput): Timer {
|
||||
const now = new Date().toISOString();
|
||||
const timer: Timer = {
|
||||
id: generateSessionId(),
|
||||
userId: 'guest',
|
||||
label: input.label || null,
|
||||
durationSeconds: input.durationSeconds,
|
||||
remainingSeconds: input.durationSeconds,
|
||||
status: 'idle' as TimerStatus,
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
sound: input.sound || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
timers = [...timers, timer];
|
||||
saveToStorage();
|
||||
|
||||
return timer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a session timer
|
||||
*/
|
||||
updateTimer(id: string, input: UpdateTimerInput): Timer | null {
|
||||
const index = timers.findIndex((t) => t.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const updated: Timer = {
|
||||
...timers[index],
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
timers = timers.map((t) => (t.id === id ? updated : t));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a timer
|
||||
*/
|
||||
startTimer(id: string): Timer | null {
|
||||
const timer = timers.find((t) => t.id === id);
|
||||
if (!timer) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: Timer = {
|
||||
...timer,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
timers = timers.map((t) => (t.id === id ? updated : t));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause a timer
|
||||
*/
|
||||
pauseTimer(id: string): Timer | null {
|
||||
const timer = timers.find((t) => t.id === id);
|
||||
if (!timer) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: Timer = {
|
||||
...timer,
|
||||
status: 'paused',
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
timers = timers.map((t) => (t.id === id ? updated : t));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset a timer
|
||||
*/
|
||||
resetTimer(id: string): Timer | null {
|
||||
const timer = timers.find((t) => t.id === id);
|
||||
if (!timer) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: Timer = {
|
||||
...timer,
|
||||
status: 'idle',
|
||||
remainingSeconds: timer.durationSeconds,
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
timers = timers.map((t) => (t.id === id ? updated : t));
|
||||
saveToStorage();
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update local timer state (for countdown display)
|
||||
*/
|
||||
updateLocalState(id: string, updates: Partial<Timer>): void {
|
||||
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a session timer
|
||||
*/
|
||||
deleteTimer(id: string): void {
|
||||
timers = timers.filter((t) => t.id !== id);
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if ID is a session timer
|
||||
*/
|
||||
isSessionTimer(id: string): boolean {
|
||||
return id.startsWith('session_');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all timers for migration
|
||||
*/
|
||||
getAllTimers(): Timer[] {
|
||||
return [...timers];
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all session data
|
||||
*/
|
||||
clear(): void {
|
||||
timers = [];
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of session timers
|
||||
*/
|
||||
get count(): number {
|
||||
return timers.length;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
/**
|
||||
* Stopwatch Store - Manages stopwatch state using Svelte 5 runes
|
||||
* Stopwatches are local-only (no backend sync)
|
||||
*/
|
||||
|
||||
export interface Lap {
|
||||
number: number;
|
||||
time: number; // milliseconds since start
|
||||
delta: number; // milliseconds since last lap
|
||||
}
|
||||
|
||||
export interface Stopwatch {
|
||||
id: string;
|
||||
label: string;
|
||||
startTime: number | null; // timestamp when started
|
||||
elapsedTime: number; // accumulated milliseconds when paused
|
||||
status: 'idle' | 'running' | 'paused';
|
||||
laps: Lap[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const STOPWATCH_COLORS = [
|
||||
'#3B82F6', // blue
|
||||
'#10B981', // green
|
||||
'#F59E0B', // amber
|
||||
'#EF4444', // red
|
||||
'#8B5CF6', // violet
|
||||
'#EC4899', // pink
|
||||
'#14B8A6', // teal
|
||||
'#F97316', // orange
|
||||
];
|
||||
|
||||
// State
|
||||
let stopwatches = $state<Stopwatch[]>([]);
|
||||
let focusedId = $state<string | null>(null);
|
||||
let colorIndex = 0;
|
||||
|
||||
// Tick interval for updating display
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function getNextColor(): string {
|
||||
const color = STOPWATCH_COLORS[colorIndex % STOPWATCH_COLORS.length];
|
||||
colorIndex++;
|
||||
return color;
|
||||
}
|
||||
|
||||
function startTicking() {
|
||||
if (tickInterval) return;
|
||||
tickInterval = setInterval(() => {
|
||||
// Force reactivity update by reassigning
|
||||
stopwatches = [...stopwatches];
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function stopTickingIfNoRunning() {
|
||||
const hasRunning = stopwatches.some((sw) => sw.status === 'running');
|
||||
if (!hasRunning && tickInterval) {
|
||||
clearInterval(tickInterval);
|
||||
tickInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const stopwatchesStore = {
|
||||
// Getters
|
||||
get stopwatches() {
|
||||
return stopwatches;
|
||||
},
|
||||
get focusedId() {
|
||||
return focusedId;
|
||||
},
|
||||
get focusedStopwatch() {
|
||||
return stopwatches.find((sw) => sw.id === focusedId) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new stopwatch
|
||||
*/
|
||||
create(label?: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
const newStopwatch: Stopwatch = {
|
||||
id,
|
||||
label: label || `Stopwatch ${stopwatches.length + 1}`,
|
||||
startTime: null,
|
||||
elapsedTime: 0,
|
||||
status: 'idle',
|
||||
laps: [],
|
||||
color: getNextColor(),
|
||||
};
|
||||
stopwatches = [...stopwatches, newStopwatch];
|
||||
if (!focusedId) {
|
||||
focusedId = id;
|
||||
}
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a stopwatch
|
||||
*/
|
||||
start(id: string) {
|
||||
stopwatches = stopwatches.map((sw) => {
|
||||
if (sw.id !== id) return sw;
|
||||
return {
|
||||
...sw,
|
||||
startTime: Date.now(),
|
||||
status: 'running' as const,
|
||||
};
|
||||
});
|
||||
startTicking();
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause a stopwatch
|
||||
*/
|
||||
pause(id: string) {
|
||||
stopwatches = stopwatches.map((sw) => {
|
||||
if (sw.id !== id || sw.status !== 'running') return sw;
|
||||
const elapsed = sw.startTime ? Date.now() - sw.startTime : 0;
|
||||
return {
|
||||
...sw,
|
||||
startTime: null,
|
||||
elapsedTime: sw.elapsedTime + elapsed,
|
||||
status: 'paused' as const,
|
||||
};
|
||||
});
|
||||
stopTickingIfNoRunning();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset a stopwatch
|
||||
*/
|
||||
reset(id: string) {
|
||||
stopwatches = stopwatches.map((sw) => {
|
||||
if (sw.id !== id) return sw;
|
||||
return {
|
||||
...sw,
|
||||
startTime: null,
|
||||
elapsedTime: 0,
|
||||
status: 'idle' as const,
|
||||
laps: [],
|
||||
};
|
||||
});
|
||||
stopTickingIfNoRunning();
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a lap to a stopwatch
|
||||
*/
|
||||
addLap(id: string) {
|
||||
stopwatches = stopwatches.map((sw) => {
|
||||
if (sw.id !== id || sw.status !== 'running') return sw;
|
||||
const currentTime = this.getElapsed(sw);
|
||||
const lastLap = sw.laps[sw.laps.length - 1];
|
||||
const delta = lastLap ? currentTime - lastLap.time : currentTime;
|
||||
const newLap: Lap = {
|
||||
number: sw.laps.length + 1,
|
||||
time: currentTime,
|
||||
delta,
|
||||
};
|
||||
return {
|
||||
...sw,
|
||||
laps: [...sw.laps, newLap],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a stopwatch
|
||||
*/
|
||||
delete(id: string) {
|
||||
stopwatches = stopwatches.filter((sw) => sw.id !== id);
|
||||
if (focusedId === id) {
|
||||
focusedId = stopwatches[0]?.id || null;
|
||||
}
|
||||
stopTickingIfNoRunning();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set focused stopwatch
|
||||
*/
|
||||
setFocused(id: string | null) {
|
||||
focusedId = id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update stopwatch label
|
||||
*/
|
||||
updateLabel(id: string, label: string) {
|
||||
stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, label } : sw));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get elapsed time for a stopwatch
|
||||
*/
|
||||
getElapsed(sw: Stopwatch): number {
|
||||
if (sw.status === 'running' && sw.startTime) {
|
||||
return sw.elapsedTime + (Date.now() - sw.startTime);
|
||||
}
|
||||
return sw.elapsedTime;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format time in milliseconds to display string
|
||||
*/
|
||||
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}:${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 lap time (delta) for display
|
||||
*/
|
||||
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);
|
||||
|
||||
if (minutes > 0) {
|
||||
return `+${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `+${seconds}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* Tag Store — Local-First via Shared Tag Store
|
||||
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
|
||||
* Use context ('tags') for reads, tagMutations for writes.
|
||||
*/
|
||||
export {
|
||||
tagMutations,
|
||||
useAllTags,
|
||||
getTagById,
|
||||
getTagsByIds,
|
||||
getTagColor,
|
||||
getTagsByGroup,
|
||||
} from '@manacore/shared-stores';
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
// Create theme store with Clock's styling
|
||||
export const theme = createThemeStore({
|
||||
appId: 'clock',
|
||||
defaultVariant: 'lume',
|
||||
});
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
/**
|
||||
* Timers Store — Mutation-Only Service
|
||||
*
|
||||
* All reads are handled by useLiveQuery() hooks in queries.ts.
|
||||
* This store only provides write operations (create, update, delete, start, pause, reset).
|
||||
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
|
||||
*/
|
||||
|
||||
import { timerCollection, type LocalTimer } from '$lib/data/local-store';
|
||||
import { toTimer } from '$lib/data/queries';
|
||||
import type { CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
||||
import { ClockEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const timersStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new timer -- writes to IndexedDB instantly.
|
||||
*/
|
||||
async createTimer(input: CreateTimerInput) {
|
||||
error = null;
|
||||
try {
|
||||
const newLocal: LocalTimer = {
|
||||
id: crypto.randomUUID(),
|
||||
label: input.label ?? null,
|
||||
durationSeconds: input.durationSeconds,
|
||||
remainingSeconds: null,
|
||||
status: 'idle',
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
sound: input.sound ?? null,
|
||||
};
|
||||
|
||||
const inserted = await timerCollection.insert(newLocal);
|
||||
return { success: true, data: toTimer(inserted) };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create timer';
|
||||
console.error('Failed to create timer:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a timer -- writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateTimer(id: string, input: UpdateTimerInput) {
|
||||
error = null;
|
||||
try {
|
||||
const updateData: Partial<LocalTimer> = {};
|
||||
if (input.label !== undefined) updateData.label = input.label ?? null;
|
||||
if (input.durationSeconds !== undefined) updateData.durationSeconds = input.durationSeconds;
|
||||
if (input.sound !== undefined) updateData.sound = input.sound ?? null;
|
||||
|
||||
const updated = await timerCollection.update(id, updateData);
|
||||
if (updated) {
|
||||
return { success: true, data: toTimer(updated) };
|
||||
}
|
||||
return { success: false, error: 'Timer not found' };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update timer';
|
||||
console.error('Failed to update timer:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a timer -- sets status to running with current timestamp.
|
||||
*/
|
||||
async startTimer(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const existing = await timerCollection.get(id);
|
||||
if (!existing) return { success: false, error: 'Timer not found' };
|
||||
|
||||
const updateData: Partial<LocalTimer> = {
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
pausedAt: null,
|
||||
};
|
||||
|
||||
// If resuming from pause, keep remaining seconds
|
||||
if (existing.status !== 'paused') {
|
||||
updateData.remainingSeconds = existing.durationSeconds;
|
||||
}
|
||||
|
||||
const updated = await timerCollection.update(id, updateData);
|
||||
if (updated) {
|
||||
const updatedTimer = toTimer(updated);
|
||||
ClockEvents.timerStarted(
|
||||
(updatedTimer as any).type as 'pomodoro' | 'stopwatch' | 'countdown'
|
||||
);
|
||||
return { success: true, data: updatedTimer };
|
||||
}
|
||||
return { success: false, error: 'Timer not found' };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to start timer';
|
||||
console.error('Failed to start timer:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause a timer -- calculates remaining seconds and saves.
|
||||
*/
|
||||
async pauseTimer(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const existing = await timerCollection.get(id);
|
||||
if (!existing) return { success: false, error: 'Timer not found' };
|
||||
|
||||
// Calculate remaining seconds
|
||||
let remaining = existing.remainingSeconds ?? existing.durationSeconds;
|
||||
if (existing.startedAt) {
|
||||
const elapsed = (Date.now() - new Date(existing.startedAt).getTime()) / 1000;
|
||||
remaining = Math.max(0, remaining - elapsed);
|
||||
}
|
||||
|
||||
const updateData: Partial<LocalTimer> = {
|
||||
status: 'paused',
|
||||
pausedAt: new Date().toISOString(),
|
||||
remainingSeconds: Math.round(remaining),
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
const updated = await timerCollection.update(id, updateData);
|
||||
if (updated) {
|
||||
return { success: true, data: toTimer(updated) };
|
||||
}
|
||||
return { success: false, error: 'Timer not found' };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to pause timer';
|
||||
console.error('Failed to pause timer:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset a timer -- back to idle with full duration.
|
||||
*/
|
||||
async resetTimer(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const updateData: Partial<LocalTimer> = {
|
||||
status: 'idle',
|
||||
remainingSeconds: null,
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
};
|
||||
|
||||
const updated = await timerCollection.update(id, updateData);
|
||||
if (updated) {
|
||||
return { success: true, data: toTimer(updated) };
|
||||
}
|
||||
return { success: false, error: 'Timer not found' };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reset timer';
|
||||
console.error('Failed to reset timer:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a timer -- removes from IndexedDB instantly.
|
||||
*/
|
||||
async deleteTimer(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await timerCollection.delete(id);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete timer';
|
||||
console.error('Failed to delete timer:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update remaining seconds in IndexedDB (for countdown display).
|
||||
*/
|
||||
async updateLocalTimer(id: string, remainingSeconds: number) {
|
||||
try {
|
||||
await timerCollection.update(id, { remainingSeconds });
|
||||
} catch (e) {
|
||||
console.error('Failed to update local timer:', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* User Settings Store for Clock
|
||||
*
|
||||
* This store syncs settings with mana-core-auth and provides:
|
||||
* - Global settings that apply to all apps
|
||||
* - Per-app overrides for customization
|
||||
* - localStorage caching for offline support
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// Get auth URL dynamically at runtime
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
}
|
||||
return import.meta.env.DEV ? 'http://localhost:3001' : '';
|
||||
}
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'clock',
|
||||
authUrl: getAuthUrl,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* World Clocks Store — Mutation-Only Service
|
||||
*
|
||||
* All reads are handled by useLiveQuery() hooks in queries.ts.
|
||||
* This store only provides write operations (add, remove, reorder).
|
||||
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
|
||||
*/
|
||||
|
||||
import { worldClockCollection, type LocalWorldClock } from '$lib/data/local-store';
|
||||
import type { CreateWorldClockInput, WorldClock } from '@clock/shared';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const worldClocksStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new world clock -- writes to IndexedDB instantly.
|
||||
*/
|
||||
async addWorldClock(input: CreateWorldClockInput, currentCount: number = 0) {
|
||||
error = null;
|
||||
try {
|
||||
const newLocal: LocalWorldClock = {
|
||||
id: crypto.randomUUID(),
|
||||
timezone: input.timezone,
|
||||
cityName: input.cityName,
|
||||
sortOrder: currentCount,
|
||||
};
|
||||
|
||||
await worldClockCollection.insert(newLocal);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add world clock';
|
||||
console.error('Failed to add world clock:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a world clock -- removes from IndexedDB instantly.
|
||||
*/
|
||||
async removeWorldClock(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await worldClockCollection.delete(id);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to remove world clock';
|
||||
console.error('Failed to remove world clock:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder world clocks -- updates sortOrder in IndexedDB.
|
||||
*/
|
||||
async reorder(ids: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await worldClockCollection.update(ids[i], {
|
||||
sortOrder: i,
|
||||
} as Partial<LocalWorldClock>);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder world clocks';
|
||||
console.error('Failed to reorder world clocks:', e);
|
||||
return { success: false, error: error };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export const APP_VERSION = '0.2.0';
|
||||
export const BUILD_TIME: string =
|
||||
typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : new Date().toISOString();
|
||||
export const BUILD_HASH: string = typeof __BUILD_HASH__ !== 'undefined' ? __BUILD_HASH__ : 'dev';
|
||||
|
|
@ -1,465 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation, CommandBar, TagStrip } from '@manacore/shared-ui';
|
||||
import { SyncIndicator } from '@manacore/shared-ui';
|
||||
import type {
|
||||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
SpotlightAction,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { useAllAlarms, useAllTimers, useAllWorldClocks } from '$lib/data/queries';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems, getManaApp } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { clockStore } from '$lib/data/local-store';
|
||||
import {
|
||||
tagLocalStore,
|
||||
tagMutations,
|
||||
useAllTags as useAllSharedTags,
|
||||
} from '@manacore/shared-stores';
|
||||
|
||||
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
|
||||
const allAlarms = useAllAlarms();
|
||||
const allTimers = useAllTimers();
|
||||
const allWorldClocks = useAllWorldClocks();
|
||||
const allTags = useAllSharedTags();
|
||||
|
||||
// Provide data to child components via Svelte context
|
||||
setContext('alarms', allAlarms);
|
||||
setContext('timers', allTimers);
|
||||
setContext('worldClocks', allWorldClocks);
|
||||
setContext('tags', allTags);
|
||||
|
||||
// Guest welcome modal state
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
function initGuestWelcome() {
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('clock')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
}
|
||||
|
||||
// App switcher items
|
||||
let appItems = $derived(getPillAppItems('clock', undefined, undefined, authStore.user?.tier));
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// CommandBar state
|
||||
let commandBarOpen = $state(false);
|
||||
|
||||
// CommandBar quick actions
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{
|
||||
id: 'alarm',
|
||||
label: 'Neuen Wecker erstellen',
|
||||
icon: 'bell',
|
||||
href: '/alarms?new=true',
|
||||
shortcut: 'A',
|
||||
},
|
||||
{
|
||||
id: 'timer',
|
||||
label: 'Neuen Timer starten',
|
||||
icon: 'timer',
|
||||
href: '/timers?new=true',
|
||||
shortcut: 'T',
|
||||
},
|
||||
{ id: 'stopwatch', label: 'Stoppuhr', icon: 'stopwatch', href: '/stopwatch' },
|
||||
{ id: 'pomodoro', label: 'Pomodoro starten', icon: 'target', href: '/pomodoro' },
|
||||
{ id: 'worldclock', label: 'Weltzeituhr', icon: 'globe', href: '/world-clock' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
// CommandBar search - search alarms and timers using live query data
|
||||
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const results: CommandBarItem[] = [];
|
||||
|
||||
// Search alarms (from live query)
|
||||
const matchingAlarms = allAlarms.value
|
||||
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
|
||||
.slice(0, 5)
|
||||
.map((alarm) => ({
|
||||
id: `alarm-${alarm.id}`,
|
||||
title: alarm.label || 'Wecker',
|
||||
subtitle: `${alarm.time.slice(0, 5)} ${alarm.enabled ? '(aktiv)' : '(inaktiv)'}`,
|
||||
}));
|
||||
results.push(...matchingAlarms);
|
||||
|
||||
// Search timers (from live query)
|
||||
const matchingTimers = allTimers.value
|
||||
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
|
||||
.slice(0, 5)
|
||||
.map((timer) => {
|
||||
const mins = Math.floor(timer.durationSeconds / 60);
|
||||
const secs = timer.durationSeconds % 60;
|
||||
return {
|
||||
id: `timer-${timer.id}`,
|
||||
title: timer.label || 'Timer',
|
||||
subtitle: `${mins}:${secs.toString().padStart(2, '0')} ${timer.status === 'running' ? '(läuft)' : ''}`,
|
||||
};
|
||||
});
|
||||
results.push(...matchingTimers);
|
||||
|
||||
return results.slice(0, 10);
|
||||
}
|
||||
|
||||
function handleCommandBarSelect(item: CommandBarItem) {
|
||||
if (item.id.startsWith('alarm-')) {
|
||||
goto('/alarms');
|
||||
} else if (item.id.startsWith('timer-')) {
|
||||
goto('/timers');
|
||||
}
|
||||
}
|
||||
|
||||
let isCollapsed = $state(false);
|
||||
|
||||
// Use theme store's isDark directly
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
// Get pinned themes from user settings (extended themes only)
|
||||
let pinnedThemes = $derived<ThemeVariant[]>(
|
||||
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
|
||||
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
|
||||
)
|
||||
);
|
||||
|
||||
// Visible themes in PillNav: default + pinned extended
|
||||
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
|
||||
|
||||
// Theme variant dropdown items (with SSR fallback)
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...visibleThemes.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant]?.label || variant,
|
||||
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: (theme.variant || 'lume') === variant,
|
||||
})),
|
||||
{
|
||||
id: 'all-themes',
|
||||
label: 'Alle Themes',
|
||||
icon: 'palette',
|
||||
onClick: () => goto('/themes'),
|
||||
active: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Current theme variant label (with SSR fallback)
|
||||
let currentThemeVariantLabel = $derived(
|
||||
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
|
||||
);
|
||||
|
||||
// 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
|
||||
// User email for user dropdown — empty string for guests so PillNav shows login button
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
// Spotlight actions for PillNavigation
|
||||
const spotlightActions: SpotlightAction[] = [
|
||||
{
|
||||
id: 'new-alarm',
|
||||
label: 'Neuer Alarm',
|
||||
icon: 'plus',
|
||||
category: 'Erstellen',
|
||||
onExecute: () => goto('/alarms'),
|
||||
},
|
||||
{
|
||||
id: 'new-timer',
|
||||
label: 'Neuer Timer',
|
||||
icon: 'plus',
|
||||
category: 'Erstellen',
|
||||
onExecute: () => goto('/timers'),
|
||||
},
|
||||
{
|
||||
id: 'world-clock',
|
||||
label: 'Weltuhren',
|
||||
category: 'Navigation',
|
||||
onExecute: () => goto('/world-clock'),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
category: 'Navigation',
|
||||
onExecute: () => goto('/settings'),
|
||||
},
|
||||
];
|
||||
|
||||
// TagStrip visibility
|
||||
let isTagStripVisible = $state(false);
|
||||
function handleTagStripToggle() {
|
||||
isTagStripVisible = !isTagStripVisible;
|
||||
}
|
||||
|
||||
// Base navigation items for Clock
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Übersicht', icon: 'home' },
|
||||
{ href: '/alarms', label: 'Wecker', icon: 'bell' },
|
||||
{ href: '/timers', label: 'Timer', icon: 'timer' },
|
||||
{ href: '/stopwatch', label: 'Stoppuhr', icon: 'stopwatch' },
|
||||
{ href: '/pomodoro', label: 'Pomodoro', icon: 'target' },
|
||||
{ href: '/world-clock', label: 'Weltzeituhr', icon: 'globe' },
|
||||
{ href: '/life', label: 'Lebensuhr', icon: 'heart' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{
|
||||
href: '/',
|
||||
label: 'Tags',
|
||||
icon: 'tag',
|
||||
onClick: handleTagStripToggle,
|
||||
active: isTagStripVisible,
|
||||
},
|
||||
];
|
||||
|
||||
// Navigation items filtered by visibility settings
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('clock', baseNavItems, userSettings.nav?.hiddenNavItems || {})
|
||||
);
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-9) - use base items for consistent shortcuts
|
||||
const navRoutes = baseNavItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Cmd/Ctrl+K to open command bar (works even in inputs)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
commandBarOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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 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');
|
||||
}
|
||||
|
||||
async function handleAuthReady() {
|
||||
// Initialize local-first databases (opens IndexedDB, seeds guest data)
|
||||
await Promise.all([clockStore.initialize(), tagLocalStore.initialize()]);
|
||||
|
||||
// If authenticated, start syncing to server
|
||||
if (authStore.isAuthenticated) {
|
||||
const getToken = () => authStore.getValidToken();
|
||||
clockStore.startSync(getToken);
|
||||
tagMutations.startSync(getToken);
|
||||
}
|
||||
|
||||
// Initialize collapsed state from localStorage
|
||||
const savedCollapsed = localStorage.getItem('clock-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
// Show guest welcome modal on first visit
|
||||
initGuestWelcome();
|
||||
|
||||
// Load user settings (requires auth)
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
}
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
|
||||
goto(userSettings.startPage, { replaceState: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<AuthGate
|
||||
{authStore}
|
||||
{goto}
|
||||
allowGuest={true}
|
||||
onReady={handleAuthReady}
|
||||
requiredTier={getManaApp('clock')?.requiredTier}
|
||||
appName={getManaApp('clock')?.name}
|
||||
>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Clock"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{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"
|
||||
themesHref="/themes"
|
||||
helpHref="/help"
|
||||
allAppsHref="/apps"
|
||||
{spotlightActions}
|
||||
/>
|
||||
|
||||
<!-- TagStrip (above PillNav, toggled via Tags pill) -->
|
||||
{#if isTagStripVisible}
|
||||
<TagStrip
|
||||
tags={allTags.value.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color || '#3b82f6',
|
||||
}))}
|
||||
selectedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onClear={() => {}}
|
||||
managementHref="/tags"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<main class="main-content bg-background">
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Global Command Bar (Cmd/K) -->
|
||||
<CommandBar
|
||||
bind:open={commandBarOpen}
|
||||
onClose={() => (commandBarOpen = false)}
|
||||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Schnellzugriff..."
|
||||
emptyText="Keine Ergebnisse"
|
||||
searchingText="Suche..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Onboarding Modal -->
|
||||
{#if clockOnboarding.shouldShow}
|
||||
<MiniOnboardingModal store={clockOnboarding} appName="Uhr" appEmoji="⏰" />
|
||||
{/if}
|
||||
|
||||
<!-- Guest Welcome Modal -->
|
||||
<GuestWelcomeModal
|
||||
appId="clock"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
<SyncIndicator />
|
||||
</AuthGate>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Clock, Bell, Timer, Hourglass, Globe } from '@manacore/shared-icons';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
// No auth redirect - guest mode allowed
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
href: '/world-clock',
|
||||
icon: Globe,
|
||||
label: 'Weltzeituhr',
|
||||
description: 'Zeitzonen im Blick',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
href: '/alarms',
|
||||
icon: Bell,
|
||||
label: 'Wecker',
|
||||
description: 'Alarme verwalten',
|
||||
color: 'bg-amber-500',
|
||||
},
|
||||
{
|
||||
href: '/timers',
|
||||
icon: Timer,
|
||||
label: 'Timer',
|
||||
description: 'Countdowns starten',
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
href: '/stopwatch',
|
||||
icon: Hourglass,
|
||||
label: 'Stoppuhr',
|
||||
description: 'Zeit messen',
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Clock - Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isLoading}
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="dashboard">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-foreground">Clock</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Dein Zeit-Management Hub</p>
|
||||
</header>
|
||||
|
||||
<!-- Current Time Display -->
|
||||
<div class="current-time-card mb-8 p-6 rounded-xl bg-card border border-border">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 rounded-full bg-primary/10">
|
||||
<Clock size={32} class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-4xl font-bold text-foreground tabular-nums">
|
||||
{new Date().toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
{new Date().toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{#each quickLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="quick-link p-4 rounded-xl bg-card border border-border hover:border-primary/50 transition-all hover:shadow-lg group"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
<div
|
||||
class="{link.color} p-3 rounded-full text-white group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<link.icon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">{link.label}</div>
|
||||
<div class="text-xs text-muted-foreground">{link.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PageHeader, toast } from '@manacore/shared-ui';
|
||||
import { alarmsStore } from '$lib/stores/alarms.svelte';
|
||||
import type { Alarm } from '@clock/shared';
|
||||
import { ALARM_SOUNDS, DEFAULT_ALARM_PRESETS } from '@clock/shared';
|
||||
|
||||
// Get live query data from layout context
|
||||
const allAlarms: { readonly value: Alarm[] } = getContext('alarms');
|
||||
|
||||
// Quick create form (inline)
|
||||
let newTime = $state('07:00');
|
||||
let newLabel = $state('');
|
||||
let newRepeatDays = $state<number[]>([]);
|
||||
let showOptions = $state(false);
|
||||
|
||||
// Edit modal state
|
||||
let showEditModal = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let editTime = $state('07:00');
|
||||
let editLabel = $state('');
|
||||
let editRepeatDays = $state<number[]>([]);
|
||||
let editSound = $state('default');
|
||||
let editSnoozeMinutes = $state(5);
|
||||
|
||||
const dayNames = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
// Find existing alarm for a preset time
|
||||
function findAlarmForPreset(presetTime: string): Alarm | undefined {
|
||||
return allAlarms.value.find((a) => a.time.slice(0, 5) === presetTime);
|
||||
}
|
||||
|
||||
// Toggle a preset alarm
|
||||
async function togglePreset(presetTime: string, presetLabel: string) {
|
||||
const existingAlarm = findAlarmForPreset(presetTime);
|
||||
|
||||
if (existingAlarm) {
|
||||
await alarmsStore.toggleAlarm(existingAlarm.id, allAlarms.value);
|
||||
} else {
|
||||
const result = await alarmsStore.createAlarm({
|
||||
time: presetTime + ':00',
|
||||
label: presetLabel,
|
||||
enabled: true,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success('Wecker erstellt');
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Erstellen');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick create new alarm
|
||||
async function handleQuickCreate() {
|
||||
const result = await alarmsStore.createAlarm({
|
||||
time: newTime + ':00',
|
||||
label: newLabel || undefined,
|
||||
repeatDays: newRepeatDays.length > 0 ? newRepeatDays : undefined,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Wecker erstellt');
|
||||
// Reset form
|
||||
newTime = '07:00';
|
||||
newLabel = '';
|
||||
newRepeatDays = [];
|
||||
showOptions = false;
|
||||
} else {
|
||||
toast.error(result.error || 'Fehler beim Erstellen');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNewDay(day: number) {
|
||||
if (newRepeatDays.includes(day)) {
|
||||
newRepeatDays = newRepeatDays.filter((d) => d !== day);
|
||||
} else {
|
||||
newRepeatDays = [...newRepeatDays, day];
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(alarm: Alarm) {
|
||||
editingId = alarm.id;
|
||||
editTime = alarm.time.slice(0, 5);
|
||||
editLabel = alarm.label || '';
|
||||
editRepeatDays = alarm.repeatDays || [];
|
||||
editSound = alarm.sound || 'default';
|
||||
editSnoozeMinutes = alarm.snoozeMinutes || 5;
|
||||
showEditModal = true;
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
showEditModal = false;
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
function toggleEditDay(day: number) {
|
||||
if (editRepeatDays.includes(day)) {
|
||||
editRepeatDays = editRepeatDays.filter((d) => d !== day);
|
||||
} else {
|
||||
editRepeatDays = [...editRepeatDays, day];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSubmit() {
|
||||
if (!editingId) return;
|
||||
|
||||
const result = await alarmsStore.updateAlarm(editingId, {
|
||||
time: editTime + ':00',
|
||||
label: editLabel || undefined,
|
||||
repeatDays: editRepeatDays.length > 0 ? editRepeatDays : undefined,
|
||||
sound: editSound,
|
||||
snoozeMinutes: editSnoozeMinutes,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Wecker aktualisiert');
|
||||
closeEditModal();
|
||||
} 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, allAlarms.value);
|
||||
}
|
||||
|
||||
function getRepeatText(days: number[] | null) {
|
||||
if (!days || days.length === 0) return 'Einmalig';
|
||||
if (days.length === 7) return 'Täglich';
|
||||
if (days.length === 5 && [1, 2, 3, 4, 5].every((d) => days.includes(d))) return 'Wochentags';
|
||||
if (days.length === 2 && days.includes(0) && days.includes(6)) return 'Am Wochenende';
|
||||
return days.map((d) => dayNames[d]).join(', ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={$_('alarm.title')} size="md" centered />
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Quick Create Form -->
|
||||
<div class="quick-create">
|
||||
<input type="time" class="time-input-inline" bind:value={newTime} />
|
||||
<input type="text" class="label-input" placeholder="Bezeichnung" bind:value={newLabel} />
|
||||
<button
|
||||
class="text-xs text-muted-foreground hover:text-foreground transition-colors px-2"
|
||||
onclick={() => (showOptions = !showOptions)}
|
||||
title="Wiederholung"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
class:text-primary={newRepeatDays.length > 0}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick={handleQuickCreate}> + </button>
|
||||
</div>
|
||||
|
||||
{#if showOptions}
|
||||
<div class="day-selector-compact">
|
||||
{#each dayNames as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class:active={newRepeatDays.includes(i)}
|
||||
onclick={() => toggleNewDay(i)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Default Alarm Presets (Grid) -->
|
||||
<div class="alarm-grid">
|
||||
{#each DEFAULT_ALARM_PRESETS as preset}
|
||||
{@const existingAlarm = findAlarmForPreset(preset.time)}
|
||||
{@const isActive = existingAlarm?.enabled ?? false}
|
||||
<div
|
||||
class="alarm-tile"
|
||||
class:active={isActive}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => togglePreset(preset.time, preset.label)}
|
||||
onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)}
|
||||
>
|
||||
<div class="text-xl font-light text-foreground tabular-nums text-center">
|
||||
{preset.time}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
|
||||
{existingAlarm?.label || preset.label}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Custom Alarms (Grid) -->
|
||||
{#if allAlarms.value.filter((a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))).length > 0}
|
||||
{@const customAlarms = allAlarms.value.filter(
|
||||
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
|
||||
)}
|
||||
<div class="mt-4">
|
||||
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||
{$_('alarm.custom')}
|
||||
</h2>
|
||||
<div class="alarm-grid">
|
||||
{#each customAlarms as alarm (alarm.id)}
|
||||
<div
|
||||
class="alarm-tile"
|
||||
class:active={alarm.enabled}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleToggle(alarm.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)}
|
||||
>
|
||||
<div class="text-xl font-light text-foreground tabular-nums text-center">
|
||||
{alarm.time.slice(0, 5)}
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
|
||||
{alarm.label || getRepeatText(alarm.repeatDays)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Modal -->
|
||||
{#if showEditModal}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="card w-full max-w-md">
|
||||
<h2 class="mb-4 text-xl font-semibold">{$_('alarm.edit')}</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleEditSubmit();
|
||||
}}
|
||||
>
|
||||
<!-- Time -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-1 block text-sm font-medium">{$_('alarm.time')}</label>
|
||||
<input type="time" class="input time-input" bind:value={editTime} />
|
||||
</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={editLabel}
|
||||
/>
|
||||
</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={editRepeatDays.includes(i)}
|
||||
onclick={() => toggleEditDay(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={editSound}>
|
||||
{#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={editSnoozeMinutes}>
|
||||
<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={closeEditModal}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary flex-1">
|
||||
{$_('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { FeedbackPage } from '@manacore/feedback';
|
||||
import { createFeedbackService } from '@manacore/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get auth URL dynamically at runtime
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
const feedbackService = createFeedbackService({
|
||||
appName: 'clock',
|
||||
apiUrl: getAuthUrl(),
|
||||
});
|
||||
|
||||
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} />
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { HelpPage, getHelpTranslations } from '@manacore/help';
|
||||
import { getClockHelpContent } from '$lib/content/help/index.js';
|
||||
|
||||
const content = $derived(getClockHelpContent($locale ?? 'de'));
|
||||
const translations = $derived(
|
||||
getHelpTranslations($locale ?? 'de', {
|
||||
subtitle:
|
||||
$locale === 'de'
|
||||
? 'Finde Antworten und lerne Clock kennen'
|
||||
: 'Find answers and learn how to use Clock',
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Clock</title>
|
||||
</svelte:head>
|
||||
|
||||
<HelpPage
|
||||
{content}
|
||||
appName="Clock"
|
||||
appId="clock"
|
||||
{translations}
|
||||
showBackButton
|
||||
onBack={() => goto('/')}
|
||||
showGettingStarted={false}
|
||||
showChangelog={false}
|
||||
defaultSection="faq"
|
||||
/>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { SubscriptionPage } from '@manacore/subscriptions';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<SubscriptionPage user={authStore.user} appName="Clock" />
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { ProfilePage } from '@manacore/shared-profile-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<ProfilePage user={authStore.user} appName="Clock" />
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { GlobalSettingsSection } from '@manacore/shared-ui';
|
||||
import { APP_VERSION } from '$lib/version';
|
||||
|
||||
onMount(async () => {
|
||||
await userSettings.load();
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Translation function for GlobalSettingsSection
|
||||
function translate(key: string): string {
|
||||
return $_?.(key) ?? key;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('settings.title')}</h1>
|
||||
|
||||
<!-- Global Settings Section (synced across all apps) -->
|
||||
<GlobalSettingsSection
|
||||
{userSettings}
|
||||
appId="clock"
|
||||
title="App-Einstellungen"
|
||||
description="Diese Einstellungen werden mit allen Mana Apps synchronisiert"
|
||||
t={translate}
|
||||
/>
|
||||
|
||||
<!-- Clock-specific Settings -->
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-lg font-semibold">{$_('settings.clockFormat')}</h2>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium">Zeitformat</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>
|
||||
|
||||
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
|
||||
</div>
|
||||
|
|
@ -1,466 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PageHeader } from '@manacore/shared-ui';
|
||||
import { Clock } from '@manacore/shared-icons';
|
||||
import {
|
||||
stopwatchesStore,
|
||||
formatTime,
|
||||
formatLapTime,
|
||||
STOPWATCH_COLORS,
|
||||
type Stopwatch,
|
||||
} from '$lib/stores/stopwatch.svelte';
|
||||
|
||||
// Edit state
|
||||
let editingLabelId = $state<string | null>(null);
|
||||
let editingLabelValue = $state('');
|
||||
|
||||
function handleCreateNew() {
|
||||
const id = stopwatchesStore.create();
|
||||
stopwatchesStore.start(id);
|
||||
}
|
||||
|
||||
function handleFocus(id: string) {
|
||||
stopwatchesStore.setFocused(id);
|
||||
}
|
||||
|
||||
function startEditLabel(sw: Stopwatch) {
|
||||
editingLabelId = sw.id;
|
||||
editingLabelValue = sw.label;
|
||||
}
|
||||
|
||||
function saveLabel() {
|
||||
if (editingLabelId && editingLabelValue.trim()) {
|
||||
stopwatchesStore.updateLabel(editingLabelId, editingLabelValue.trim());
|
||||
}
|
||||
editingLabelId = null;
|
||||
}
|
||||
|
||||
function handleLabelKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
saveLabel();
|
||||
} else if (e.key === 'Escape') {
|
||||
editingLabelId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Derived states
|
||||
let focused = $derived(stopwatchesStore.focusedStopwatch);
|
||||
let otherStopwatches = $derived(
|
||||
stopwatchesStore.stopwatches.filter((sw) => sw.id !== stopwatchesStore.focusedId)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<PageHeader title={$_('stopwatch.title')} size="md" />
|
||||
<button class="btn btn-primary btn-sm" onclick={handleCreateNew}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$_('stopwatch.new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if stopwatchesStore.stopwatches.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div class="w-24 h-24 mb-6 rounded-full bg-muted flex items-center justify-center">
|
||||
<Clock size={20} class="text-muted-foreground" />
|
||||
</div>
|
||||
<h2 class="text-xl font-medium text-foreground mb-2">{$_('stopwatch.noStopwatches')}</h2>
|
||||
<p class="text-muted-foreground mb-6 max-w-sm">{$_('stopwatch.noStopwatchesDescription')}</p>
|
||||
<button class="btn btn-primary btn-lg" onclick={handleCreateNew}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$_('stopwatch.startFirst')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<!-- Focused Stopwatch (Large) -->
|
||||
{#if focused}
|
||||
{@const bestLap = stopwatchesStore.getBestLap(focused.id)}
|
||||
{@const worstLap = stopwatchesStore.getWorstLap(focused.id)}
|
||||
<div class="stopwatch-card-focused" style="--sw-color: {focused.color}">
|
||||
<!-- Header with Label and Delete -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
class:animate-pulse={focused.isRunning}
|
||||
style="background-color: {focused.color}"
|
||||
></div>
|
||||
{#if editingLabelId === focused.id}
|
||||
<input
|
||||
type="text"
|
||||
class="bg-transparent border-b border-primary text-lg font-medium focus:outline-none"
|
||||
bind:value={editingLabelValue}
|
||||
onblur={saveLabel}
|
||||
onkeydown={handleLabelKeydown}
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="text-lg font-medium hover:text-primary transition-colors"
|
||||
onclick={() => startEditLabel(focused)}
|
||||
>
|
||||
{focused.label}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="text-muted-foreground hover:text-error transition-colors p-1"
|
||||
onclick={() => stopwatchesStore.delete(focused.id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time Display -->
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div
|
||||
class="digital-clock text-5xl sm:text-6xl font-light tabular-nums"
|
||||
class:text-primary={focused.isRunning}
|
||||
>
|
||||
{formatTime(focused.elapsedTime)}
|
||||
</div>
|
||||
{#if focused.laps.length > 0}
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{focused.laps.length}
|
||||
{$_('stopwatch.laps')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-center gap-3 mb-6">
|
||||
{#if focused.isRunning}
|
||||
<button
|
||||
class="btn btn-secondary btn-lg"
|
||||
onclick={() => stopwatchesStore.pause(focused.id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$_('stopwatch.stop')}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-lg" onclick={() => stopwatchesStore.lap(focused.id)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$_('stopwatch.lap')}
|
||||
</button>
|
||||
{:else if focused.elapsedTime > 0}
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={() => stopwatchesStore.start(focused.id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$_('stopwatch.continue')}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary btn-lg"
|
||||
onclick={() => stopwatchesStore.reset(focused.id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$_('stopwatch.reset')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={() => stopwatchesStore.start(focused.id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$_('stopwatch.start')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Laps List -->
|
||||
{#if focused.laps.length > 0}
|
||||
<div class="border-t border-border pt-4">
|
||||
<h3 class="text-sm font-medium text-muted-foreground mb-3">
|
||||
{$_('stopwatch.laps')} ({focused.laps.length})
|
||||
</h3>
|
||||
<div class="max-h-48 overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{#each [...focused.laps].reverse() as lap (lap.number)}
|
||||
{@const isBest = bestLap?.number === lap.number}
|
||||
{@const isWorst = worstLap?.number === lap.number}
|
||||
<div class="lap-item rounded-md" class:best={isBest} class:worst={isWorst}>
|
||||
<span class="text-sm flex items-center gap-2">
|
||||
<span class="text-muted-foreground">#{lap.number}</span>
|
||||
{#if isBest}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-success/20 text-success"
|
||||
>{$_('stopwatch.best')}</span
|
||||
>
|
||||
{:else if isWorst}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-error/20 text-error"
|
||||
>{$_('stopwatch.worst')}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="text-right">
|
||||
<span class="font-mono text-sm">{formatLapTime(lap.time)}</span>
|
||||
<span class="font-mono text-xs text-muted-foreground ml-2">
|
||||
{formatTime(lap.splitTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex justify-between border-t border-border mt-3 pt-3">
|
||||
<span class="text-sm font-medium">{$_('stopwatch.total')}</span>
|
||||
<span class="font-mono text-sm font-medium">
|
||||
{formatTime(focused.elapsedTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Other Stopwatches (Compact Grid) -->
|
||||
{#if otherStopwatches.length > 0}
|
||||
<div>
|
||||
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||
{$_('stopwatch.otherStopwatches')} ({otherStopwatches.length})
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{#each otherStopwatches as sw (sw.id)}
|
||||
<div
|
||||
class="stopwatch-card-compact"
|
||||
class:running={sw.isRunning}
|
||||
style="--sw-color: {sw.color}"
|
||||
onclick={() => handleFocus(sw.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleFocus(sw.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Status indicator -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
class:animate-pulse={sw.isRunning}
|
||||
style="background-color: {sw.color}"
|
||||
></div>
|
||||
<button
|
||||
class="text-muted-foreground hover:text-error p-0.5 -mr-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopwatchesStore.delete(sw.id);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="text-xl font-light tabular-nums mb-1" class:text-primary={sw.isRunning}>
|
||||
{formatTime(sw.elapsedTime)}
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="text-xs text-muted-foreground truncate mb-2">
|
||||
{sw.label}
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="flex gap-1">
|
||||
{#if sw.isRunning}
|
||||
<button
|
||||
class="btn btn-secondary btn-sm flex-1 text-xs"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopwatchesStore.pause(sw.id);
|
||||
}}
|
||||
>
|
||||
{$_('stopwatch.stop')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-primary btn-sm flex-1 text-xs"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopwatchesStore.start(sw.id);
|
||||
}}
|
||||
>
|
||||
{sw.elapsedTime > 0 ? $_('stopwatch.continue') : $_('stopwatch.start')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if sw.elapsedTime > 0 && !sw.isRunning}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-xs"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopwatchesStore.reset(sw.id);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Lap badge -->
|
||||
{#if sw.laps.length > 0}
|
||||
<div class="absolute top-2 right-8 text-xs bg-muted px-1.5 py-0.5 rounded-full">
|
||||
{sw.laps.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stopwatch-card-focused {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 2px solid var(--sw-color, hsl(var(--color-primary)));
|
||||
box-shadow: 0 0 20px
|
||||
color-mix(in srgb, var(--sw-color, hsl(var(--color-primary))) 20%, transparent);
|
||||
}
|
||||
|
||||
.stopwatch-card-compact {
|
||||
position: relative;
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stopwatch-card-compact:hover {
|
||||
border-color: var(--sw-color, hsl(var(--color-primary)));
|
||||
background-color: hsl(var(--color-muted) / 0.3);
|
||||
}
|
||||
|
||||
.stopwatch-card-compact.running {
|
||||
border-color: var(--sw-color, hsl(var(--color-primary)));
|
||||
box-shadow: 0 0 10px
|
||||
color-mix(in srgb, var(--sw-color, hsl(var(--color-primary))) 15%, transparent);
|
||||
}
|
||||
|
||||
.lap-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: hsl(var(--color-muted) / 0.3);
|
||||
}
|
||||
|
||||
.lap-item.best {
|
||||
background-color: hsl(var(--color-success) / 0.1);
|
||||
}
|
||||
|
||||
.lap-item.worst {
|
||||
background-color: hsl(var(--color-error) / 0.1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { tagMutations } from '@manacore/shared-stores';
|
||||
import type { Tag } from '@manacore/shared-tags';
|
||||
|
||||
// Live tags from layout context
|
||||
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tags | Clock</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="tags-page">
|
||||
<h1>Tags verwalten</h1>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Tags sind app-übergreifend — Änderungen gelten in allen ManaCore-Apps.
|
||||
</p>
|
||||
|
||||
{#if tagsCtx.value.length === 0}
|
||||
<p>Keine Tags vorhanden.</p>
|
||||
{:else}
|
||||
<div class="grid gap-2">
|
||||
{#each tagsCtx.value as tag}
|
||||
<div class="flex items-center gap-2 p-2 rounded-lg bg-card">
|
||||
<span class="w-3 h-3 rounded-full" style="background-color: {tag.color}"></span>
|
||||
<span>{tag.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tags-page {
|
||||
padding: 1.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
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>
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { PageHeader, toast } from '@manacore/shared-ui';
|
||||
import { timersStore } from '$lib/stores/timers.svelte';
|
||||
import { QUICK_TIMER_PRESETS, formatDuration } from '@clock/shared';
|
||||
import type { Timer } from '@clock/shared';
|
||||
|
||||
// Get live query data from layout context
|
||||
const allTimersQuery: { readonly value: Timer[] } = getContext('timers');
|
||||
|
||||
// Form state (inline on page)
|
||||
let formMinutes = $state(5);
|
||||
let formSeconds = $state(0);
|
||||
let formLabel = $state('');
|
||||
|
||||
// Local timers
|
||||
interface LocalTimer {
|
||||
id: string;
|
||||
label: string;
|
||||
durationSeconds: number;
|
||||
remainingSeconds: number;
|
||||
status: 'idle' | 'running' | 'paused' | 'finished';
|
||||
createdAt: Date;
|
||||
}
|
||||
let localTimers = $state<LocalTimer[]>([]);
|
||||
let intervals: Map<string, ReturnType<typeof setInterval>> = new Map();
|
||||
let allTimers = $derived([...allTimersQuery.value, ...localTimers]);
|
||||
|
||||
onDestroy(() => {
|
||||
intervals.forEach((interval) => clearInterval(interval));
|
||||
});
|
||||
|
||||
function startLocalCountdown(timerId: string, isLocal: boolean = false) {
|
||||
if (intervals.has(timerId)) {
|
||||
clearInterval(intervals.get(timerId));
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (isLocal) {
|
||||
const timer = localTimers.find((t) => t.id === timerId);
|
||||
if (!timer || timer.status !== 'running') {
|
||||
clearInterval(interval);
|
||||
intervals.delete(timerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const newRemaining = Math.max(0, timer.remainingSeconds - 1);
|
||||
localTimers = localTimers.map((t) =>
|
||||
t.id === timerId
|
||||
? {
|
||||
...t,
|
||||
remainingSeconds: newRemaining,
|
||||
status: newRemaining === 0 ? 'finished' : 'running',
|
||||
}
|
||||
: t
|
||||
);
|
||||
|
||||
if (newRemaining === 0) {
|
||||
clearInterval(interval);
|
||||
intervals.delete(timerId);
|
||||
toast.success($_('timer.finished'));
|
||||
if (browser && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Timer', { body: 'Timer abgelaufen!' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const timer = allTimersQuery.value.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 createAndStartTimer() {
|
||||
const durationSeconds = formMinutes * 60 + formSeconds;
|
||||
if (durationSeconds <= 0) {
|
||||
toast.error('Bitte eine gültige Zeit eingeben');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTimer: LocalTimer = {
|
||||
id: crypto.randomUUID(),
|
||||
label: formLabel || formatDuration(durationSeconds),
|
||||
durationSeconds,
|
||||
remainingSeconds: durationSeconds,
|
||||
status: 'running',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
localTimers = [...localTimers, newTimer];
|
||||
startLocalCountdown(newTimer.id, true);
|
||||
toast.success('Timer gestartet');
|
||||
formLabel = '';
|
||||
}
|
||||
|
||||
function setPreset(seconds: number) {
|
||||
formMinutes = Math.floor(seconds / 60);
|
||||
formSeconds = seconds % 60;
|
||||
}
|
||||
|
||||
async function handleStart(id: string, isLocal: boolean) {
|
||||
if (isLocal) {
|
||||
localTimers = localTimers.map((t) =>
|
||||
t.id === id ? { ...t, status: 'running' as const } : t
|
||||
);
|
||||
startLocalCountdown(id, true);
|
||||
} else {
|
||||
const result = await timersStore.startTimer(id);
|
||||
if (result.success) startLocalCountdown(id, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause(id: string, isLocal: boolean) {
|
||||
if (intervals.has(id)) {
|
||||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
if (isLocal) {
|
||||
localTimers = localTimers.map((t) => (t.id === id ? { ...t, status: 'paused' as const } : t));
|
||||
} else {
|
||||
await timersStore.pauseTimer(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(id: string, isLocal: boolean) {
|
||||
if (intervals.has(id)) {
|
||||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
if (isLocal) {
|
||||
localTimers = localTimers.map((t) =>
|
||||
t.id === id ? { ...t, remainingSeconds: t.durationSeconds, status: 'idle' as const } : t
|
||||
);
|
||||
} else {
|
||||
await timersStore.resetTimer(id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, isLocal: boolean) {
|
||||
if (intervals.has(id)) {
|
||||
clearInterval(intervals.get(id));
|
||||
intervals.delete(id);
|
||||
}
|
||||
if (isLocal) {
|
||||
localTimers = localTimers.filter((t) => t.id !== id);
|
||||
} else {
|
||||
await timersStore.deleteTimer(id);
|
||||
}
|
||||
}
|
||||
|
||||
function getTimerDisplay(timer: any) {
|
||||
const remaining = timer.remainingSeconds ?? timer.durationSeconds;
|
||||
return formatDuration(remaining);
|
||||
}
|
||||
|
||||
function getProgress(timer: any) {
|
||||
const remaining = timer.remainingSeconds ?? timer.durationSeconds;
|
||||
return (remaining / timer.durationSeconds) * 100;
|
||||
}
|
||||
|
||||
function isLocalTimer(timer: any): boolean {
|
||||
return localTimers.some((t) => t.id === timer.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={$_('timer.title')} size="md" centered />
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Quick Create Form -->
|
||||
<div class="quick-create">
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
class="time-input-inline w-12 text-center"
|
||||
min="0"
|
||||
max="99"
|
||||
bind:value={formMinutes}
|
||||
/>
|
||||
<span class="text-muted-foreground">:</span>
|
||||
<input
|
||||
type="number"
|
||||
class="time-input-inline w-12 text-center"
|
||||
min="0"
|
||||
max="59"
|
||||
bind:value={formSeconds}
|
||||
/>
|
||||
</div>
|
||||
<input type="text" class="label-input" placeholder="Bezeichnung" bind:value={formLabel} />
|
||||
<button class="btn btn-primary btn-sm" onclick={createAndStartTimer}> Start </button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Presets -->
|
||||
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
|
||||
{#each QUICK_TIMER_PRESETS as preset}
|
||||
<button class="alarm-tile text-center" onclick={() => setPreset(preset.seconds)}>
|
||||
<span class="text-lg font-light tabular-nums">{preset.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if allTimers.length > 0}
|
||||
<!-- Active Timers -->
|
||||
<div>
|
||||
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||
Aktiv ({allTimers.length})
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{#each allTimers as timer (timer.id)}
|
||||
{@const isLocal = isLocalTimer(timer)}
|
||||
<div class="alarm-tile" class:active={timer.status === 'running'}>
|
||||
<div class="flex items-start justify-between mb-1">
|
||||
<span
|
||||
class="text-xl font-light tabular-nums"
|
||||
class:text-primary={timer.status === 'running'}
|
||||
class:text-green-500={timer.status === 'finished'}
|
||||
>
|
||||
{getTimerDisplay(timer)}
|
||||
</span>
|
||||
<button
|
||||
class="text-muted-foreground hover:text-error p-0.5 -mr-1"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(timer.id, isLocal);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[10px] text-muted-foreground truncate mb-2">{timer.label}</div>
|
||||
<div class="h-1 bg-muted rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-1000"
|
||||
class:bg-primary={timer.status !== 'finished'}
|
||||
class:bg-green-500={timer.status === 'finished'}
|
||||
style="width: {getProgress(timer)}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
{#if timer.status === 'running'}
|
||||
<button
|
||||
class="btn btn-secondary btn-sm flex-1 text-xs"
|
||||
onclick={() => handlePause(timer.id, isLocal)}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-primary btn-sm flex-1 text-xs"
|
||||
onclick={() => handleStart(timer.id, isLocal)}
|
||||
>
|
||||
{timer.status === 'finished' ? 'Neu' : 'Start'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-xs"
|
||||
onclick={() => handleReset(timer.id, isLocal)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,368 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { PageHeader, toast } from '@manacore/shared-ui';
|
||||
import { worldClocksStore } from '$lib/stores/world-clocks.svelte';
|
||||
import { POPULAR_TIMEZONES } from '@clock/shared';
|
||||
import type { WorldClock } from '@clock/shared';
|
||||
import WorldMap from '$lib/components/WorldMap.svelte';
|
||||
import { Monitor } from '@manacore/shared-icons';
|
||||
|
||||
// Get live query data from layout context
|
||||
const allWorldClocks: { readonly value: WorldClock[] } = getContext('worldClocks');
|
||||
|
||||
// State
|
||||
let showAddModal = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let currentTime = $state(new Date());
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
let showMap = $state(true);
|
||||
|
||||
// Selected city timezones for map highlighting
|
||||
let selectedTimezones = $derived(allWorldClocks.value.map((wc) => wc.timezone));
|
||||
|
||||
// Handle map city click
|
||||
function handleMapCityClick(timezone: string, cityName: string) {
|
||||
const alreadyAdded = allWorldClocks.value.some((wc) => wc.timezone === timezone);
|
||||
if (alreadyAdded) {
|
||||
toast.info(`${cityName} ist bereits hinzugefügt`);
|
||||
} else {
|
||||
addCity(timezone, cityName);
|
||||
}
|
||||
}
|
||||
|
||||
// Filtered timezones based on search
|
||||
let filteredTimezones = $derived(
|
||||
searchQuery
|
||||
? POPULAR_TIMEZONES.filter(
|
||||
(tz) =>
|
||||
tz.city.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tz.timezone.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: POPULAR_TIMEZONES
|
||||
);
|
||||
|
||||
// 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 },
|
||||
allWorldClocks.value.length
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
<PageHeader title={$_('worldClock.title')} size="md" centered>
|
||||
{#snippet actions()}
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm px-2"
|
||||
onclick={() => (showMap = !showMap)}
|
||||
title={showMap ? 'Karte ausblenden' : 'Karte anzeigen'}
|
||||
>
|
||||
<Monitor size={20} />
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick={openAddModal}>
|
||||
+ {$_('worldClock.add')}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="world-clock-page">
|
||||
<!-- World Map (Full Width) -->
|
||||
{#if showMap}
|
||||
<div class="map-section">
|
||||
<div class="map-container">
|
||||
<WorldMap
|
||||
selectedCities={selectedTimezones}
|
||||
onCityClick={handleMapCityClick}
|
||||
{currentTime}
|
||||
/>
|
||||
</div>
|
||||
<p class="text-center text-xs text-muted-foreground py-2">
|
||||
Klicke auf eine Stadt um sie hinzuzufügen
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- World Clock List -->
|
||||
{#if allWorldClocks.value.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 allWorldClocks.value 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 p-0.5"
|
||||
onclick={() => removeCity(clock.id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Day/Night indicator -->
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">{isDay ? 'Tag' : 'Nacht'}</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 p-0.5" onclick={closeAddModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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 = allWorldClocks.value.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>
|
||||
|
||||
<style>
|
||||
.world-clock-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.map-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 -1rem 1rem -1rem;
|
||||
background: hsl(var(--color-card));
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-container :global(.world-map-container) {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.map-section {
|
||||
margin: 0 -1.5rem 1.5rem -1.5rem;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
.world-clock-card {
|
||||
background: hsl(var(--color-card));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.city-name {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: hsl(var(--color-foreground));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timezone-info {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
<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"
|
||||
/>
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { getLoginTranslations } from '@manacore/shared-i18n';
|
||||
import { ClockLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { APP_VERSION, BUILD_TIME } from '$lib/version';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Read verification status from query params (set after email verification)
|
||||
const verified = $derived($page.url.searchParams.get('verified') === 'true');
|
||||
const initialEmail = $derived($page.url.searchParams.get('email') || '');
|
||||
|
||||
// Get redirect URL from query params or sessionStorage (set by AuthGateModal in guest mode)
|
||||
const redirectTo = $derived.by(() => {
|
||||
const queryRedirect = $page.url.searchParams.get('redirectTo');
|
||||
if (queryRedirect) return queryRedirect;
|
||||
|
||||
// Check sessionStorage for return URL (from guest mode)
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
// Clear it after reading
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
return '/';
|
||||
});
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getLoginTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
return authStore.resendVerificationEmail(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Clock</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="Clock"
|
||||
logo={ClockLogo}
|
||||
primaryColor="#f59e0b"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
passkeyAvailable={authStore.isPasskeyAvailable()}
|
||||
onSignInWithPasskey={() => authStore.signInWithPasskey()}
|
||||
onVerifyTwoFactor={(code, trust) => authStore.verifyTwoFactor(code, trust)}
|
||||
onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)}
|
||||
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#fffbeb"
|
||||
darkBackground="#1c1917"
|
||||
{translations}
|
||||
{verified}
|
||||
{initialEmail}
|
||||
version={APP_VERSION}
|
||||
buildTime={BUILD_TIME}
|
||||
/>
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { getRegisterTranslations } from '@manacore/shared-i18n';
|
||||
import { ClockLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
// Get redirect URL from sessionStorage (set by AuthGateModal in guest mode)
|
||||
const redirectTo = $derived.by(() => {
|
||||
if (browser) {
|
||||
const sessionRedirect = sessionStorage.getItem('auth-return-url');
|
||||
if (sessionRedirect) {
|
||||
// Clear it after reading
|
||||
sessionStorage.removeItem('auth-return-url');
|
||||
return sessionRedirect;
|
||||
}
|
||||
}
|
||||
return '/';
|
||||
});
|
||||
|
||||
// Get translations based on current locale
|
||||
const translations = $derived(getRegisterTranslations($locale || 'de'));
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
|
||||
async function handleResendVerification(email: string) {
|
||||
return authStore.resendVerificationEmail(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{translations.title} | Clock</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="Clock"
|
||||
logo={ClockLogo}
|
||||
primaryColor="#f59e0b"
|
||||
onSignUp={handleSignUp}
|
||||
onResendVerification={handleResendVerification}
|
||||
{goto}
|
||||
successRedirect={redirectTo}
|
||||
loginPath="/login"
|
||||
lightBackground="#fffbeb"
|
||||
darkBackground="#1c1917"
|
||||
{translations}
|
||||
/>
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { ClockLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
let hasToken = $state(false);
|
||||
let token = $state<string | null>(null);
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
token = $page.url.searchParams.get('token');
|
||||
hasToken = !!token;
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
|
||||
if (!token) {
|
||||
error = 'Invalid or missing token';
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
error = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const result = await authStore.resetPasswordWithToken(token, password);
|
||||
if (!result.success) {
|
||||
error = result.error || 'Failed to reset password';
|
||||
} else {
|
||||
success = true;
|
||||
setTimeout(() => goto('/login'), 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reset Password - Clock</title>
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="flex min-h-screen flex-col bg-gradient-to-b from-amber-100 to-white dark:from-stone-900 dark:to-stone-800"
|
||||
>
|
||||
<header class="flex items-center justify-between p-4">
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<ClockLogo class="h-8 w-8" />
|
||||
<span class="text-xl font-semibold" style="color: #f59e0b">Clock</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main class="flex flex-1 items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Reset Password</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{#if success}Password reset successfully
|
||||
{:else if hasToken}Enter your new password
|
||||
{:else}Invalid or missing token{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if success}
|
||||
<div class="rounded-xl bg-white p-8 shadow-lg dark:bg-slate-800">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">✅</div>
|
||||
<p class="mb-6 text-gray-600 dark:text-gray-400">
|
||||
Your password has been reset successfully. You will be redirected to the login page
|
||||
shortly.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-block rounded-lg px-6 py-3 font-medium text-white transition-colors"
|
||||
style="background-color: #f59e0b"
|
||||
>
|
||||
Go to login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasToken}
|
||||
<div class="rounded-xl bg-white p-8 shadow-lg dark:bg-slate-800">
|
||||
<form onsubmit={handleSubmit}>
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>New Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Enter new password"
|
||||
required
|
||||
minlength={8}
|
||||
bind:value={password}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:outline-none focus:ring-2 dark:border-slate-600 dark:bg-slate-700 dark:text-white dark:placeholder-gray-400"
|
||||
style="--tw-ring-color: #f59e0b80; border-color: #f59e0b"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">At least 8 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>Confirm Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
required
|
||||
minlength={8}
|
||||
bind:value={confirmPassword}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-gray-900 focus:outline-none focus:ring-2 dark:border-slate-600 dark:bg-slate-700 dark:text-white dark:placeholder-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg px-4 py-3 font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style="background-color: #f59e0b"
|
||||
>
|
||||
{loading ? 'Updating password...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-xl bg-white p-8 shadow-lg dark:bg-slate-800">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-6xl">⚠️</div>
|
||||
<p class="mb-6 text-gray-600 dark:text-gray-400">
|
||||
This password reset link is invalid or has expired.
|
||||
</p>
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="inline-block rounded-lg px-6 py-3 font-medium text-white transition-colors"
|
||||
style="background-color: #f59e0b"
|
||||
>
|
||||
Request a new link
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { isLoading as isLocaleLoading } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
// Setup global error handling
|
||||
const cleanupErrorHandler = setupGlobalErrorHandler();
|
||||
|
||||
// Initialize async operations
|
||||
const init = async () => {
|
||||
// Wait for locale to be loaded
|
||||
await waitLocale();
|
||||
|
||||
// Initialize theme
|
||||
theme.initialize();
|
||||
|
||||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return cleanupErrorHandler;
|
||||
});
|
||||
</script>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
{#if $isLocaleLoading || loading}
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Disable SSR — all data is local-first (IndexedDB + mana-sync)
|
||||
export const ssr = false;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
return json({
|
||||
status: 'ok',
|
||||
service: 'clock-web',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { OfflinePage } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<OfflinePage
|
||||
appName="Clock"
|
||||
offlineMessage="Die Uhr funktioniert auch offline - Timer und Stoppuhr sind verfügbar."
|
||||
accentColor="#0891b2"
|
||||
/>
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const prerender = true;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
prerender: {
|
||||
handleHttpError: ({ path, message }) => {
|
||||
if (path === '/favicon.png') return;
|
||||
throw new Error(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import { createPWAConfig } from '@manacore/shared-pwa';
|
||||
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
SvelteKitPWA(
|
||||
createPWAConfig({
|
||||
name: 'Clock - Uhr & Timer',
|
||||
shortName: 'Clock',
|
||||
description: 'Uhr, Timer und Stoppuhr',
|
||||
themeColor: '#06b6d4',
|
||||
preset: 'minimal',
|
||||
})
|
||||
),
|
||||
],
|
||||
server: {
|
||||
port: 5187,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [...MANACORE_SHARED_PACKAGES],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [...MANACORE_SHARED_PACKAGES],
|
||||
},
|
||||
define: {
|
||||
...getBuildDefines(),
|
||||
},
|
||||
});
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "@clock/shared",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./constants": "./src/constants/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
// Popular timezones with city names and coordinates for map display
|
||||
export const POPULAR_TIMEZONES = [
|
||||
{
|
||||
timezone: 'America/New_York',
|
||||
city: 'New York',
|
||||
region: 'Americas',
|
||||
lat: 40.7128,
|
||||
lng: -74.006,
|
||||
},
|
||||
{
|
||||
timezone: 'America/Los_Angeles',
|
||||
city: 'Los Angeles',
|
||||
region: 'Americas',
|
||||
lat: 34.0522,
|
||||
lng: -118.2437,
|
||||
},
|
||||
{ timezone: 'America/Chicago', city: 'Chicago', region: 'Americas', lat: 41.8781, lng: -87.6298 },
|
||||
{ timezone: 'America/Toronto', city: 'Toronto', region: 'Americas', lat: 43.6532, lng: -79.3832 },
|
||||
{
|
||||
timezone: 'America/Sao_Paulo',
|
||||
city: 'São Paulo',
|
||||
region: 'Americas',
|
||||
lat: -23.5505,
|
||||
lng: -46.6333,
|
||||
},
|
||||
{
|
||||
timezone: 'America/Mexico_City',
|
||||
city: 'Mexico City',
|
||||
region: 'Americas',
|
||||
lat: 19.4326,
|
||||
lng: -99.1332,
|
||||
},
|
||||
{
|
||||
timezone: 'America/Buenos_Aires',
|
||||
city: 'Buenos Aires',
|
||||
region: 'Americas',
|
||||
lat: -34.6037,
|
||||
lng: -58.3816,
|
||||
},
|
||||
{
|
||||
timezone: 'America/Vancouver',
|
||||
city: 'Vancouver',
|
||||
region: 'Americas',
|
||||
lat: 49.2827,
|
||||
lng: -123.1207,
|
||||
},
|
||||
{ timezone: 'Europe/London', city: 'London', region: 'Europe', lat: 51.5074, lng: -0.1278 },
|
||||
{ timezone: 'Europe/Paris', city: 'Paris', region: 'Europe', lat: 48.8566, lng: 2.3522 },
|
||||
{ timezone: 'Europe/Berlin', city: 'Berlin', region: 'Europe', lat: 52.52, lng: 13.405 },
|
||||
{ timezone: 'Europe/Rome', city: 'Rome', region: 'Europe', lat: 41.9028, lng: 12.4964 },
|
||||
{ timezone: 'Europe/Madrid', city: 'Madrid', region: 'Europe', lat: 40.4168, lng: -3.7038 },
|
||||
{ timezone: 'Europe/Amsterdam', city: 'Amsterdam', region: 'Europe', lat: 52.3676, lng: 4.9041 },
|
||||
{ timezone: 'Europe/Vienna', city: 'Vienna', region: 'Europe', lat: 48.2082, lng: 16.3738 },
|
||||
{ timezone: 'Europe/Zurich', city: 'Zurich', region: 'Europe', lat: 47.3769, lng: 8.5417 },
|
||||
{ timezone: 'Europe/Moscow', city: 'Moscow', region: 'Europe', lat: 55.7558, lng: 37.6173 },
|
||||
{ timezone: 'Europe/Stockholm', city: 'Stockholm', region: 'Europe', lat: 59.3293, lng: 18.0686 },
|
||||
{ timezone: 'Europe/Istanbul', city: 'Istanbul', region: 'Europe', lat: 41.0082, lng: 28.9784 },
|
||||
{ timezone: 'Asia/Tokyo', city: 'Tokyo', region: 'Asia', lat: 35.6762, lng: 139.6503 },
|
||||
{ timezone: 'Asia/Shanghai', city: 'Shanghai', region: 'Asia', lat: 31.2304, lng: 121.4737 },
|
||||
{ timezone: 'Asia/Hong_Kong', city: 'Hong Kong', region: 'Asia', lat: 22.3193, lng: 114.1694 },
|
||||
{ timezone: 'Asia/Singapore', city: 'Singapore', region: 'Asia', lat: 1.3521, lng: 103.8198 },
|
||||
{ timezone: 'Asia/Seoul', city: 'Seoul', region: 'Asia', lat: 37.5665, lng: 126.978 },
|
||||
{ timezone: 'Asia/Mumbai', city: 'Mumbai', region: 'Asia', lat: 19.076, lng: 72.8777 },
|
||||
{ timezone: 'Asia/Dubai', city: 'Dubai', region: 'Asia', lat: 25.2048, lng: 55.2708 },
|
||||
{ timezone: 'Asia/Bangkok', city: 'Bangkok', region: 'Asia', lat: 13.7563, lng: 100.5018 },
|
||||
{ timezone: 'Asia/Jakarta', city: 'Jakarta', region: 'Asia', lat: -6.2088, lng: 106.8456 },
|
||||
{ timezone: 'Australia/Sydney', city: 'Sydney', region: 'Oceania', lat: -33.8688, lng: 151.2093 },
|
||||
{
|
||||
timezone: 'Australia/Melbourne',
|
||||
city: 'Melbourne',
|
||||
region: 'Oceania',
|
||||
lat: -37.8136,
|
||||
lng: 144.9631,
|
||||
},
|
||||
{
|
||||
timezone: 'Pacific/Auckland',
|
||||
city: 'Auckland',
|
||||
region: 'Oceania',
|
||||
lat: -36.8485,
|
||||
lng: 174.7633,
|
||||
},
|
||||
{ timezone: 'Africa/Cairo', city: 'Cairo', region: 'Africa', lat: 30.0444, lng: 31.2357 },
|
||||
{
|
||||
timezone: 'Africa/Johannesburg',
|
||||
city: 'Johannesburg',
|
||||
region: 'Africa',
|
||||
lat: -26.2041,
|
||||
lng: 28.0473,
|
||||
},
|
||||
{ timezone: 'Africa/Lagos', city: 'Lagos', region: 'Africa', lat: 6.5244, lng: 3.3792 },
|
||||
] as const;
|
||||
|
||||
// Available alarm sounds
|
||||
export const ALARM_SOUNDS = [
|
||||
{ id: 'default', name: 'Default', nameDE: 'Standard' },
|
||||
{ id: 'gentle', name: 'Gentle', nameDE: 'Sanft' },
|
||||
{ id: 'classic', name: 'Classic', nameDE: 'Klassisch' },
|
||||
{ id: 'digital', name: 'Digital', nameDE: 'Digital' },
|
||||
{ id: 'nature', name: 'Nature', nameDE: 'Natur' },
|
||||
{ id: 'chime', name: 'Chime', nameDE: 'Glockenspiel' },
|
||||
] as const;
|
||||
|
||||
// Timer presets
|
||||
export const QUICK_TIMER_PRESETS = [
|
||||
{ label: '1 min', seconds: 60 },
|
||||
{ label: '3 min', seconds: 180 },
|
||||
{ label: '5 min', seconds: 300 },
|
||||
{ label: '10 min', seconds: 600 },
|
||||
{ label: '15 min', seconds: 900 },
|
||||
{ label: '30 min', seconds: 1800 },
|
||||
{ label: '45 min', seconds: 2700 },
|
||||
{ label: '1 hour', seconds: 3600 },
|
||||
] as const;
|
||||
|
||||
// Default alarm presets (like iOS Clock app)
|
||||
export const DEFAULT_ALARM_PRESETS = [
|
||||
{ time: '06:00', label: 'Früh aufstehen', labelEN: 'Wake up early' },
|
||||
{ time: '07:00', label: 'Aufwachen', labelEN: 'Wake up' },
|
||||
{ time: '08:00', label: 'Morgen', labelEN: 'Morning' },
|
||||
{ time: '12:00', label: 'Mittag', labelEN: 'Noon' },
|
||||
{ time: '18:00', label: 'Feierabend', labelEN: 'End of work' },
|
||||
{ time: '22:00', label: 'Schlafenszeit', labelEN: 'Bedtime' },
|
||||
] as const;
|
||||
|
||||
// Pomodoro presets
|
||||
export const POMODORO_PRESETS = [
|
||||
{
|
||||
name: 'Classic Pomodoro',
|
||||
nameDE: 'Klassischer Pomodoro',
|
||||
workDuration: 25 * 60,
|
||||
breakDuration: 5 * 60,
|
||||
longBreakDuration: 15 * 60,
|
||||
sessionsBeforeLongBreak: 4,
|
||||
},
|
||||
{
|
||||
name: 'Short Focus',
|
||||
nameDE: 'Kurzer Fokus',
|
||||
workDuration: 15 * 60,
|
||||
breakDuration: 3 * 60,
|
||||
longBreakDuration: 10 * 60,
|
||||
sessionsBeforeLongBreak: 4,
|
||||
},
|
||||
{
|
||||
name: 'Deep Work',
|
||||
nameDE: 'Tiefes Arbeiten',
|
||||
workDuration: 50 * 60,
|
||||
breakDuration: 10 * 60,
|
||||
longBreakDuration: 30 * 60,
|
||||
sessionsBeforeLongBreak: 3,
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './types';
|
||||
export * from './constants';
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
export interface Alarm {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
time: string; // HH:MM:SS format
|
||||
enabled: boolean;
|
||||
repeatDays: number[] | null; // [0-6] where 0 = Sunday
|
||||
snoozeMinutes: number | null;
|
||||
sound: string | null;
|
||||
vibrate: boolean | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateAlarmInput {
|
||||
label?: string;
|
||||
time: string;
|
||||
enabled?: boolean;
|
||||
repeatDays?: number[];
|
||||
snoozeMinutes?: number;
|
||||
sound?: string;
|
||||
vibrate?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateAlarmInput {
|
||||
label?: string;
|
||||
time?: string;
|
||||
enabled?: boolean;
|
||||
repeatDays?: number[];
|
||||
snoozeMinutes?: number;
|
||||
sound?: string;
|
||||
vibrate?: boolean;
|
||||
}
|
||||
|
||||
export type RepeatDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export const REPEAT_DAY_LABELS = {
|
||||
0: 'Sun',
|
||||
1: 'Mon',
|
||||
2: 'Tue',
|
||||
3: 'Wed',
|
||||
4: 'Thu',
|
||||
5: 'Fri',
|
||||
6: 'Sat',
|
||||
} as const;
|
||||
|
||||
export const REPEAT_DAY_LABELS_DE = {
|
||||
0: 'So',
|
||||
1: 'Mo',
|
||||
2: 'Di',
|
||||
3: 'Mi',
|
||||
4: 'Do',
|
||||
5: 'Fr',
|
||||
6: 'Sa',
|
||||
} as const;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './alarm';
|
||||
export * from './timer';
|
||||
export * from './world-clock';
|
||||
export * from './preset';
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
export type PresetType = 'timer' | 'pomodoro';
|
||||
|
||||
export interface PresetSettings {
|
||||
// For pomodoro presets
|
||||
workDuration?: number;
|
||||
breakDuration?: number;
|
||||
longBreakDuration?: number;
|
||||
sessionsBeforeLongBreak?: number;
|
||||
// For timer presets
|
||||
sound?: string;
|
||||
}
|
||||
|
||||
export interface Preset {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: PresetType;
|
||||
name: string;
|
||||
durationSeconds: number;
|
||||
settings: PresetSettings | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreatePresetInput {
|
||||
type: PresetType;
|
||||
name: string;
|
||||
durationSeconds: number;
|
||||
settings?: PresetSettings;
|
||||
}
|
||||
|
||||
export interface UpdatePresetInput {
|
||||
name?: string;
|
||||
durationSeconds?: number;
|
||||
settings?: PresetSettings;
|
||||
}
|
||||
|
||||
// Default pomodoro settings
|
||||
export const DEFAULT_POMODORO_SETTINGS: PresetSettings = {
|
||||
workDuration: 25 * 60, // 25 minutes
|
||||
breakDuration: 5 * 60, // 5 minutes
|
||||
longBreakDuration: 15 * 60, // 15 minutes
|
||||
sessionsBeforeLongBreak: 4,
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
export type TimerStatus = 'idle' | 'running' | 'paused' | 'finished';
|
||||
|
||||
export interface Timer {
|
||||
id: string;
|
||||
userId: string;
|
||||
label: string | null;
|
||||
durationSeconds: number;
|
||||
remainingSeconds: number | null;
|
||||
status: TimerStatus;
|
||||
startedAt: string | null;
|
||||
pausedAt: string | null;
|
||||
sound: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateTimerInput {
|
||||
label?: string;
|
||||
durationSeconds: number;
|
||||
sound?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTimerInput {
|
||||
label?: string;
|
||||
durationSeconds?: number;
|
||||
sound?: string;
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function parseDuration(formatted: string): number {
|
||||
const parts = formatted.split(':').map(Number);
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
export interface WorldClock {
|
||||
id: string;
|
||||
userId: string;
|
||||
timezone: string; // IANA timezone e.g. 'America/New_York'
|
||||
cityName: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateWorldClockInput {
|
||||
timezone: string;
|
||||
cityName: string;
|
||||
}
|
||||
|
||||
export interface TimezoneInfo {
|
||||
timezone: string;
|
||||
city: string;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
53
apps-archived/wisekeep/.gitignore
vendored
53
apps-archived/wisekeep/.gitignore
vendored
|
|
@ -1,53 +0,0 @@
|
|||
# Python (legacy)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Data (transcripts, playlists)
|
||||
data/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.npm
|
||||
dist/
|
||||
build/
|
||||
.astro/
|
||||
.svelte-kit/
|
||||
.turbo/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
apps/*/dist/
|
||||
apps/*/.astro/
|
||||
apps/*/.svelte-kit/
|
||||
|
||||
# Expo
|
||||
apps/mobile/.expo/
|
||||
apps/mobile/ios/
|
||||
apps/mobile/android/
|
||||
|
||||
# Legacy
|
||||
legacy/venv/
|
||||
legacy/__pycache__/
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
# Wisekeep — AI Wisdom Extraction from Video
|
||||
|
||||
## Architecture
|
||||
|
||||
Local-first for transcripts/playlists, Hono/Bun server for Groq Whisper transcription.
|
||||
|
||||
```
|
||||
Browser → IndexedDB (Transcripts, Playlists)
|
||||
↕ sync
|
||||
mana-sync → PostgreSQL
|
||||
|
||||
Browser → Hono Server → yt-dlp (download) → Groq Whisper (transcribe)
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/wisekeep/
|
||||
├── apps/
|
||||
│ ├── web/ # SvelteKit web app (local-first)
|
||||
│ ├── server/ # Hono/Bun (transcription via Groq)
|
||||
│ └── landing/ # Astro content site (curated talks)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev:wisekeep:web # SvelteKit dev server
|
||||
pnpm dev:wisekeep:server # Hono/Bun server (port 3072)
|
||||
pnpm dev:wisekeep:landing # Landing page
|
||||
pnpm dev:wisekeep:local # Web + Sync + Server (no auth)
|
||||
pnpm dev:wisekeep:full # Everything incl. auth
|
||||
```
|
||||
|
||||
## Server Routes
|
||||
|
||||
| Route | Auth | Description |
|
||||
|-------|------|-------------|
|
||||
| `GET /health` | No | Health check |
|
||||
| `POST /api/v1/transcribe` | JWT | Transcribe YouTube URL via Groq |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `yt-dlp` installed (`brew install yt-dlp`)
|
||||
- `GROQ_API_KEY` env variable set
|
||||
|
||||
## Local-First Collections
|
||||
|
||||
| Collection | Purpose |
|
||||
|-----------|---------|
|
||||
| `transcripts` | Video transcriptions (title, channel, transcript text) |
|
||||
| `playlists` | Organized collections of transcripts |
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import solidJs from '@astrojs/solid-js';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [solidJs(), tailwind()],
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"name": "@wisekeep/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"lint": "eslint .",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/solid-js": "^4.4.0",
|
||||
"astro": "^4.16.0",
|
||||
"solid-js": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
---
|
||||
export interface Props {
|
||||
title: string;
|
||||
icon?: string;
|
||||
defaultCollapsed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { title, icon = '📌', defaultCollapsed = false, className = '' } = Astro.props;
|
||||
const sectionId = title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
---
|
||||
|
||||
<div class={`collapsible-section ${className}`} data-section-id={sectionId}>
|
||||
<button
|
||||
class="section-header"
|
||||
aria-expanded={!defaultCollapsed}
|
||||
aria-controls={`content-${sectionId}`}
|
||||
>
|
||||
<span class="section-icon">{icon}</span>
|
||||
<h2 class="section-title">{title}</h2>
|
||||
<span class="section-arrow" data-collapsed={defaultCollapsed}>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 9L12 15L18 9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div id={`content-${sectionId}`} class="section-content" data-collapsed={defaultCollapsed}>
|
||||
<div class="section-inner">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.collapsible-section {
|
||||
background: rgb(var(--theme-card));
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsible-section:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
width: 100%;
|
||||
padding: 1.5rem 2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.section-arrow {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.section-arrow[data-collapsed='true'] {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.section-content {
|
||||
max-height: 2000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.section-content[data-collapsed='true'] {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.section-inner {
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.section-inner {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sections = document.querySelectorAll('.collapsible-section');
|
||||
|
||||
sections.forEach((section) => {
|
||||
const header = section.querySelector('.section-header');
|
||||
const content = section.querySelector('.section-content');
|
||||
const arrow = section.querySelector('.section-arrow');
|
||||
|
||||
if (!header || !content || !arrow) return;
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
const isCollapsed = content.dataset.collapsed === 'true';
|
||||
|
||||
if (isCollapsed) {
|
||||
content.dataset.collapsed = 'false';
|
||||
arrow.dataset.collapsed = 'false';
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Calculate actual height for smooth animation
|
||||
const inner = content.querySelector('.section-inner');
|
||||
if (inner) {
|
||||
content.style.maxHeight = inner.scrollHeight + 'px';
|
||||
}
|
||||
} else {
|
||||
content.dataset.collapsed = 'true';
|
||||
arrow.dataset.collapsed = 'true';
|
||||
header.setAttribute('aria-expanded', 'false');
|
||||
content.style.maxHeight = '0';
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial max-height for expanded sections
|
||||
if (content.dataset.collapsed === 'false') {
|
||||
const inner = content.querySelector('.section-inner');
|
||||
if (inner) {
|
||||
content.style.maxHeight = inner.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Component } from 'solid-js';
|
||||
|
||||
interface ContentCardProps {
|
||||
title: string;
|
||||
speaker: string;
|
||||
speakerId?: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
}
|
||||
|
||||
const ContentCard: Component<ContentCardProps> = (props) => {
|
||||
return (
|
||||
<a href={props.link} class="group relative flex flex-col h-full cursor-pointer">
|
||||
{/* Card Container with hover effects */}
|
||||
<article class="glass rounded-2xl overflow-hidden h-full flex flex-col transition-all duration-500 hover:shadow-theme-xl hover:-translate-y-1 border border-theme-border/50 hover:border-theme-primary/30">
|
||||
{/* Gradient overlay on hover */}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-theme-primary/5 to-theme-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none rounded-2xl"></div>
|
||||
|
||||
{/* Content section */}
|
||||
<div class="flex-1 p-6 flex flex-col relative z-10">
|
||||
{/* Title */}
|
||||
<h3 class="text-xl font-bold mb-3 text-theme-text group-hover:text-theme-primary transition-colors duration-300 line-clamp-2">
|
||||
{props.title}
|
||||
</h3>
|
||||
|
||||
{/* Meta information */}
|
||||
<div class="flex items-center gap-3 text-sm text-theme-text-muted mb-3">
|
||||
{props.speakerId ? (
|
||||
<a
|
||||
href={`/speakers/${props.speakerId}`}
|
||||
class="flex items-center gap-1 hover:text-theme-primary transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<span class="text-base">🎤</span>
|
||||
<span class="font-medium">{props.speaker}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="text-base">🎤</span>
|
||||
<span class="font-medium">{props.speaker}</span>
|
||||
</span>
|
||||
)}
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>⏱️ {props.duration}</span>
|
||||
{props.date && (
|
||||
<>
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>{props.date}</span>
|
||||
</>
|
||||
)}
|
||||
{props.views && (
|
||||
<>
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>👁️ {props.views}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<p class="text-theme-text-muted mb-4 line-clamp-3 flex-1">{props.excerpt}</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{props.tags.map((tag) => (
|
||||
<span class="px-3 py-1 bg-theme-surface rounded-full text-xs font-medium text-theme-text-muted border border-theme-border/50">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Text (no longer a link since whole card is clickable) */}
|
||||
<div class="inline-flex items-center gap-2 text-theme-primary font-semibold transition-all duration-300">
|
||||
<span>Weiterlesen</span>
|
||||
<svg
|
||||
class="w-4 h-4 transform group-hover:translate-x-1 transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative corner accent */}
|
||||
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-theme-primary/10 to-transparent rounded-bl-[40px] opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</article>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentCard;
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
import { Component, For, createSignal, onMount } from 'solid-js';
|
||||
import ContentCard from './ContentCard';
|
||||
|
||||
interface Talk {
|
||||
id: string;
|
||||
title: string;
|
||||
speaker: string;
|
||||
speakerId?: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
}
|
||||
|
||||
const ContentCardList: Component = () => {
|
||||
const [talks, setTalks] = createSignal<Talk[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
// Mock data - später durch API-Call ersetzen
|
||||
onMount(() => {
|
||||
// Simuliere API-Call
|
||||
setTimeout(() => {
|
||||
setTalks([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Perspective is Everything: The Psychology of Reframing',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '18 Min',
|
||||
excerpt:
|
||||
'Wie kleine Änderungen in der Perspektive große Auswirkungen auf unser Verhalten und unsere Entscheidungen haben können. Ein faszinierender Einblick in die Verhaltensökonomie.',
|
||||
tags: ['Behavioral Economics', 'Psychology', 'Marketing'],
|
||||
link: '/talks/rory-sutherland-perspective-is-everything',
|
||||
date: '15. März 2024',
|
||||
views: '12.5k',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'The Power of Psychological Solutions',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '22 Min',
|
||||
excerpt:
|
||||
'Warum psychologische Lösungen oft effektiver und günstiger sind als technische. Sutherland zeigt, wie wir Probleme neu denken können.',
|
||||
tags: ['Innovation', 'Problem Solving', 'Design Thinking'],
|
||||
link: '/talks/rory-sutherland-psychological-solutions',
|
||||
date: '10. März 2024',
|
||||
views: '8.3k',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Marketing Secrets from Behavioral Science',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '25 Min',
|
||||
excerpt:
|
||||
'Die verborgenen psychologischen Mechanismen hinter erfolgreichem Marketing. Erkenntnisse aus jahrzehntelanger Erfahrung bei Ogilvy.',
|
||||
tags: ['Marketing', 'Consumer Behavior', 'Branding'],
|
||||
link: '/talks/rory-sutherland-marketing-secrets',
|
||||
date: '5. März 2024',
|
||||
views: '15.7k',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Why Context Matters More Than Content',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '20 Min',
|
||||
excerpt:
|
||||
'Der Kontext bestimmt, wie wir Informationen wahrnehmen und interpretieren. Eine Lektion in der Kunst der Kommunikation.',
|
||||
tags: ['Communication', 'Perception', 'Context'],
|
||||
link: '/talks/rory-sutherland-context-matters',
|
||||
date: '1. März 2024',
|
||||
views: '6.2k',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'The Irrational Consumer: Understanding Human Behavior',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '30 Min',
|
||||
excerpt:
|
||||
'Menschen sind keine rationalen Akteure. Wie wir diese Erkenntnis nutzen können, um bessere Produkte und Services zu entwickeln.',
|
||||
tags: ['Consumer Psychology', 'Behavioral Economics', 'UX Design'],
|
||||
link: '/talks/rory-sutherland-irrational-consumer',
|
||||
date: '25. Februar 2024',
|
||||
views: '10.1k',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Alchemy: The Magic of Ideas',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '28 Min',
|
||||
excerpt:
|
||||
'Große Ideen kommen oft aus unerwarteten Ecken. Sutherland erklärt, warum Logik allein nicht ausreicht, um Innovation zu schaffen.',
|
||||
tags: ['Creativity', 'Innovation', 'Ideas'],
|
||||
link: '/talks/rory-sutherland-alchemy',
|
||||
date: '20. Februar 2024',
|
||||
views: '18.9k',
|
||||
},
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
{loading() ? (
|
||||
// Loading skeleton
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={[1, 2, 3, 4, 5, 6]}>
|
||||
{() => (
|
||||
<div class="glass rounded-2xl overflow-hidden h-[460px] animate-pulse">
|
||||
<div class="h-48 bg-theme-surface"></div>
|
||||
<div class="p-6">
|
||||
<div class="h-6 bg-theme-surface rounded mb-3"></div>
|
||||
<div class="h-4 bg-theme-surface rounded w-2/3 mb-3"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : (
|
||||
// Content cards grid
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={talks()}>
|
||||
{(talk) => (
|
||||
<ContentCard
|
||||
title={talk.title}
|
||||
speaker={talk.speaker}
|
||||
speakerId={talk.speakerId}
|
||||
duration={talk.duration}
|
||||
excerpt={talk.excerpt}
|
||||
tags={talk.tags}
|
||||
link={talk.link}
|
||||
date={talk.date}
|
||||
thumbnail={talk.thumbnail}
|
||||
views={talk.views}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentCardList;
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
---
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="mt-24 border-t border-theme-border/30">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Main footer content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
|
||||
<!-- About section -->
|
||||
<div class="md:col-span-2">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl">🎥</span>
|
||||
<span class="text-xl font-bold text-theme-text">Wisdom Library</span>
|
||||
</div>
|
||||
<p class="text-theme-text-muted text-sm leading-relaxed">
|
||||
Transkribierte Vorträge von führenden Denkern - durchsuchbar, aufbereitet und immer
|
||||
verfügbar. Powered by OpenAI Whisper für präzise Transkriptionen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-theme-text mb-4">Entdecken</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a
|
||||
href="/talks"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Alle Vorträge
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/speakers"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Sprecher
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/categories"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Kategorien
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/trending"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Beliebt
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Resources -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-theme-text mb-4">Ressourcen</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a
|
||||
href="/admin"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Admin Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="http://localhost:8000/docs"
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm flex items-center gap-1"
|
||||
>
|
||||
API Dokumentation
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/about"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Über uns
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/contact"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Kontakt
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="pt-8 border-t border-theme-border/20">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="flex flex-col md:flex-row items-center gap-4 text-sm text-theme-text-muted">
|
||||
<span>© {currentYear} YouTube Wisdom Library</span>
|
||||
<span class="hidden md:inline">•</span>
|
||||
<span>Powered by OpenAI Whisper</span>
|
||||
</div>
|
||||
|
||||
<!-- Social links / Stats -->
|
||||
<div class="flex items-center gap-6 text-sm text-theme-text-muted">
|
||||
<a href="/privacy" class="hover:text-theme-primary transition-colors"> Datenschutz </a>
|
||||
<a href="/terms" class="hover:text-theme-primary transition-colors">
|
||||
Nutzungsbedingungen
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
class="hover:text-theme-primary transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
---
|
||||
export interface Props {
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
const { currentPath = '/' } = Astro.props;
|
||||
---
|
||||
|
||||
<nav
|
||||
class="border-b border-theme-border/50 backdrop-blur-md sticky top-0 z-40 bg-theme-background/80"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<a
|
||||
href="/"
|
||||
class="text-xl font-bold flex items-center gap-2 text-theme-text hover:text-theme-primary transition-colors"
|
||||
>
|
||||
<span class="text-2xl">🎥</span>
|
||||
<span>Wisdom Library</span>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<a
|
||||
href="/talks"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/talks') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Vorträge
|
||||
</a>
|
||||
<a
|
||||
href="/speakers"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/speakers') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Sprecher
|
||||
</a>
|
||||
<a
|
||||
href="/categories"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/categories') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Kategorien
|
||||
</a>
|
||||
<a
|
||||
href="/admin"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/admin') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8000/docs"
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
API
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button class="md:hidden text-theme-text" id="mobile-menu-button">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div id="mobile-menu" class="hidden md:hidden pb-4">
|
||||
<a
|
||||
href="/talks"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/talks') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Vorträge
|
||||
</a>
|
||||
<a
|
||||
href="/speakers"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/speakers') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Sprecher
|
||||
</a>
|
||||
<a
|
||||
href="/categories"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/categories') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Kategorien
|
||||
</a>
|
||||
<a
|
||||
href="/admin"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/admin') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8000/docs"
|
||||
target="_blank"
|
||||
class="block py-2 text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
>
|
||||
API ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
import { Component, For, createSignal, onMount, createMemo } from 'solid-js';
|
||||
import ContentCard from './ContentCard';
|
||||
|
||||
interface Talk {
|
||||
id: string;
|
||||
title: string;
|
||||
speaker: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
}
|
||||
|
||||
const SearchableContentList: Component = () => {
|
||||
const [talks, setTalks] = createSignal<Talk[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
|
||||
// Mock data - später durch API-Call ersetzen
|
||||
onMount(() => {
|
||||
// Simuliere API-Call
|
||||
setTimeout(() => {
|
||||
setTalks([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Perspective is Everything: The Psychology of Reframing',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '18 Min',
|
||||
excerpt:
|
||||
'Wie kleine Änderungen in der Perspektive große Auswirkungen auf unser Verhalten und unsere Entscheidungen haben können. Ein faszinierender Einblick in die Verhaltensökonomie.',
|
||||
tags: ['Behavioral Economics', 'Psychology', 'Marketing'],
|
||||
link: '/talks/rory-sutherland-perspective-is-everything',
|
||||
date: '15. März 2024',
|
||||
views: '12.5k',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'The Power of Psychological Solutions',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '22 Min',
|
||||
excerpt:
|
||||
'Warum psychologische Lösungen oft effektiver und günstiger sind als technische. Sutherland zeigt, wie wir Probleme neu denken können.',
|
||||
tags: ['Innovation', 'Problem Solving', 'Design Thinking'],
|
||||
link: '/talks/rory-sutherland-psychological-solutions',
|
||||
date: '10. März 2024',
|
||||
views: '8.3k',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Marketing Secrets from Behavioral Science',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '25 Min',
|
||||
excerpt:
|
||||
'Die verborgenen psychologischen Mechanismen hinter erfolgreichem Marketing. Erkenntnisse aus jahrzehntelanger Erfahrung bei Ogilvy.',
|
||||
tags: ['Marketing', 'Consumer Behavior', 'Branding'],
|
||||
link: '/talks/rory-sutherland-marketing-secrets',
|
||||
date: '5. März 2024',
|
||||
views: '15.7k',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Why Context Matters More Than Content',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '20 Min',
|
||||
excerpt:
|
||||
'Der Kontext bestimmt, wie wir Informationen wahrnehmen und interpretieren. Eine Lektion in der Kunst der Kommunikation.',
|
||||
tags: ['Communication', 'Perception', 'Context'],
|
||||
link: '/talks/rory-sutherland-context-matters',
|
||||
date: '1. März 2024',
|
||||
views: '6.2k',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'The Irrational Consumer: Understanding Human Behavior',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '30 Min',
|
||||
excerpt:
|
||||
'Menschen sind keine rationalen Akteure. Wie wir diese Erkenntnis nutzen können, um bessere Produkte und Services zu entwickeln.',
|
||||
tags: ['Consumer Psychology', 'Behavioral Economics', 'UX Design'],
|
||||
link: '/talks/rory-sutherland-irrational-consumer',
|
||||
date: '25. Februar 2024',
|
||||
views: '10.1k',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Alchemy: The Magic of Ideas',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '28 Min',
|
||||
excerpt:
|
||||
'Große Ideen kommen oft aus unerwarteten Ecken. Sutherland erklärt, warum Logik allein nicht ausreicht, um Innovation zu schaffen.',
|
||||
tags: ['Creativity', 'Innovation', 'Ideas'],
|
||||
link: '/talks/rory-sutherland-alchemy',
|
||||
date: '20. Februar 2024',
|
||||
views: '18.9k',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'How Great Leaders Inspire Action (Start with Why)',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '18 Min',
|
||||
excerpt:
|
||||
'Simon Sineks berühmter TED Talk über das Golden Circle Modell - warum großartige Führungskräfte mit dem "Warum" beginnen und wie dies das Verhalten und die Loyalität von Menschen beeinflusst.',
|
||||
tags: ['Leadership', 'Purpose', 'Golden Circle', 'Inspiration'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '60M+',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Why Good Leaders Make You Feel Safe',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '12 Min',
|
||||
excerpt:
|
||||
'Ein kraftvoller Vortrag darüber, wie echte Führung bedeutet, Sicherheit für das Team zu schaffen, damit Menschen ihr Bestes geben können und bereit sind, füreinander einzustehen.',
|
||||
tags: ['Leadership', 'Trust', 'Safety', 'Team Building'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '18M+',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
title: 'Millennials in the Workplace',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '15 Min',
|
||||
excerpt:
|
||||
'Simon Sineks virales Interview über die Herausforderungen der Millennial-Generation im Arbeitsplatz - von der Auswirkung der Technologie bis hin zu veränderten Arbeitserwartungen.',
|
||||
tags: ['Millennials', 'Workplace', 'Technology', 'Generational Change'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '100M+',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
title: 'Love Your Work',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '42 Min',
|
||||
excerpt:
|
||||
'Ein inspirierender Talk über die Bedeutung von Leidenschaft bei der Arbeit und wie man eine Karriere aufbaut, die nicht nur erfolgreich, sondern auch erfüllend ist.',
|
||||
tags: ['Career', 'Passion', 'Purpose', 'Work-Life Balance'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '2.8M',
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
title: 'The Future of AI and Machine Learning',
|
||||
speaker: 'Andrew Ng',
|
||||
duration: '35 Min',
|
||||
excerpt:
|
||||
'Ein tiefer Einblick in die Zukunft der künstlichen Intelligenz und wie Machine Learning unsere Welt verändern wird.',
|
||||
tags: ['AI', 'Machine Learning', 'Technology'],
|
||||
link: '/talks/andrew-ng-future-of-ai',
|
||||
date: '18. Februar 2024',
|
||||
views: '22.3k',
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
title: 'Building Resilient Systems',
|
||||
speaker: 'Martin Fowler',
|
||||
duration: '40 Min',
|
||||
excerpt:
|
||||
'Wie man Software-Systeme baut, die robust, wartbar und skalierbar sind. Best Practices aus jahrzehntelanger Erfahrung.',
|
||||
tags: ['Software Architecture', 'Engineering', 'Best Practices'],
|
||||
link: '/talks/martin-fowler-resilient-systems',
|
||||
date: '15. Februar 2024',
|
||||
views: '9.8k',
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
title: 'The Psychology of Money',
|
||||
speaker: 'Morgan Housel',
|
||||
duration: '32 Min',
|
||||
excerpt:
|
||||
'Warum kluge Menschen dumme Dinge mit Geld machen und wie unsere Psychologie unsere finanziellen Entscheidungen beeinflusst.',
|
||||
tags: ['Finance', 'Psychology', 'Behavioral Economics'],
|
||||
link: '/talks/morgan-housel-psychology-of-money',
|
||||
date: '10. Februar 2024',
|
||||
views: '25.6k',
|
||||
},
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Filtered talks based on search query
|
||||
const filteredTalks = createMemo(() => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (!query) return talks();
|
||||
|
||||
return talks().filter((talk) => {
|
||||
return (
|
||||
talk.title.toLowerCase().includes(query) ||
|
||||
talk.speaker.toLowerCase().includes(query) ||
|
||||
talk.excerpt.toLowerCase().includes(query) ||
|
||||
talk.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle search input
|
||||
const handleSearch = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setSearchQuery(target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
{/* Search Bar */}
|
||||
<div class="mb-12 max-w-2xl mx-auto">
|
||||
<div class="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Vorträgen, Sprechern oder Themen..."
|
||||
value={searchQuery()}
|
||||
onInput={handleSearch}
|
||||
class="w-full px-6 py-4 pl-12 glass rounded-full text-theme-text placeholder-theme-text-muted/60 border border-theme-border/50 focus:border-theme-primary/50 focus:outline-none focus:ring-2 focus:ring-theme-primary/20 transition-all"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
{/* Clear button */}
|
||||
{searchQuery() && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-theme-surface transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results count */}
|
||||
{searchQuery() && !loading() && (
|
||||
<div class="mt-4 text-center text-theme-text-muted">
|
||||
{filteredTalks().length === 0 ? (
|
||||
<span>Keine Ergebnisse für "{searchQuery()}"</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredTalks().length} {filteredTalks().length === 1 ? 'Ergebnis' : 'Ergebnisse'}{' '}
|
||||
für "{searchQuery()}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading() ? (
|
||||
// Loading skeleton
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={[1, 2, 3, 4, 5, 6]}>
|
||||
{() => (
|
||||
<div class="glass rounded-2xl overflow-hidden h-[460px] animate-pulse">
|
||||
<div class="h-48 bg-theme-surface"></div>
|
||||
<div class="p-6">
|
||||
<div class="h-6 bg-theme-surface rounded mb-3"></div>
|
||||
<div class="h-4 bg-theme-surface rounded w-2/3 mb-3"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTalks().length === 0 && searchQuery() ? (
|
||||
// No results state
|
||||
<div class="text-center py-16">
|
||||
<div class="text-6xl mb-4">🔍</div>
|
||||
<h3 class="text-2xl font-semibold mb-2 text-theme-text">Keine Treffer</h3>
|
||||
<p class="text-theme-text-muted max-w-md mx-auto">
|
||||
Versuche es mit anderen Suchbegriffen oder browse durch alle verfügbaren Vorträge.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
class="mt-6 px-6 py-2 bg-theme-primary text-white rounded-lg hover:bg-theme-primary-hover transition-colors"
|
||||
>
|
||||
Alle Vorträge anzeigen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Content cards grid with fade-in animation
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={filteredTalks()}>
|
||||
{(talk, index) => (
|
||||
<div
|
||||
style={{
|
||||
animation: `fadeIn 0.5s ease-out ${index() * 0.05}s both`,
|
||||
}}
|
||||
>
|
||||
<ContentCard
|
||||
title={talk.title}
|
||||
speaker={talk.speaker}
|
||||
duration={talk.duration}
|
||||
excerpt={talk.excerpt}
|
||||
tags={talk.tags}
|
||||
link={talk.link}
|
||||
date={talk.date}
|
||||
thumbnail={talk.thumbnail}
|
||||
views={talk.views}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchableContentList;
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const talks = await getCollection('talks');
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
// Sort talks by date (newest first)
|
||||
const sortedTalks = talks.sort((a, b) => {
|
||||
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
|
||||
});
|
||||
|
||||
// Group talks by speaker
|
||||
const talksBySpeaker = sortedTalks.reduce(
|
||||
(acc, talk) => {
|
||||
const speaker = talk.data.speaker;
|
||||
if (!acc[speaker]) {
|
||||
acc[speaker] = [];
|
||||
}
|
||||
acc[speaker].push(talk);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof talks>
|
||||
);
|
||||
---
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-section">
|
||||
<h1 class="logo">📚 Wisdom Library</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/" class="nav-item">
|
||||
<span class="nav-icon">🏠</span>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="/speakers" class="nav-item">
|
||||
<span class="nav-icon">🎤</span>
|
||||
<span>Speakers</span>
|
||||
</a>
|
||||
<a href="/admin" class="nav-item">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="talks-section">
|
||||
<div class="section-header">
|
||||
<h2>MY TALKS</h2>
|
||||
<button class="add-btn" title="Add new talk">+</button>
|
||||
</div>
|
||||
|
||||
<div class="talks-list">
|
||||
{
|
||||
Object.entries(talksBySpeaker).map(([speaker, speakerTalks]) => (
|
||||
<div class="speaker-group">
|
||||
<div class="speaker-header">
|
||||
<span class="speaker-name">{speaker}</span>
|
||||
<span class="talk-count">{speakerTalks.length}</span>
|
||||
</div>
|
||||
{speakerTalks.map((talk) => {
|
||||
const isActive = currentPath.includes(talk.slug);
|
||||
return (
|
||||
<a href={`/talks/${talk.slug}`} class={`talk-card ${isActive ? 'active' : ''}`}>
|
||||
<div class="talk-title">{talk.data.title}</div>
|
||||
<div class="talk-meta">
|
||||
<span class="talk-tag">{talk.data.category.replace('-', ' ')}</span>
|
||||
<span class="talk-date">
|
||||
{new Date(talk.data.date).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div class="talk-summary">{talk.data.summary.substring(0, 120)}...</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
height: 100vh;
|
||||
background: rgb(var(--theme-card));
|
||||
border-right: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(var(--theme-primary), 0.08);
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.talks-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
color: rgb(var(--theme-primary));
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: rgba(var(--theme-primary), 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.talks-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.talks-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.talks-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.talks-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--theme-primary), 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.talks-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(var(--theme-primary), 0.3);
|
||||
}
|
||||
|
||||
.speaker-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.speaker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.talk-count {
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
color: rgb(var(--theme-primary));
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.talk-card {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
margin: 0.25rem 0.5rem;
|
||||
background: rgba(var(--theme-background), 0.5);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.08);
|
||||
border-radius: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.talk-card:hover {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-color: rgba(var(--theme-primary), 0.15);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.talk-card.active {
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
border-color: rgb(var(--theme-primary));
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
.talk-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text));
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.talk-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.talk-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: rgba(var(--theme-secondary), 0.1);
|
||||
color: rgb(var(--theme-secondary));
|
||||
border-radius: 4px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.talk-date {
|
||||
font-size: 0.7rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
|
||||
.talk-summary {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
line-height: 1.4;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<div class="fixed top-4 right-4 z-50 flex items-center gap-2">
|
||||
<!-- Theme Selector -->
|
||||
<div class="relative">
|
||||
<button
|
||||
id="theme-menu-button"
|
||||
class="glass px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-theme-surface-hover text-theme-text"
|
||||
aria-label="Select theme"
|
||||
>
|
||||
<span id="theme-icon">🌊</span>
|
||||
<span id="theme-name" class="hidden sm:inline">Ocean</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="theme-menu"
|
||||
class="absolute right-0 mt-2 w-48 glass rounded-lg shadow-theme-lg hidden opacity-0 transform scale-95 transition-all duration-200"
|
||||
>
|
||||
<div class="p-2">
|
||||
<button
|
||||
data-theme="ocean"
|
||||
class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text"
|
||||
>
|
||||
<span>🌊</span> Ocean
|
||||
</button>
|
||||
<button
|
||||
data-theme="forest"
|
||||
class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text"
|
||||
>
|
||||
<span>🌲</span> Forest
|
||||
</button>
|
||||
<button
|
||||
data-theme="sunset"
|
||||
class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text"
|
||||
>
|
||||
<span>🌅</span> Sunset
|
||||
</button>
|
||||
<button
|
||||
data-theme="monochrome"
|
||||
class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text"
|
||||
>
|
||||
<span>⚫</span> Monochrome
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button
|
||||
id="dark-toggle"
|
||||
class="glass p-2 rounded-lg hover:bg-theme-surface-hover text-theme-text"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<svg id="sun-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg id="moon-icon" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const themes = {
|
||||
ocean: { icon: '🌊', name: 'Ocean' },
|
||||
forest: { icon: '🌲', name: 'Forest' },
|
||||
sunset: { icon: '🌅', name: 'Sunset' },
|
||||
monochrome: { icon: '⚫', name: 'Monochrome' },
|
||||
};
|
||||
|
||||
class ThemeManager {
|
||||
constructor() {
|
||||
this.currentTheme = 'ocean';
|
||||
this.isDark = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Load saved preferences
|
||||
this.loadPreferences();
|
||||
|
||||
// Apply theme immediately
|
||||
this.applyTheme();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Listen for system theme changes
|
||||
this.watchSystemPreference();
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
// Check localStorage first
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
|
||||
if (savedTheme && themes[savedTheme]) {
|
||||
this.currentTheme = savedTheme;
|
||||
}
|
||||
|
||||
if (savedMode !== null) {
|
||||
this.isDark = savedMode === 'true';
|
||||
} else {
|
||||
// Check system preference if no saved preference
|
||||
this.isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
}
|
||||
|
||||
savePreferences() {
|
||||
localStorage.setItem('theme', this.currentTheme);
|
||||
localStorage.setItem('darkMode', this.isDark.toString());
|
||||
}
|
||||
|
||||
applyTheme() {
|
||||
const html = document.documentElement;
|
||||
|
||||
// Set theme
|
||||
html.setAttribute('data-theme', this.currentTheme);
|
||||
|
||||
// Set dark mode
|
||||
if (this.isDark) {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
// Update theme button
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
const themeName = document.getElementById('theme-name');
|
||||
if (themeIcon && themeName) {
|
||||
themeIcon.textContent = themes[this.currentTheme].icon;
|
||||
themeName.textContent = themes[this.currentTheme].name;
|
||||
}
|
||||
|
||||
// Update dark mode toggle
|
||||
const sunIcon = document.getElementById('sun-icon');
|
||||
const moonIcon = document.getElementById('moon-icon');
|
||||
if (sunIcon && moonIcon) {
|
||||
if (this.isDark) {
|
||||
sunIcon.classList.remove('hidden');
|
||||
moonIcon.classList.add('hidden');
|
||||
} else {
|
||||
sunIcon.classList.add('hidden');
|
||||
moonIcon.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update active theme in menu
|
||||
document.querySelectorAll('.theme-option').forEach((btn) => {
|
||||
const theme = btn.getAttribute('data-theme');
|
||||
if (theme === this.currentTheme) {
|
||||
btn.classList.add('bg-theme-primary/10', 'text-theme-primary');
|
||||
} else {
|
||||
btn.classList.remove('bg-theme-primary/10', 'text-theme-primary');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Theme menu toggle
|
||||
const menuButton = document.getElementById('theme-menu-button');
|
||||
const menu = document.getElementById('theme-menu');
|
||||
|
||||
if (menuButton && menu) {
|
||||
menuButton.addEventListener('click', () => {
|
||||
const isHidden = menu.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
menu.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
menu.classList.remove('opacity-0', 'scale-95');
|
||||
menu.classList.add('opacity-100', 'scale-100');
|
||||
}, 10);
|
||||
} else {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menuButton.contains(e.target) && !menu.contains(e.target)) {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Theme selection
|
||||
document.querySelectorAll('.theme-option').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const theme = btn.getAttribute('data-theme');
|
||||
if (theme && themes[theme]) {
|
||||
this.currentTheme = theme;
|
||||
this.applyTheme();
|
||||
this.savePreferences();
|
||||
|
||||
// Close menu
|
||||
const menu = document.getElementById('theme-menu');
|
||||
if (menu) {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dark mode toggle
|
||||
const darkToggle = document.getElementById('dark-toggle');
|
||||
if (darkToggle) {
|
||||
darkToggle.addEventListener('click', () => {
|
||||
this.isDark = !this.isDark;
|
||||
this.applyTheme();
|
||||
this.savePreferences();
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Alt/Option + T: Open theme menu
|
||||
if (e.altKey && e.key === 't') {
|
||||
e.preventDefault();
|
||||
menuButton?.click();
|
||||
}
|
||||
|
||||
// Alt/Option + D: Toggle dark mode
|
||||
if (e.altKey && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
darkToggle?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watchSystemPreference() {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
// Only apply if user hasn't set a preference
|
||||
if (localStorage.getItem('darkMode') === null) {
|
||||
this.isDark = e.matches;
|
||||
this.applyTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme manager when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => new ThemeManager());
|
||||
} else {
|
||||
new ThemeManager();
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue