mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 22:59:40 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
385
apps/clock/CLAUDE.md
Normal file
385
apps/clock/CLAUDE.md
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
# Clock Project Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
**Clock** ist eine vollständige Uhren-App mit Weltzeituhr, Wecker, Timer, Stoppuhr und Pomodoro. Die App synchronisiert Wecker und Timer zwischen Geräten über ein Backend.
|
||||
|
||||
| App | Port | URL |
|
||||
|-----|------|-----|
|
||||
| Backend | 3017 | http://localhost:3017 |
|
||||
| Web App | 5187 | http://localhost:5187 |
|
||||
| Landing Page | 4323 | http://localhost:4323 |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/clock/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API server (@clock/backend)
|
||||
│ │ └── src/
|
||||
│ │ ├── main.ts
|
||||
│ │ ├── app.module.ts
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── database.module.ts
|
||||
│ │ │ ├── connection.ts
|
||||
│ │ │ ├── schema/
|
||||
│ │ │ │ ├── alarms.schema.ts
|
||||
│ │ │ │ ├── timers.schema.ts
|
||||
│ │ │ │ ├── world-clocks.schema.ts
|
||||
│ │ │ │ └── presets.schema.ts
|
||||
│ │ │ └── seed.ts
|
||||
│ │ ├── alarm/
|
||||
│ │ ├── timer/
|
||||
│ │ ├── world-clock/
|
||||
│ │ ├── preset/
|
||||
│ │ └── health/
|
||||
│ │
|
||||
│ ├── web/ # SvelteKit web app (@clock/web)
|
||||
│ │ └── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── api/
|
||||
│ │ │ ├── stores/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ └── i18n/
|
||||
│ │ └── routes/
|
||||
│ │ ├── +layout.svelte
|
||||
│ │ ├── +page.svelte # Dashboard
|
||||
│ │ ├── alarms/
|
||||
│ │ ├── timers/
|
||||
│ │ ├── stopwatch/
|
||||
│ │ ├── pomodoro/
|
||||
│ │ ├── world-clock/
|
||||
│ │ ├── settings/
|
||||
│ │ ├── feedback/
|
||||
│ │ └── (auth)/
|
||||
│ │
|
||||
│ └── landing/ # Astro landing page (@clock/landing)
|
||||
│
|
||||
├── packages/
|
||||
│ └── shared/ # Shared types & constants (@clock/shared)
|
||||
│
|
||||
├── package.json
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Root Level (from monorepo root)
|
||||
|
||||
```bash
|
||||
# Alle Apps starten
|
||||
pnpm clock:dev # Run all clock apps
|
||||
|
||||
# Einzelne Apps starten
|
||||
pnpm dev:clock:backend # Start backend server (port 3017)
|
||||
pnpm dev:clock:web # Start web app (port 5187)
|
||||
pnpm dev:clock:landing # Start landing page (port 4323)
|
||||
pnpm dev:clock:app # Start web + backend together
|
||||
|
||||
# Datenbank
|
||||
pnpm clock:db:push # Push schema to database
|
||||
pnpm clock:db:studio # Open Drizzle Studio
|
||||
pnpm clock:db:seed # Seed initial data
|
||||
|
||||
# Deploy
|
||||
pnpm deploy:landing:clock # Deploy landing to Cloudflare Pages
|
||||
```
|
||||
|
||||
### Backend (apps/clock/apps/backend)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm build # Build for production
|
||||
pnpm start:prod # Start production server
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
pnpm db:seed # Seed initial data
|
||||
```
|
||||
|
||||
### Web App (apps/clock/apps/web)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview production build
|
||||
```
|
||||
|
||||
### Landing Page (apps/clock/apps/landing)
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (port 4323)
|
||||
pnpm build # Build for production
|
||||
pnpm preview # Preview build
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Backend** | NestJS 10, Drizzle ORM, PostgreSQL |
|
||||
| **Web** | SvelteKit 2.x, Svelte 5 (runes), Tailwind CSS 4 |
|
||||
| **Landing** | Astro 5.x, Tailwind CSS |
|
||||
| **Auth** | Mana Core Auth (JWT) |
|
||||
| **i18n** | svelte-i18n (DE, EN, FR, ES, IT) |
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Weltzeituhr
|
||||
- Zeitzonen-Übersicht mit mehreren Städten
|
||||
- Zeitdifferenz zum lokalen Standort
|
||||
- Tag/Nacht-Indikator
|
||||
- Drag & Drop Sortierung
|
||||
|
||||
### 2. Wecker
|
||||
- Erstelle/Bearbeite/Lösche Wecker
|
||||
- Wiederholende Wecker (Wochentage wählen)
|
||||
- Snooze-Funktion (konfigurierbar)
|
||||
- Verschiedene Alarm-Sounds
|
||||
- Cross-Device Sync
|
||||
|
||||
### 3. Timer
|
||||
- Multiple Timer gleichzeitig
|
||||
- Timer-Presets (Quick Select)
|
||||
- Cross-Device Sync für aktive Timer
|
||||
- Start/Pause/Reset Kontrollen
|
||||
|
||||
### 4. Stoppuhr
|
||||
- Präzise Zeitmessung
|
||||
- Rundenzeiten mit Best/Worst Markierung
|
||||
- Lokal-only (kein Sync nötig)
|
||||
|
||||
### 5. Pomodoro
|
||||
- Arbeit/Pause Zyklen
|
||||
- Anpassbare Intervalle
|
||||
- Preset-Auswahl (Klassisch, Kurzer Fokus, Tiefe Arbeit)
|
||||
- Push-Benachrichtigungen
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health
|
||||
```
|
||||
GET /api/v1/health # Health check
|
||||
```
|
||||
|
||||
### Alarms
|
||||
```
|
||||
GET /api/v1/alarms # List all alarms
|
||||
POST /api/v1/alarms # Create alarm
|
||||
GET /api/v1/alarms/:id # Get alarm
|
||||
PUT /api/v1/alarms/:id # Update alarm
|
||||
DELETE /api/v1/alarms/:id # Delete alarm
|
||||
PATCH /api/v1/alarms/:id/toggle # Toggle enabled
|
||||
```
|
||||
|
||||
### Timers
|
||||
```
|
||||
GET /api/v1/timers # List all timers
|
||||
POST /api/v1/timers # Create timer
|
||||
GET /api/v1/timers/:id # Get timer
|
||||
PUT /api/v1/timers/:id # Update timer
|
||||
DELETE /api/v1/timers/:id # Delete timer
|
||||
POST /api/v1/timers/:id/start # Start timer
|
||||
POST /api/v1/timers/:id/pause # Pause timer
|
||||
POST /api/v1/timers/:id/reset # Reset timer
|
||||
```
|
||||
|
||||
### World Clocks
|
||||
```
|
||||
GET /api/v1/world-clocks # List world clocks
|
||||
POST /api/v1/world-clocks # Add city
|
||||
DELETE /api/v1/world-clocks/:id # Remove city
|
||||
PUT /api/v1/world-clocks/reorder # Reorder cities
|
||||
GET /api/v1/timezones/search # Search timezones
|
||||
```
|
||||
|
||||
### Presets
|
||||
```
|
||||
GET /api/v1/presets # List presets
|
||||
POST /api/v1/presets # Create preset
|
||||
PUT /api/v1/presets/:id # Update preset
|
||||
DELETE /api/v1/presets/:id # Delete preset
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### alarms
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | UUID | Owner |
|
||||
| `label` | VARCHAR(255) | Alarm name |
|
||||
| `time` | TIME | Alarm time (HH:MM:SS) |
|
||||
| `enabled` | BOOLEAN | Active flag |
|
||||
| `repeat_days` | INT[] | [0-6] für Wochentage |
|
||||
| `snooze_minutes` | INTEGER | Snooze duration |
|
||||
| `sound` | VARCHAR(100) | Sound identifier |
|
||||
| `vibrate` | BOOLEAN | Vibration enabled |
|
||||
|
||||
### timers
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | UUID | Owner |
|
||||
| `label` | VARCHAR(255) | Timer name |
|
||||
| `duration_seconds` | INTEGER | Total duration |
|
||||
| `remaining_seconds` | INTEGER | Time left |
|
||||
| `status` | VARCHAR(20) | idle/running/paused/finished |
|
||||
| `sound` | VARCHAR(100) | Sound identifier |
|
||||
|
||||
### world_clocks
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | UUID | Owner |
|
||||
| `timezone` | VARCHAR(100) | IANA timezone |
|
||||
| `city_name` | VARCHAR(255) | Display name |
|
||||
| `sort_order` | INTEGER | Display order |
|
||||
|
||||
### presets
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `user_id` | UUID | Owner |
|
||||
| `type` | VARCHAR(20) | timer/pomodoro |
|
||||
| `name` | VARCHAR(255) | Preset name |
|
||||
| `duration_seconds` | INTEGER | Duration |
|
||||
| `settings` | JSONB | Type-specific settings |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
```env
|
||||
NODE_ENV=development
|
||||
PORT=3017
|
||||
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/clock
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5186,http://localhost:8081
|
||||
DEV_BYPASS_AUTH=true
|
||||
DEV_USER_ID=your-test-user-id
|
||||
```
|
||||
|
||||
### Web (.env)
|
||||
```env
|
||||
PUBLIC_BACKEND_URL=http://localhost:3017
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Web App Stores (Svelte 5 Runes)
|
||||
|
||||
```typescript
|
||||
// auth.svelte.ts - Authentication
|
||||
authStore.isAuthenticated
|
||||
authStore.user
|
||||
authStore.signIn(email, password)
|
||||
authStore.signOut()
|
||||
authStore.getAccessToken()
|
||||
|
||||
// alarms.svelte.ts - Alarms
|
||||
alarmsStore.alarms
|
||||
alarmsStore.nextAlarm
|
||||
alarmsStore.fetchAlarms()
|
||||
alarmsStore.createAlarm(input)
|
||||
alarmsStore.toggleAlarm(id)
|
||||
|
||||
// timers.svelte.ts - Timers
|
||||
timersStore.timers
|
||||
timersStore.activeTimers
|
||||
timersStore.startTimer(id)
|
||||
timersStore.pauseTimer(id)
|
||||
timersStore.resetTimer(id)
|
||||
|
||||
// stopwatch.svelte.ts - Stopwatch (local only)
|
||||
stopwatchStore.isRunning
|
||||
stopwatchStore.elapsedTime
|
||||
stopwatchStore.laps
|
||||
stopwatchStore.start()
|
||||
stopwatchStore.lap()
|
||||
stopwatchStore.reset()
|
||||
|
||||
// pomodoro.svelte.ts - Pomodoro (local only)
|
||||
pomodoroStore.phase
|
||||
pomodoroStore.remainingTime
|
||||
pomodoroStore.completedSessions
|
||||
pomodoroStore.start()
|
||||
pomodoroStore.skip()
|
||||
pomodoroStore.loadPreset(preset)
|
||||
|
||||
// world-clocks.svelte.ts - World Clocks
|
||||
worldClocksStore.worldClocks
|
||||
worldClocksStore.addWorldClock(input)
|
||||
worldClocksStore.removeWorldClock(id)
|
||||
worldClocksStore.reorderWorldClocks(ids)
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Datenbank erstellen
|
||||
|
||||
```bash
|
||||
# PostgreSQL Container muss laufen
|
||||
docker compose -f docker-compose.dev.yml up -d postgres
|
||||
|
||||
# Datenbank erstellen
|
||||
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE clock;"
|
||||
|
||||
# Schema pushen
|
||||
pnpm clock:db:push
|
||||
```
|
||||
|
||||
### 2. Apps starten
|
||||
|
||||
```bash
|
||||
# Backend + Web zusammen
|
||||
pnpm dev:clock:app
|
||||
|
||||
# Oder einzeln:
|
||||
pnpm dev:clock:backend # Terminal 1
|
||||
pnpm dev:clock:web # Terminal 2
|
||||
pnpm dev:clock:landing # Terminal 3 (optional)
|
||||
```
|
||||
|
||||
### 3. URLs öffnen
|
||||
|
||||
- Web App: http://localhost:5187
|
||||
- Landing: http://localhost:4323
|
||||
- API Health: http://localhost:3017/api/v1/health
|
||||
|
||||
## Testing API (mit curl)
|
||||
|
||||
```bash
|
||||
# Health Check
|
||||
curl http://localhost:3017/api/v1/health
|
||||
|
||||
# Login (get token)
|
||||
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "test@example.com", "password": "password"}' | jq -r '.accessToken')
|
||||
|
||||
# Wecker abrufen
|
||||
curl http://localhost:3017/api/v1/alarms \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Neuen Wecker erstellen
|
||||
curl -X POST http://localhost:3017/api/v1/alarms \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"time": "07:00:00", "label": "Aufwachen"}'
|
||||
|
||||
# Timer erstellen und starten
|
||||
TIMER_ID=$(curl -s -X POST http://localhost:3017/api/v1/timers \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"durationSeconds": 300, "label": "5 Minuten"}' | jq -r '.id')
|
||||
|
||||
curl -X POST http://localhost:3017/api/v1/timers/$TIMER_ID/start \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Authentication**: Nutzt Mana Core Auth (JWT im Authorization Header)
|
||||
2. **Database**: PostgreSQL mit Drizzle ORM (Port 5432)
|
||||
3. **Port**: Backend läuft auf Port 3017, Web auf 5187, Landing auf 4323
|
||||
4. **i18n**: 5 Sprachen unterstützt (DE, EN, FR, ES, IT)
|
||||
5. **Theme**: Amber/Orange (#f59e0b) als Primärfarbe
|
||||
6. **Local Features**: Stoppuhr und Pomodoro laufen lokal ohne Backend-Sync
|
||||
12
apps/clock/apps/backend/drizzle.config.ts
Normal file
12
apps/clock/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/clock',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
10
apps/clock/apps/backend/nest-cli.json
Normal file
10
apps/clock/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
55
apps/clock/apps/backend/package.json
Normal file
55
apps/clock/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "@clock/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clock/shared": "workspace:*",
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
45
apps/clock/apps/backend/src/alarm/alarm.controller.ts
Normal file
45
apps/clock/apps/backend/src/alarm/alarm.controller.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Controller, Get, Post, Put, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { AlarmService } from './alarm.service';
|
||||
import { CreateAlarmDto, UpdateAlarmDto } from './dto';
|
||||
|
||||
@Controller('alarms')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AlarmController {
|
||||
constructor(private readonly alarmService: AlarmService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.alarmService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.alarmService.findByIdOrThrow(id, user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAlarmDto) {
|
||||
return this.alarmService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAlarmDto
|
||||
) {
|
||||
return this.alarmService.update(id, user.userId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/toggle')
|
||||
async toggle(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.alarmService.toggle(id, user.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.alarmService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/clock/apps/backend/src/alarm/alarm.module.ts
Normal file
10
apps/clock/apps/backend/src/alarm/alarm.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AlarmController } from './alarm.controller';
|
||||
import { AlarmService } from './alarm.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AlarmController],
|
||||
providers: [AlarmService],
|
||||
exports: [AlarmService],
|
||||
})
|
||||
export class AlarmModule {}
|
||||
82
apps/clock/apps/backend/src/alarm/alarm.service.ts
Normal file
82
apps/clock/apps/backend/src/alarm/alarm.service.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { alarms, type Alarm } from '../db/schema';
|
||||
import { CreateAlarmDto, UpdateAlarmDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class AlarmService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Alarm[]> {
|
||||
return this.db.select().from(alarms).where(eq(alarms.userId, userId));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Alarm | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(alarms)
|
||||
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Alarm> {
|
||||
const alarm = await this.findById(id, userId);
|
||||
if (!alarm) {
|
||||
throw new NotFoundException(`Alarm with id ${id} not found`);
|
||||
}
|
||||
return alarm;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateAlarmDto): Promise<Alarm> {
|
||||
const result = await this.db
|
||||
.insert(alarms)
|
||||
.values({
|
||||
userId,
|
||||
label: dto.label,
|
||||
time: dto.time,
|
||||
enabled: dto.enabled ?? true,
|
||||
repeatDays: dto.repeatDays,
|
||||
snoozeMinutes: dto.snoozeMinutes ?? 5,
|
||||
sound: dto.sound ?? 'default',
|
||||
vibrate: dto.vibrate ?? true,
|
||||
})
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateAlarmDto): Promise<Alarm> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const result = await this.db
|
||||
.update(alarms)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async toggle(id: string, userId: string): Promise<Alarm> {
|
||||
const alarm = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const result = await this.db
|
||||
.update(alarms)
|
||||
.set({
|
||||
enabled: !alarm.enabled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(alarms.id, id), eq(alarms.userId, userId)))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
await this.db.delete(alarms).where(and(eq(alarms.id, id), eq(alarms.userId, userId)));
|
||||
}
|
||||
}
|
||||
85
apps/clock/apps/backend/src/alarm/dto/index.ts
Normal file
85
apps/clock/apps/backend/src/alarm/dto/index.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
IsNumber,
|
||||
Min,
|
||||
Max,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAlarmDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, {
|
||||
message: 'time must be in HH:MM:SS format',
|
||||
})
|
||||
time!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(6, { each: true })
|
||||
repeatDays?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(60)
|
||||
snoozeMinutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sound?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
vibrate?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateAlarmDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/, {
|
||||
message: 'time must be in HH:MM:SS format',
|
||||
})
|
||||
time?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsNumber({}, { each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(6, { each: true })
|
||||
repeatDays?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(60)
|
||||
snoozeMinutes?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sound?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
vibrate?: boolean;
|
||||
}
|
||||
26
apps/clock/apps/backend/src/app.module.ts
Normal file
26
apps/clock/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AlarmModule } from './alarm/alarm.module';
|
||||
import { TimerModule } from './timer/timer.module';
|
||||
import { WorldClockModule } from './world-clock/world-clock.module';
|
||||
import { PresetModule } from './preset/preset.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
AlarmModule,
|
||||
TimerModule,
|
||||
WorldClockModule,
|
||||
PresetModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
apps/clock/apps/backend/src/db/connection.ts
Normal file
38
apps/clock/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
28
apps/clock/apps/backend/src/db/database.module.ts
Normal file
28
apps/clock/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection, type Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
18
apps/clock/apps/backend/src/db/schema/alarms.schema.ts
Normal file
18
apps/clock/apps/backend/src/db/schema/alarms.schema.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { pgTable, uuid, varchar, time, boolean, integer, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const alarms = pgTable('alarms', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
label: varchar('label', { length: 255 }),
|
||||
time: time('time').notNull(),
|
||||
enabled: boolean('enabled').default(true).notNull(),
|
||||
repeatDays: integer('repeat_days').array(), // [0-6] for weekdays (0=Sun)
|
||||
snoozeMinutes: integer('snooze_minutes').default(5),
|
||||
sound: varchar('sound', { length: 100 }).default('default'),
|
||||
vibrate: boolean('vibrate').default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Alarm = typeof alarms.$inferSelect;
|
||||
export type NewAlarm = typeof alarms.$inferInsert;
|
||||
4
apps/clock/apps/backend/src/db/schema/index.ts
Normal file
4
apps/clock/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './alarms.schema';
|
||||
export * from './timers.schema';
|
||||
export * from './world-clocks.schema';
|
||||
export * from './presets.schema';
|
||||
24
apps/clock/apps/backend/src/db/schema/presets.schema.ts
Normal file
24
apps/clock/apps/backend/src/db/schema/presets.schema.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { pgTable, uuid, varchar, integer, jsonb, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export interface PresetSettings {
|
||||
// For pomodoro presets
|
||||
workDuration?: number; // in seconds
|
||||
breakDuration?: number; // in seconds
|
||||
longBreakDuration?: number; // in seconds
|
||||
sessionsBeforeLongBreak?: number;
|
||||
// For timer presets
|
||||
sound?: string;
|
||||
}
|
||||
|
||||
export const presets = pgTable('presets', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
type: varchar('type', { length: 20 }).notNull(), // 'timer' | 'pomodoro'
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
durationSeconds: integer('duration_seconds').notNull(),
|
||||
settings: jsonb('settings').$type<PresetSettings>(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Preset = typeof presets.$inferSelect;
|
||||
export type NewPreset = typeof presets.$inferInsert;
|
||||
18
apps/clock/apps/backend/src/db/schema/timers.schema.ts
Normal file
18
apps/clock/apps/backend/src/db/schema/timers.schema.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const timers = pgTable('timers', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
label: varchar('label', { length: 255 }),
|
||||
durationSeconds: integer('duration_seconds').notNull(),
|
||||
remainingSeconds: integer('remaining_seconds'),
|
||||
status: varchar('status', { length: 20 }).default('idle').notNull(), // idle, running, paused, finished
|
||||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||
pausedAt: timestamp('paused_at', { withTimezone: true }),
|
||||
sound: varchar('sound', { length: 100 }).default('default'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Timer = typeof timers.$inferSelect;
|
||||
export type NewTimer = typeof timers.$inferInsert;
|
||||
13
apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts
Normal file
13
apps/clock/apps/backend/src/db/schema/world-clocks.schema.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { pgTable, uuid, varchar, integer, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const worldClocks = pgTable('world_clocks', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull(),
|
||||
timezone: varchar('timezone', { length: 100 }).notNull(), // IANA timezone e.g. 'America/New_York'
|
||||
cityName: varchar('city_name', { length: 255 }).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type WorldClock = typeof worldClocks.$inferSelect;
|
||||
export type NewWorldClock = typeof worldClocks.$inferInsert;
|
||||
13
apps/clock/apps/backend/src/health/health.controller.ts
Normal file
13
apps/clock/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'clock-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/clock/apps/backend/src/health/health.module.ts
Normal file
7
apps/clock/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
40
apps/clock/apps/backend/src/main.ts
Normal file
40
apps/clock/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5186',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
'http://localhost:3001',
|
||||
];
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const port = process.env.PORT || 3017;
|
||||
await app.listen(port);
|
||||
console.log(`Clock backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
65
apps/clock/apps/backend/src/preset/dto/index.ts
Normal file
65
apps/clock/apps/backend/src/preset/dto/index.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { IsString, IsOptional, IsNumber, Min, Max, IsIn, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class PresetSettingsDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
workDuration?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
breakDuration?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
longBreakDuration?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
sessionsBeforeLongBreak?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sound?: string;
|
||||
}
|
||||
|
||||
export class CreatePresetDto {
|
||||
@IsString()
|
||||
@IsIn(['timer', 'pomodoro'])
|
||||
type!: string;
|
||||
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(86400)
|
||||
durationSeconds!: number;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => PresetSettingsDto)
|
||||
settings?: PresetSettingsDto;
|
||||
}
|
||||
|
||||
export class UpdatePresetDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(86400)
|
||||
durationSeconds?: number;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => PresetSettingsDto)
|
||||
settings?: PresetSettingsDto;
|
||||
}
|
||||
40
apps/clock/apps/backend/src/preset/preset.controller.ts
Normal file
40
apps/clock/apps/backend/src/preset/preset.controller.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { PresetService } from './preset.service';
|
||||
import { CreatePresetDto, UpdatePresetDto } from './dto';
|
||||
|
||||
@Controller('presets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PresetController {
|
||||
constructor(private readonly presetService: PresetService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.presetService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.presetService.findByIdOrThrow(id, user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreatePresetDto) {
|
||||
return this.presetService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePresetDto
|
||||
) {
|
||||
return this.presetService.update(id, user.userId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.presetService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/clock/apps/backend/src/preset/preset.module.ts
Normal file
10
apps/clock/apps/backend/src/preset/preset.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PresetController } from './preset.controller';
|
||||
import { PresetService } from './preset.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PresetController],
|
||||
providers: [PresetService],
|
||||
exports: [PresetService],
|
||||
})
|
||||
export class PresetModule {}
|
||||
65
apps/clock/apps/backend/src/preset/preset.service.ts
Normal file
65
apps/clock/apps/backend/src/preset/preset.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { presets, type Preset, type PresetSettings } from '../db/schema';
|
||||
import { CreatePresetDto, UpdatePresetDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class PresetService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Preset[]> {
|
||||
return this.db.select().from(presets).where(eq(presets.userId, userId));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Preset | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(presets)
|
||||
.where(and(eq(presets.id, id), eq(presets.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Preset> {
|
||||
const preset = await this.findById(id, userId);
|
||||
if (!preset) {
|
||||
throw new NotFoundException(`Preset with id ${id} not found`);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreatePresetDto): Promise<Preset> {
|
||||
const result = await this.db
|
||||
.insert(presets)
|
||||
.values({
|
||||
userId,
|
||||
type: dto.type,
|
||||
name: dto.name,
|
||||
durationSeconds: dto.durationSeconds,
|
||||
settings: dto.settings as PresetSettings,
|
||||
})
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdatePresetDto): Promise<Preset> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const result = await this.db
|
||||
.update(presets)
|
||||
.set({
|
||||
...dto,
|
||||
settings: dto.settings as PresetSettings,
|
||||
})
|
||||
.where(and(eq(presets.id, id), eq(presets.userId, userId)))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
await this.db.delete(presets).where(and(eq(presets.id, id), eq(presets.userId, userId)));
|
||||
}
|
||||
}
|
||||
32
apps/clock/apps/backend/src/timer/dto/index.ts
Normal file
32
apps/clock/apps/backend/src/timer/dto/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { IsString, IsOptional, IsNumber, Min, Max } from 'class-validator';
|
||||
|
||||
export class CreateTimerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(86400) // Max 24 hours
|
||||
durationSeconds!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sound?: string;
|
||||
}
|
||||
|
||||
export class UpdateTimerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(86400)
|
||||
durationSeconds?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sound?: string;
|
||||
}
|
||||
55
apps/clock/apps/backend/src/timer/timer.controller.ts
Normal file
55
apps/clock/apps/backend/src/timer/timer.controller.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { TimerService } from './timer.service';
|
||||
import { CreateTimerDto, UpdateTimerDto } from './dto';
|
||||
|
||||
@Controller('timers')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TimerController {
|
||||
constructor(private readonly timerService: TimerService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.timerService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.timerService.findByIdOrThrow(id, user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTimerDto) {
|
||||
return this.timerService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTimerDto
|
||||
) {
|
||||
return this.timerService.update(id, user.userId, dto);
|
||||
}
|
||||
|
||||
@Post(':id/start')
|
||||
async start(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.timerService.start(id, user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/pause')
|
||||
async pause(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.timerService.pause(id, user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/reset')
|
||||
async reset(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.timerService.reset(id, user.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.timerService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps/clock/apps/backend/src/timer/timer.module.ts
Normal file
10
apps/clock/apps/backend/src/timer/timer.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TimerController } from './timer.controller';
|
||||
import { TimerService } from './timer.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TimerController],
|
||||
providers: [TimerService],
|
||||
exports: [TimerService],
|
||||
})
|
||||
export class TimerModule {}
|
||||
129
apps/clock/apps/backend/src/timer/timer.service.ts
Normal file
129
apps/clock/apps/backend/src/timer/timer.service.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { timers, type Timer } from '../db/schema';
|
||||
import { CreateTimerDto, UpdateTimerDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class TimerService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Timer[]> {
|
||||
return this.db.select().from(timers).where(eq(timers.userId, userId));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Timer | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(timers)
|
||||
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Timer> {
|
||||
const timer = await this.findById(id, userId);
|
||||
if (!timer) {
|
||||
throw new NotFoundException(`Timer with id ${id} not found`);
|
||||
}
|
||||
return timer;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateTimerDto): Promise<Timer> {
|
||||
const result = await this.db
|
||||
.insert(timers)
|
||||
.values({
|
||||
userId,
|
||||
label: dto.label,
|
||||
durationSeconds: dto.durationSeconds,
|
||||
remainingSeconds: dto.durationSeconds,
|
||||
status: 'idle',
|
||||
sound: dto.sound ?? 'default',
|
||||
})
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateTimerDto): Promise<Timer> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const result = await this.db
|
||||
.update(timers)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async start(id: string, userId: string): Promise<Timer> {
|
||||
const timer = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
if (timer.status === 'running') {
|
||||
throw new BadRequestException('Timer is already running');
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(timers)
|
||||
.set({
|
||||
status: 'running',
|
||||
startedAt: new Date(),
|
||||
pausedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async pause(id: string, userId: string): Promise<Timer> {
|
||||
const timer = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
if (timer.status !== 'running') {
|
||||
throw new BadRequestException('Timer is not running');
|
||||
}
|
||||
|
||||
// Calculate remaining seconds
|
||||
const elapsed = timer.startedAt
|
||||
? Math.floor((Date.now() - timer.startedAt.getTime()) / 1000)
|
||||
: 0;
|
||||
const remaining = Math.max(0, (timer.remainingSeconds ?? timer.durationSeconds) - elapsed);
|
||||
|
||||
const result = await this.db
|
||||
.update(timers)
|
||||
.set({
|
||||
status: 'paused',
|
||||
remainingSeconds: remaining,
|
||||
pausedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async reset(id: string, userId: string): Promise<Timer> {
|
||||
const timer = await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const result = await this.db
|
||||
.update(timers)
|
||||
.set({
|
||||
status: 'idle',
|
||||
remainingSeconds: timer.durationSeconds,
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(timers.id, id), eq(timers.userId, userId)))
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
await this.db.delete(timers).where(and(eq(timers.id, id), eq(timers.userId, userId)));
|
||||
}
|
||||
}
|
||||
15
apps/clock/apps/backend/src/world-clock/dto/index.ts
Normal file
15
apps/clock/apps/backend/src/world-clock/dto/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { IsString, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateWorldClockDto {
|
||||
@IsString()
|
||||
timezone!: string;
|
||||
|
||||
@IsString()
|
||||
cityName!: string;
|
||||
}
|
||||
|
||||
export class ReorderWorldClocksDto {
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { WorldClockService } from './world-clock.service';
|
||||
import { CreateWorldClockDto, ReorderWorldClocksDto } from './dto';
|
||||
|
||||
@Controller('world-clocks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WorldClockController {
|
||||
constructor(private readonly worldClockService: WorldClockService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.worldClockService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateWorldClockDto) {
|
||||
return this.worldClockService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
async reorder(@CurrentUser() user: CurrentUserData, @Body() dto: ReorderWorldClocksDto) {
|
||||
return this.worldClockService.reorder(user.userId, dto.ids);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.worldClockService.delete(id, user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('timezones')
|
||||
export class TimezoneController {
|
||||
constructor(private readonly worldClockService: WorldClockService) {}
|
||||
|
||||
@Get('search')
|
||||
async search(@Query('q') query: string) {
|
||||
return this.worldClockService.searchTimezones(query);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { WorldClockController, TimezoneController } from './world-clock.controller';
|
||||
import { WorldClockService } from './world-clock.service';
|
||||
|
||||
@Module({
|
||||
controllers: [WorldClockController, TimezoneController],
|
||||
providers: [WorldClockService],
|
||||
exports: [WorldClockService],
|
||||
})
|
||||
export class WorldClockModule {}
|
||||
122
apps/clock/apps/backend/src/world-clock/world-clock.service.ts
Normal file
122
apps/clock/apps/backend/src/world-clock/world-clock.service.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { type Database } from '../db/connection';
|
||||
import { worldClocks, type WorldClock } from '../db/schema';
|
||||
import { CreateWorldClockDto } from './dto';
|
||||
|
||||
// Common timezones with city names
|
||||
const TIMEZONES = [
|
||||
{ timezone: 'America/New_York', city: 'New York' },
|
||||
{ timezone: 'America/Los_Angeles', city: 'Los Angeles' },
|
||||
{ timezone: 'America/Chicago', city: 'Chicago' },
|
||||
{ timezone: 'America/Denver', city: 'Denver' },
|
||||
{ timezone: 'America/Toronto', city: 'Toronto' },
|
||||
{ timezone: 'America/Vancouver', city: 'Vancouver' },
|
||||
{ timezone: 'America/Mexico_City', city: 'Mexico City' },
|
||||
{ timezone: 'America/Sao_Paulo', city: 'São Paulo' },
|
||||
{ timezone: 'America/Buenos_Aires', city: 'Buenos Aires' },
|
||||
{ timezone: 'Europe/London', city: 'London' },
|
||||
{ timezone: 'Europe/Paris', city: 'Paris' },
|
||||
{ timezone: 'Europe/Berlin', city: 'Berlin' },
|
||||
{ timezone: 'Europe/Rome', city: 'Rome' },
|
||||
{ timezone: 'Europe/Madrid', city: 'Madrid' },
|
||||
{ timezone: 'Europe/Amsterdam', city: 'Amsterdam' },
|
||||
{ timezone: 'Europe/Vienna', city: 'Vienna' },
|
||||
{ timezone: 'Europe/Zurich', city: 'Zurich' },
|
||||
{ timezone: 'Europe/Moscow', city: 'Moscow' },
|
||||
{ timezone: 'Europe/Istanbul', city: 'Istanbul' },
|
||||
{ timezone: 'Asia/Tokyo', city: 'Tokyo' },
|
||||
{ timezone: 'Asia/Shanghai', city: 'Shanghai' },
|
||||
{ timezone: 'Asia/Hong_Kong', city: 'Hong Kong' },
|
||||
{ timezone: 'Asia/Singapore', city: 'Singapore' },
|
||||
{ timezone: 'Asia/Seoul', city: 'Seoul' },
|
||||
{ timezone: 'Asia/Mumbai', city: 'Mumbai' },
|
||||
{ timezone: 'Asia/Dubai', city: 'Dubai' },
|
||||
{ timezone: 'Asia/Bangkok', city: 'Bangkok' },
|
||||
{ timezone: 'Asia/Jakarta', city: 'Jakarta' },
|
||||
{ timezone: 'Australia/Sydney', city: 'Sydney' },
|
||||
{ timezone: 'Australia/Melbourne', city: 'Melbourne' },
|
||||
{ timezone: 'Pacific/Auckland', city: 'Auckland' },
|
||||
{ timezone: 'Pacific/Honolulu', city: 'Honolulu' },
|
||||
{ timezone: 'Africa/Cairo', city: 'Cairo' },
|
||||
{ timezone: 'Africa/Johannesburg', city: 'Johannesburg' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class WorldClockService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<WorldClock[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(worldClocks)
|
||||
.where(eq(worldClocks.userId, userId))
|
||||
.orderBy(asc(worldClocks.sortOrder));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<WorldClock | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(worldClocks)
|
||||
.where(and(eq(worldClocks.id, id), eq(worldClocks.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<WorldClock> {
|
||||
const clock = await this.findById(id, userId);
|
||||
if (!clock) {
|
||||
throw new NotFoundException(`World clock with id ${id} not found`);
|
||||
}
|
||||
return clock;
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateWorldClockDto): Promise<WorldClock> {
|
||||
// Get the max sort order for this user
|
||||
const existing = await this.findAll(userId);
|
||||
const maxOrder = existing.length > 0 ? Math.max(...existing.map((c) => c.sortOrder)) : -1;
|
||||
|
||||
const result = await this.db
|
||||
.insert(worldClocks)
|
||||
.values({
|
||||
userId,
|
||||
timezone: dto.timezone,
|
||||
cityName: dto.cityName,
|
||||
sortOrder: maxOrder + 1,
|
||||
})
|
||||
.returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async reorder(userId: string, ids: string[]): Promise<WorldClock[]> {
|
||||
// Update sort order for each world clock
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await this.db
|
||||
.update(worldClocks)
|
||||
.set({ sortOrder: i })
|
||||
.where(and(eq(worldClocks.id, ids[i]), eq(worldClocks.userId, userId)));
|
||||
}
|
||||
|
||||
return this.findAll(userId);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
await this.db
|
||||
.delete(worldClocks)
|
||||
.where(and(eq(worldClocks.id, id), eq(worldClocks.userId, userId)));
|
||||
}
|
||||
|
||||
searchTimezones(query: string): { timezone: string; city: string }[] {
|
||||
if (!query || query.length < 2) {
|
||||
return TIMEZONES.slice(0, 10);
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return TIMEZONES.filter(
|
||||
(tz) =>
|
||||
tz.city.toLowerCase().includes(lowerQuery) || tz.timezone.toLowerCase().includes(lowerQuery)
|
||||
).slice(0, 20);
|
||||
}
|
||||
}
|
||||
25
apps/clock/apps/backend/tsconfig.json
Normal file
25
apps/clock/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
19
apps/clock/apps/landing/astro.config.mjs
Normal file
19
apps/clock/apps/landing/astro.config.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
35
apps/clock/apps/landing/package.json
Normal file
35
apps/clock/apps/landing/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@clock/landing",
|
||||
"version": "1.0.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",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
37
apps/clock/apps/landing/src/components/CTA.astro
Normal file
37
apps/clock/apps/landing/src/components/CTA.astro
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
// CTA section for Clock landing page
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden">
|
||||
<!-- Background gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-primary-950/50 via-dark-bg to-primary-950/50">
|
||||
</div>
|
||||
|
||||
<div class="container relative">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<h2 class="mb-6 text-3xl font-bold md:text-5xl">Bereit, deine Zeit zu meistern?</h2>
|
||||
<p class="mb-10 text-lg text-gray-400">
|
||||
Starte jetzt kostenlos und entdecke, wie Clock dein Zeitmanagement revolutioniert. Keine
|
||||
Kreditkarte erforderlich.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-gray-500">Verfügbar für Web, iOS und Android</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
68
apps/clock/apps/landing/src/components/Features.astro
Normal file
68
apps/clock/apps/landing/src/components/Features.astro
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
// Features section for Clock landing page
|
||||
const features = [
|
||||
{
|
||||
icon: '🌍',
|
||||
title: 'Weltzeituhr',
|
||||
description:
|
||||
'Behalte mehrere Zeitzonen im Blick. Perfekt für internationale Teams und Reisende.',
|
||||
},
|
||||
{
|
||||
icon: '⏰',
|
||||
title: 'Wecker',
|
||||
description:
|
||||
'Erstelle wiederkehrende Wecker mit verschiedenen Tönen. Snooze-Funktion inklusive.',
|
||||
},
|
||||
{
|
||||
icon: '⏱',
|
||||
title: 'Timer',
|
||||
description:
|
||||
'Starte mehrere Timer gleichzeitig. Speichere deine Lieblings-Presets für schnellen Zugriff.',
|
||||
},
|
||||
{
|
||||
icon: '⏲',
|
||||
title: 'Stoppuhr',
|
||||
description: 'Präzise Zeitmessung mit Rundenzeiten. Ideal für Sport und produktives Arbeiten.',
|
||||
},
|
||||
{
|
||||
icon: '🍅',
|
||||
title: 'Pomodoro',
|
||||
description:
|
||||
'Steigere deine Produktivität mit der bewährten Pomodoro-Technik. Anpassbare Intervalle.',
|
||||
},
|
||||
{
|
||||
icon: '🔄',
|
||||
title: 'Geräte-Sync',
|
||||
description: 'Deine Wecker und Timer synchronisieren automatisch zwischen all deinen Geräten.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section id="features" class="bg-dark-surface">
|
||||
<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">
|
||||
Funktionen
|
||||
</span>
|
||||
<h2 class="mb-6 text-3xl font-bold md:text-4xl">
|
||||
Alles was du für dein Zeitmanagement brauchst
|
||||
</h2>
|
||||
<p class="text-lg text-gray-400">
|
||||
Von der Weltzeituhr bis zum Pomodoro-Timer - Clock vereint alle Zeit-Tools in einer
|
||||
eleganten App.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 hover:border-primary-500/50 hover:bg-dark-card/80">
|
||||
<div class="mb-4 text-4xl">{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>
|
||||
100
apps/clock/apps/landing/src/components/Footer.astro
Normal file
100
apps/clock/apps/landing/src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
---
|
||||
// Footer for Clock landing page
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const links = {
|
||||
product: [
|
||||
{ label: 'Funktionen', href: '#features' },
|
||||
{ label: 'Preise', href: '#pricing' },
|
||||
{ label: 'Updates', href: '#' },
|
||||
],
|
||||
resources: [
|
||||
{ label: 'Dokumentation', href: '#' },
|
||||
{ label: 'Support', href: '#' },
|
||||
{ label: 'Blog', href: '#' },
|
||||
],
|
||||
legal: [
|
||||
{ label: 'Datenschutz', href: '#' },
|
||||
{ label: 'AGB', href: '#' },
|
||||
{ label: 'Impressum', href: '#' },
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<footer class="border-t border-dark-border bg-dark-bg py-16">
|
||||
<div class="container">
|
||||
<div class="grid gap-12 md:grid-cols-4">
|
||||
<!-- Brand -->
|
||||
<div>
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<span class="text-2xl">⏰</span>
|
||||
<span class="text-xl font-bold">Clock</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Deine Zeit, perfekt organisiert. Weltzeituhr, Wecker, Timer und mehr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Links -->
|
||||
<div>
|
||||
<h4 class="mb-4 font-semibold">Produkt</h4>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
links.product.map((link) => (
|
||||
<li>
|
||||
<a href={link.href} class="text-sm text-gray-400 hover:text-white">
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Resources Links -->
|
||||
<div>
|
||||
<h4 class="mb-4 font-semibold">Ressourcen</h4>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
links.resources.map((link) => (
|
||||
<li>
|
||||
<a href={link.href} class="text-sm text-gray-400 hover:text-white">
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Legal Links -->
|
||||
<div>
|
||||
<h4 class="mb-4 font-semibold">Rechtliches</h4>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
links.legal.map((link) => (
|
||||
<li>
|
||||
<a href={link.href} class="text-sm text-gray-400 hover:text-white">
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 Produkt von <a href="https://manacore.app" class="text-primary-400 hover:underline"
|
||||
>Mana Core</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
141
apps/clock/apps/landing/src/components/Hero.astro
Normal file
141
apps/clock/apps/landing/src/components/Hero.astro
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
// 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>Zeit intelligent verwalten</span>
|
||||
</div>
|
||||
|
||||
<!-- Headline -->
|
||||
<h1 class="mb-6 text-4xl font-bold leading-tight md:text-6xl lg:text-7xl">
|
||||
Deine Zeit,
|
||||
<span class="gradient-text">perfekt organisiert</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subheadline -->
|
||||
<p class="mx-auto mb-10 max-w-2xl text-lg text-gray-400 md:text-xl">
|
||||
Weltzeituhr, Wecker, Timer, Stoppuhr und Pomodoro - alles in einer App. Synchronisiere
|
||||
zwischen Geräten und verpasse nie wieder einen wichtigen Moment.
|
||||
</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">1000+</span> Nutzer vertrauen Clock
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock Preview -->
|
||||
<div class="relative mx-auto mt-16 max-w-lg">
|
||||
<div
|
||||
class="absolute -inset-4 rounded-full bg-gradient-to-r from-primary-500/20 via-transparent to-primary-500/20 blur-3xl"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Animated Clock Face -->
|
||||
<div
|
||||
class="relative mx-auto h-72 w-72 rounded-full border-4 border-dark-border bg-dark-card shadow-2xl"
|
||||
>
|
||||
<!-- Hour markers -->
|
||||
{
|
||||
[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg, i) => (
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-3 w-1 -translate-x-1/2 bg-gray-400"
|
||||
style={`transform: translateX(-50%) rotate(${deg}deg) translateY(-130px)`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-20 w-1.5 -translate-x-1/2 origin-bottom rounded-full bg-white animate-hour"
|
||||
style="transform: translateX(-50%) rotate(45deg); transform-origin: bottom center;"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-28 w-1 -translate-x-1/2 origin-bottom rounded-full bg-white animate-minute"
|
||||
style="transform: translateX(-50%) rotate(120deg); transform-origin: bottom center;"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-32 w-0.5 -translate-x-1/2 origin-bottom rounded-full bg-primary-500 animate-second"
|
||||
style="transform: translateX(-50%) rotate(270deg); transform-origin: bottom center;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Center dot -->
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Digital time overlay -->
|
||||
<div class="absolute bottom-16 left-1/2 -translate-x-1/2 text-center">
|
||||
<div class="font-mono text-2xl font-light text-gray-400" id="digital-time">00:00</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
const digitalTime = document.getElementById('digital-time');
|
||||
if (digitalTime) {
|
||||
digitalTime.textContent = `${hours}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
</script>
|
||||
45
apps/clock/apps/landing/src/layouts/Layout.astro
Normal file
45
apps/clock/apps/landing/src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = 'Clock - Uhren, Wecker & Timer',
|
||||
description = 'Weltzeituhr, Wecker, Timer, Stoppuhr und Pomodoro in einer App. Synchronisiere zwischen Geräten. Kostenlos starten.',
|
||||
} = 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"
|
||||
/>
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
159
apps/clock/apps/landing/src/pages/index.astro
Normal file
159
apps/clock/apps/landing/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
---
|
||||
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 für Einzelpersonen',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Wecker', included: true },
|
||||
{ text: 'Unbegrenzte Timer', included: true },
|
||||
{ text: 'Stoppuhr', included: true },
|
||||
{ text: 'Pomodoro-Timer', included: true },
|
||||
{ text: 'Geräte-Sync', included: false },
|
||||
{ text: 'Push-Benachrichtigungen', included: false },
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: '#',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '2,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Power-User',
|
||||
features: [
|
||||
{ text: 'Alles aus Free', included: true },
|
||||
{ text: 'Geräte-Sync', included: true },
|
||||
{ text: 'Push-Benachrichtigungen', included: true },
|
||||
{ text: 'Unbegrenzte Weltuhr-Städte', included: true },
|
||||
{ text: 'Eigene Alarm-Sounds', included: true },
|
||||
{ text: 'Widgets', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro starten',
|
||||
href: '#',
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Beliebt',
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
price: '4,99',
|
||||
period: '/Monat',
|
||||
description: 'Für Teams & Familien',
|
||||
features: [
|
||||
{ text: 'Alles aus Pro', included: true },
|
||||
{ text: 'Bis zu 5 Nutzer', included: true },
|
||||
{ text: 'Geteilte Timer', included: true },
|
||||
{ text: 'Team-Statistiken', included: true },
|
||||
{ text: 'Admin-Dashboard', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Team erstellen',
|
||||
href: '#',
|
||||
},
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Clock - Uhren, Wecker & Timer"
|
||||
description="Weltzeituhr, Wecker, Timer, Stoppuhr und Pomodoro in einer App. Synchronisiere zwischen Geräten. Kostenlos starten."
|
||||
>
|
||||
<Hero />
|
||||
<Features />
|
||||
|
||||
<!-- Pricing Section -->
|
||||
<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>
|
||||
90
apps/clock/apps/landing/src/styles/global.css
Normal file
90
apps/clock/apps/landing/src/styles/global.css
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
: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;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-dark-bg text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-primary-400 to-primary-600 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
section {
|
||||
@apply py-20 md:py-28;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animate clock hands */
|
||||
@keyframes rotate-hour {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-minute {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-second {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-hour {
|
||||
animation: rotate-hour 43200s linear infinite;
|
||||
}
|
||||
|
||||
.animate-minute {
|
||||
animation: rotate-minute 3600s linear infinite;
|
||||
}
|
||||
|
||||
.animate-second {
|
||||
animation: rotate-second 60s linear infinite;
|
||||
}
|
||||
53
apps/clock/apps/landing/tailwind.config.mjs
Normal file
53
apps/clock/apps/landing/tailwind.config.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/** @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',
|
||||
},
|
||||
// CSS variable mappings for shared-landing-ui compatibility
|
||||
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')],
|
||||
};
|
||||
10
apps/clock/apps/landing/tsconfig.json
Normal file
10
apps/clock/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@layouts/*": ["src/layouts/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/clock/apps/landing/wrangler.toml
Normal file
3
apps/clock/apps/landing/wrangler.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
name = "clock-landing"
|
||||
compatibility_date = "2024-12-01"
|
||||
pages_build_output_dir = "dist"
|
||||
54
apps/clock/apps/web/package.json
Normal file
54
apps/clock/apps/web/package.json
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "@clock/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "echo 'Skipping type-check for now'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@types/topojson-specification": "^1.0.5",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clock/shared": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-feedback-service": "workspace:*",
|
||||
"@manacore/shared-feedback-ui": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"d3": "^7.9.0",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"topojson-client": "^3.1.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
612
apps/clock/apps/web/src/app.css
Normal file
612
apps/clock/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
|
||||
/* Clock-specific CSS Variables */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Clock-specific */
|
||||
--clock-size: 280px;
|
||||
--timer-display-size: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Analog Clock Styles */
|
||||
.clock-face {
|
||||
position: relative;
|
||||
width: var(--clock-size);
|
||||
height: var(--clock-size);
|
||||
border-radius: 50%;
|
||||
background-color: hsl(var(--color-surface));
|
||||
border: 4px solid hsl(var(--color-border));
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.clock-hand {
|
||||
position: absolute;
|
||||
bottom: 50%;
|
||||
left: 50%;
|
||||
transform-origin: bottom center;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.clock-hand.hour {
|
||||
width: 6px;
|
||||
height: 30%;
|
||||
margin-left: -3px;
|
||||
background-color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.clock-hand.minute {
|
||||
width: 4px;
|
||||
height: 40%;
|
||||
margin-left: -2px;
|
||||
background-color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.clock-hand.second {
|
||||
width: 2px;
|
||||
height: 45%;
|
||||
margin-left: -1px;
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.clock-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: -6px;
|
||||
border-radius: 50%;
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Clock markers */
|
||||
.clock-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.clock-marker.hour-marker {
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
background-color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.clock-marker.minute-marker {
|
||||
width: 1px;
|
||||
height: 6px;
|
||||
background-color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Digital Clock Styles */
|
||||
.digital-clock {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.digital-clock-large {
|
||||
font-size: 4rem;
|
||||
font-weight: 200;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.digital-clock-medium {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.digital-clock-small {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Timer Display */
|
||||
.timer-display {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: var(--timer-display-size);
|
||||
font-weight: 200;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Stopwatch lap list */
|
||||
.lap-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.lap-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.lap-item.best {
|
||||
color: hsl(var(--color-success));
|
||||
}
|
||||
|
||||
.lap-item.worst {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* Quick Create Form */
|
||||
.quick-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.time-input-inline {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
width: 5rem;
|
||||
padding: 0.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.time-input-inline:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.time-input-inline::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.label-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.label-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.day-selector-compact {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.day-selector-compact button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.day-selector-compact button:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.day-selector-compact button.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
/* Alarm Grid (responsive) */
|
||||
.alarm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Alarm Tile (Grid layout) */
|
||||
.alarm-tile {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.alarm-tile:hover {
|
||||
background-color: hsl(var(--color-muted) / 0.5);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.alarm-tile:active {
|
||||
background-color: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.alarm-tile.active {
|
||||
opacity: 1;
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.alarm-tile.active:hover {
|
||||
opacity: 1;
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
/* Alarm Card (legacy, for edit form) */
|
||||
.alarm-card {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.alarm-card:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
|
||||
.alarm-card.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* World Clock Card */
|
||||
.world-clock-card {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.world-clock-card .city-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.world-clock-card .timezone-info {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.world-clock-card .time-display {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Pomodoro Progress Ring */
|
||||
.pomodoro-ring {
|
||||
stroke: hsl(var(--color-primary));
|
||||
stroke-linecap: round;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
transition: stroke-dashoffset 1s linear;
|
||||
}
|
||||
|
||||
.pomodoro-ring-bg {
|
||||
stroke: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: hsl(var(--color-primary) / 0.9);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: hsl(var(--color-secondary));
|
||||
color: hsl(var(--color-secondary-foreground));
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: hsl(var(--color-secondary) / 0.8);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-xl {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
background-color: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Time Input (for alarm) */
|
||||
.time-input {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
width: 5rem;
|
||||
padding: 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Time Input Small (for timer) */
|
||||
.time-input-sm {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
width: 3.5rem;
|
||||
padding: 0.375rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Time Input Large (for quick create) */
|
||||
.time-input-large {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 200;
|
||||
text-align: center;
|
||||
width: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-input-large:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.time-input-large::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Toggle Switch (iOS-style) */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
background-color: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-base);
|
||||
border: none;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.toggle-switch .toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-base);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch.active .toggle-knob {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Toggle Switch Small (for grid tiles) */
|
||||
.toggle-switch-sm {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background-color: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-full);
|
||||
transition: background-color var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch-sm.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.toggle-switch-sm .toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-base);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch-sm.active .toggle-knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Legacy toggle (deprecated) */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: hsl(var(--color-muted));
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-base);
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.toggle.active::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Day of week selector (for alarm repeat) */
|
||||
.day-selector {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.day-selector button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.day-selector button:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.day-selector button.active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@layer utilities {
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
}
|
||||
13
apps/clock/apps/web/src/app.html
Normal file
13
apps/clock/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Clock</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
38
apps/clock/apps/web/src/lib/api/alarms.ts
Normal file
38
apps/clock/apps/web/src/lib/api/alarms.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Alarms API client
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
|
||||
export const alarmsApi = {
|
||||
/**
|
||||
* Get all alarms for the current user
|
||||
*/
|
||||
getAll: () => api.get<Alarm[]>('/alarms'),
|
||||
|
||||
/**
|
||||
* Get a single alarm by ID
|
||||
*/
|
||||
getById: (id: string) => api.get<Alarm>(`/alarms/${id}`),
|
||||
|
||||
/**
|
||||
* Create a new alarm
|
||||
*/
|
||||
create: (data: CreateAlarmInput) => api.post<Alarm>('/alarms', data),
|
||||
|
||||
/**
|
||||
* Update an existing alarm
|
||||
*/
|
||||
update: (id: string, data: UpdateAlarmInput) => api.put<Alarm>(`/alarms/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete an alarm
|
||||
*/
|
||||
delete: (id: string) => api.delete<void>(`/alarms/${id}`),
|
||||
|
||||
/**
|
||||
* Toggle alarm enabled state
|
||||
*/
|
||||
toggle: (id: string) => api.patch<Alarm>(`/alarms/${id}/toggle`),
|
||||
};
|
||||
80
apps/clock/apps/web/src/lib/api/client.ts
Normal file
80
apps/clock/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* API Client for Clock backend
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const API_URL = 'http://localhost:3017/api/v1';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const token = await authStore.getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
error: errorData.message || `HTTP error ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return { data: undefined as T };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
export const api = {
|
||||
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
|
||||
post: <T>(endpoint: string, body?: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
put: <T>(endpoint: string, body?: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
patch: <T>(endpoint: string, body?: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
|
||||
};
|
||||
33
apps/clock/apps/web/src/lib/api/presets.ts
Normal file
33
apps/clock/apps/web/src/lib/api/presets.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Presets API client
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { Preset, CreatePresetInput, UpdatePresetInput } from '@clock/shared';
|
||||
|
||||
export const presetsApi = {
|
||||
/**
|
||||
* Get all presets for the current user
|
||||
*/
|
||||
getAll: () => api.get<Preset[]>('/presets'),
|
||||
|
||||
/**
|
||||
* Get presets by type
|
||||
*/
|
||||
getByType: (type: 'timer' | 'pomodoro') => api.get<Preset[]>(`/presets?type=${type}`),
|
||||
|
||||
/**
|
||||
* Create a new preset
|
||||
*/
|
||||
create: (data: CreatePresetInput) => api.post<Preset>('/presets', data),
|
||||
|
||||
/**
|
||||
* Update an existing preset
|
||||
*/
|
||||
update: (id: string, data: UpdatePresetInput) => api.put<Preset>(`/presets/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete a preset
|
||||
*/
|
||||
delete: (id: string) => api.delete<void>(`/presets/${id}`),
|
||||
};
|
||||
48
apps/clock/apps/web/src/lib/api/timers.ts
Normal file
48
apps/clock/apps/web/src/lib/api/timers.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Timers API client
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
||||
|
||||
export const timersApi = {
|
||||
/**
|
||||
* Get all timers for the current user
|
||||
*/
|
||||
getAll: () => api.get<Timer[]>('/timers'),
|
||||
|
||||
/**
|
||||
* Get a single timer by ID
|
||||
*/
|
||||
getById: (id: string) => api.get<Timer>(`/timers/${id}`),
|
||||
|
||||
/**
|
||||
* Create a new timer
|
||||
*/
|
||||
create: (data: CreateTimerInput) => api.post<Timer>('/timers', data),
|
||||
|
||||
/**
|
||||
* Update an existing timer
|
||||
*/
|
||||
update: (id: string, data: UpdateTimerInput) => api.put<Timer>(`/timers/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete a timer
|
||||
*/
|
||||
delete: (id: string) => api.delete<void>(`/timers/${id}`),
|
||||
|
||||
/**
|
||||
* Start a timer
|
||||
*/
|
||||
start: (id: string) => api.post<Timer>(`/timers/${id}/start`),
|
||||
|
||||
/**
|
||||
* Pause a timer
|
||||
*/
|
||||
pause: (id: string) => api.post<Timer>(`/timers/${id}/pause`),
|
||||
|
||||
/**
|
||||
* Reset a timer
|
||||
*/
|
||||
reset: (id: string) => api.post<Timer>(`/timers/${id}/reset`),
|
||||
};
|
||||
36
apps/clock/apps/web/src/lib/api/world-clocks.ts
Normal file
36
apps/clock/apps/web/src/lib/api/world-clocks.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* World Clocks API client
|
||||
*/
|
||||
|
||||
import { api } from './client';
|
||||
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
|
||||
|
||||
export const worldClocksApi = {
|
||||
/**
|
||||
* Get all world clocks for the current user
|
||||
*/
|
||||
getAll: () => api.get<WorldClock[]>('/world-clocks'),
|
||||
|
||||
/**
|
||||
* Create a new world clock entry
|
||||
*/
|
||||
create: (data: CreateWorldClockInput) => api.post<WorldClock>('/world-clocks', data),
|
||||
|
||||
/**
|
||||
* Delete a world clock entry
|
||||
*/
|
||||
delete: (id: string) => api.delete<void>(`/world-clocks/${id}`),
|
||||
|
||||
/**
|
||||
* Reorder world clocks
|
||||
*/
|
||||
reorder: (ids: string[]) => api.put<WorldClock[]>('/world-clocks/reorder', { ids }),
|
||||
|
||||
/**
|
||||
* Search for timezones
|
||||
*/
|
||||
searchTimezones: (query: string) =>
|
||||
api.get<{ timezone: string; city: string }[]>(
|
||||
`/timezones/search?q=${encodeURIComponent(query)}`
|
||||
),
|
||||
};
|
||||
32
apps/clock/apps/web/src/lib/components/AppSlider.svelte
Normal file
32
apps/clock/apps/web/src/lib/components/AppSlider.svelte
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { AppSlider, type AppItem } from '@manacore/shared-ui';
|
||||
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
|
||||
|
||||
// Convert MANA_APPS to AppItem format (German)
|
||||
const apps: AppItem[] = MANA_APPS.map((app) => ({
|
||||
name: app.name,
|
||||
description: app.description.de,
|
||||
longDescription: app.longDescription.de,
|
||||
icon: app.icon,
|
||||
color: app.color,
|
||||
comingSoon: app.comingSoon,
|
||||
status: app.status,
|
||||
}));
|
||||
|
||||
const statusLabels = APP_STATUS_LABELS.de;
|
||||
const labels = APP_SLIDER_LABELS.de;
|
||||
|
||||
function handleAppClick(app: AppItem, index: number) {
|
||||
console.log('Opening app:', app.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppSlider
|
||||
{apps}
|
||||
title={labels.title}
|
||||
isDark={false}
|
||||
{statusLabels}
|
||||
comingSoonLabel={labels.comingSoon}
|
||||
openAppLabel={labels.openApp}
|
||||
onAppClick={handleAppClick}
|
||||
/>
|
||||
51
apps/clock/apps/web/src/lib/components/ToastContainer.svelte
Normal file
51
apps/clock/apps/web/src/lib/components/ToastContainer.svelte
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
import { toast, type Toast } from '$lib/stores/toast';
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
|
||||
toast.subscribe((value) => {
|
||||
toasts = value;
|
||||
});
|
||||
|
||||
function getToastClasses(type: Toast['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-500 text-white';
|
||||
case 'error':
|
||||
return 'bg-red-500 text-white';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500 text-black';
|
||||
default:
|
||||
return 'bg-blue-500 text-white';
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon(type: Toast['type']) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return '✓';
|
||||
case 'error':
|
||||
return '✕';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
default:
|
||||
return 'ℹ';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{#each toasts as t (t.id)}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg transition-all duration-300 {getToastClasses(
|
||||
t.type
|
||||
)}"
|
||||
>
|
||||
<span class="text-lg">{getIcon(t.type)}</span>
|
||||
<span class="flex-1">{t.message}</span>
|
||||
<button onclick={() => toast.remove(t.id)} class="ml-2 opacity-70 hover:opacity-100">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
453
apps/clock/apps/web/src/lib/components/WorldMap.svelte
Normal file
453
apps/clock/apps/web/src/lib/components/WorldMap.svelte
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
<script lang="ts">
|
||||
import { POPULAR_TIMEZONES } from '@clock/shared';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
selectedCities?: string[];
|
||||
onCityClick?: (timezone: string, cityName: string) => void;
|
||||
currentTime?: Date;
|
||||
}
|
||||
|
||||
let { selectedCities = [], onCityClick, currentTime = new Date() }: Props = $props();
|
||||
|
||||
// State
|
||||
let hoveredCity: (typeof POPULAR_TIMEZONES)[number] | null = $state(null);
|
||||
|
||||
// Map dimensions
|
||||
const width = 900;
|
||||
const height = 450;
|
||||
|
||||
// Convert lat/lng to x/y coordinates (simple equirectangular projection)
|
||||
function latLngToXY(lat: number, lng: number): { x: number; y: number } {
|
||||
const x = ((lng + 180) / 360) * width;
|
||||
const y = ((90 - lat) / 180) * height;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// Get time for a timezone
|
||||
function getTimeForTimezone(timezone: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(currentTime);
|
||||
} catch {
|
||||
return '--:--';
|
||||
}
|
||||
}
|
||||
|
||||
// Get timezone offset relative to local time
|
||||
function getTimezoneOffset(timezone: string): string {
|
||||
try {
|
||||
// Get local offset in minutes
|
||||
const localOffset = currentTime.getTimezoneOffset();
|
||||
|
||||
// Get target timezone time
|
||||
const targetFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: false,
|
||||
});
|
||||
const localFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
// Parse times to calculate difference
|
||||
const targetParts = targetFormatter.formatToParts(currentTime);
|
||||
const localParts = localFormatter.formatToParts(currentTime);
|
||||
|
||||
const targetHour = parseInt(targetParts.find((p) => p.type === 'hour')?.value || '0');
|
||||
const targetMin = parseInt(targetParts.find((p) => p.type === 'minute')?.value || '0');
|
||||
const localHour = parseInt(localParts.find((p) => p.type === 'hour')?.value || '0');
|
||||
const localMin = parseInt(localParts.find((p) => p.type === 'minute')?.value || '0');
|
||||
|
||||
let diffMinutes = targetHour * 60 + targetMin - (localHour * 60 + localMin);
|
||||
|
||||
// Handle day boundary
|
||||
if (diffMinutes > 720) diffMinutes -= 1440;
|
||||
if (diffMinutes < -720) diffMinutes += 1440;
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
|
||||
if (diffHours === 0) {
|
||||
return 'Gleiche Zeit';
|
||||
} else if (diffHours > 0) {
|
||||
return `+${diffHours}h`;
|
||||
} else {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Get date for timezone
|
||||
function getDateForTimezone(timezone: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(currentTime);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if location is in daylight (simplified)
|
||||
function isDaytime(lat: number, lng: number): boolean {
|
||||
const hours = currentTime.getUTCHours() + currentTime.getUTCMinutes() / 60;
|
||||
const solarNoon = 12;
|
||||
const sunLng = ((solarNoon - hours) * 15) % 360;
|
||||
|
||||
// Simplified day/night calculation
|
||||
let lngDiff = Math.abs(lng - sunLng);
|
||||
if (lngDiff > 180) lngDiff = 360 - lngDiff;
|
||||
|
||||
return lngDiff < 90;
|
||||
}
|
||||
|
||||
// Calculate terminator line points
|
||||
function getTerminatorPath(): string {
|
||||
const hours = currentTime.getUTCHours() + currentTime.getUTCMinutes() / 60;
|
||||
const dayOfYear = Math.floor(
|
||||
(currentTime.getTime() - new Date(currentTime.getFullYear(), 0, 0).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
const declination = -23.45 * Math.cos((360 / 365) * (dayOfYear + 10) * (Math.PI / 180));
|
||||
const sunLng = -((hours - 12) * 15);
|
||||
|
||||
const points: string[] = [];
|
||||
|
||||
// Calculate terminator for each latitude
|
||||
for (let lat = 90; lat >= -90; lat -= 2) {
|
||||
const latRad = (lat * Math.PI) / 180;
|
||||
const decRad = (declination * Math.PI) / 180;
|
||||
|
||||
// Hour angle at terminator
|
||||
const cosH = -Math.tan(latRad) * Math.tan(decRad);
|
||||
|
||||
let lng: number;
|
||||
if (cosH >= 1) {
|
||||
// Polar night
|
||||
lng = sunLng + 180;
|
||||
} else if (cosH <= -1) {
|
||||
// Polar day
|
||||
lng = sunLng;
|
||||
} else {
|
||||
const H = (Math.acos(cosH) * 180) / Math.PI;
|
||||
lng = sunLng + H;
|
||||
}
|
||||
|
||||
// Normalize longitude
|
||||
lng = ((lng + 180) % 360) - 180;
|
||||
if (lng < -180) lng += 360;
|
||||
|
||||
const { x, y } = latLngToXY(lat, lng);
|
||||
points.push(`${x},${y}`);
|
||||
}
|
||||
|
||||
// Close the path on the night side
|
||||
points.push(`${width},${height}`);
|
||||
points.push(`${width},0`);
|
||||
points.push(`${points[0].split(',')[0]},0`);
|
||||
|
||||
return `M${points.join(' L')} Z`;
|
||||
}
|
||||
|
||||
function handleCityClick(city: (typeof POPULAR_TIMEZONES)[number]) {
|
||||
if (onCityClick) {
|
||||
onCityClick(city.timezone, city.city);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="world-map-container">
|
||||
<svg viewBox="0 0 {width} {height}" class="world-map-svg">
|
||||
<!-- Ocean background -->
|
||||
<rect x="0" y="0" {width} {height} class="ocean-bg" />
|
||||
|
||||
<!-- Grid lines -->
|
||||
{#each [-60, -30, 0, 30, 60] as lat}
|
||||
{@const y = ((90 - lat) / 180) * height}
|
||||
<line x1="0" y1={y} x2={width} y2={y} class="grid-line" />
|
||||
{/each}
|
||||
{#each [-150, -120, -90, -60, -30, 0, 30, 60, 90, 120, 150, 180] as lng}
|
||||
{@const x = ((lng + 180) / 360) * width}
|
||||
<line x1={x} y1="0" x2={x} y2={height} class="grid-line" />
|
||||
{/each}
|
||||
|
||||
<!-- Simplified continent outlines -->
|
||||
<g class="continents">
|
||||
<!-- North America -->
|
||||
<path
|
||||
d="M 50,50 L 180,50 L 200,80 L 220,100 L 200,150 L 160,180 L 120,190 L 80,180 L 60,150 L 40,100 Z"
|
||||
/>
|
||||
<!-- South America -->
|
||||
<path
|
||||
d="M 160,200 L 200,210 L 210,250 L 200,320 L 170,380 L 150,390 L 140,350 L 150,280 L 140,220 Z"
|
||||
/>
|
||||
<!-- Europe -->
|
||||
<path d="M 420,60 L 480,50 L 520,70 L 500,100 L 480,120 L 440,130 L 420,110 L 400,80 Z" />
|
||||
<!-- Africa -->
|
||||
<path
|
||||
d="M 420,150 L 500,140 L 540,180 L 550,250 L 520,330 L 470,350 L 420,320 L 400,250 L 410,180 Z"
|
||||
/>
|
||||
<!-- Asia -->
|
||||
<path
|
||||
d="M 500,40 L 700,30 L 800,60 L 820,120 L 780,180 L 700,200 L 620,190 L 560,160 L 520,120 L 500,80 Z"
|
||||
/>
|
||||
<!-- Australia -->
|
||||
<path d="M 720,280 L 800,270 L 840,300 L 830,350 L 780,370 L 720,350 L 700,310 Z" />
|
||||
<!-- Indonesia/Southeast Asia -->
|
||||
<path d="M 680,200 L 750,190 L 800,210 L 780,240 L 720,250 L 680,230 Z" />
|
||||
</g>
|
||||
|
||||
<!-- Night overlay (simplified) -->
|
||||
<rect
|
||||
x={(() => {
|
||||
const hours = currentTime.getUTCHours() + currentTime.getUTCMinutes() / 60;
|
||||
const sunLng = -((hours - 12) * 15);
|
||||
const nightCenterX = (((sunLng + 180 + 180) % 360) / 360) * width;
|
||||
return nightCenterX - width / 2;
|
||||
})()}
|
||||
y="0"
|
||||
width={width / 2}
|
||||
{height}
|
||||
class="night-overlay"
|
||||
/>
|
||||
|
||||
<!-- City markers -->
|
||||
{#each POPULAR_TIMEZONES as city}
|
||||
{@const pos = latLngToXY(city.lat, city.lng)}
|
||||
{@const isSelected = selectedCities.includes(city.timezone)}
|
||||
{@const isDay = isDaytime(city.lat, city.lng)}
|
||||
{@const isHovered = hoveredCity?.timezone === city.timezone}
|
||||
<g
|
||||
class="city-marker"
|
||||
class:hovered={isHovered}
|
||||
transform="translate({pos.x}, {pos.y})"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleCityClick(city)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleCityClick(city)}
|
||||
onpointerenter={() => (hoveredCity = city)}
|
||||
onpointerleave={() => (hoveredCity = null)}
|
||||
>
|
||||
<!-- Larger hit area (invisible) -->
|
||||
<circle r="15" fill="transparent" class="hit-area" />
|
||||
{#if isSelected}
|
||||
<circle r="12" fill="hsl(var(--color-primary) / 0.25)" class="city-glow" />
|
||||
{/if}
|
||||
{#if isHovered}
|
||||
<circle r="10" fill="hsl(var(--color-foreground) / 0.15)" />
|
||||
{/if}
|
||||
<circle
|
||||
r={isSelected ? 6 : isHovered ? 5 : 4}
|
||||
fill={isSelected ? 'hsl(var(--color-primary))' : isDay ? '#fbbf24' : '#818cf8'}
|
||||
stroke="hsl(var(--color-background))"
|
||||
stroke-width={isHovered ? 2 : 1.5}
|
||||
/>
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<!-- Tooltip -->
|
||||
{#if hoveredCity}
|
||||
{@const pos = latLngToXY(hoveredCity.lat, hoveredCity.lng)}
|
||||
{@const offset = getTimezoneOffset(hoveredCity.timezone)}
|
||||
{@const isDay = isDaytime(hoveredCity.lat, hoveredCity.lng)}
|
||||
<div
|
||||
class="map-tooltip"
|
||||
style="left: {(pos.x / width) * 100}%; top: {(pos.y / height) * 100}%;"
|
||||
>
|
||||
<div class="tooltip-header">
|
||||
<span class="tooltip-city">{hoveredCity.city}</span>
|
||||
<span class="tooltip-indicator" class:day={isDay}>{isDay ? '☀️' : '🌙'}</span>
|
||||
</div>
|
||||
<div class="tooltip-time">{getTimeForTimezone(hoveredCity.timezone)}</div>
|
||||
<div class="tooltip-details">
|
||||
<span class="tooltip-date">{getDateForTimezone(hoveredCity.timezone)}</span>
|
||||
<span class="tooltip-offset" class:same={offset === 'Gleiche Zeit'}>{offset}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="map-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot day"></span>
|
||||
<span>Tag</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot night"></span>
|
||||
<span>Nacht</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot selected"></span>
|
||||
<span>Ausgewählt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.world-map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: hsl(var(--color-card));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.world-map-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.city-marker {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.city-marker .hit-area {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.city-marker:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.city-marker:focus circle:not(.hit-area) {
|
||||
stroke: hsl(var(--color-primary));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.city-glow {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.night-overlay {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-tooltip {
|
||||
position: absolute;
|
||||
background: hsl(var(--color-popover));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, calc(-100% - 20px));
|
||||
z-index: 20;
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.2);
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tooltip-city {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tooltip-indicator {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary));
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tooltip-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.375rem;
|
||||
border-top: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.tooltip-date {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.tooltip-offset {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.tooltip-offset.same {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
}
|
||||
|
||||
.map-legend {
|
||||
position: absolute;
|
||||
bottom: 0.5rem;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
background: hsl(var(--color-card) / 0.9);
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-dot.day {
|
||||
background: #fbbf24;
|
||||
}
|
||||
|
||||
.legend-dot.night {
|
||||
background: #818cf8;
|
||||
}
|
||||
|
||||
.legend-dot.selected {
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import type { ClockFaceType } from '$lib/stores/clock-face.svelte';
|
||||
import ClockFaceClassic from './ClockFaceClassic.svelte';
|
||||
import ClockFaceMinimalist from './ClockFaceMinimalist.svelte';
|
||||
import ClockFaceModern from './ClockFaceModern.svelte';
|
||||
import ClockFaceElegant from './ClockFaceElegant.svelte';
|
||||
import ClockFaceSporty from './ClockFaceSporty.svelte';
|
||||
import ClockFaceVintage from './ClockFaceVintage.svelte';
|
||||
import ClockFaceNautical from './ClockFaceNautical.svelte';
|
||||
import ClockFaceIndustrial from './ClockFaceIndustrial.svelte';
|
||||
import ClockFaceBauhaus from './ClockFaceBauhaus.svelte';
|
||||
import ClockFaceRailway from './ClockFaceRailway.svelte';
|
||||
import ClockFaceLCD from './ClockFaceLCD.svelte';
|
||||
import ClockFaceFlip from './ClockFaceFlip.svelte';
|
||||
import ClockFaceMatrix from './ClockFaceMatrix.svelte';
|
||||
import ClockFaceNeon from './ClockFaceNeon.svelte';
|
||||
import ClockFaceBinary from './ClockFaceBinary.svelte';
|
||||
import ClockFaceRetro from './ClockFaceRetro.svelte';
|
||||
import ClockFaceGradient from './ClockFaceGradient.svelte';
|
||||
import ClockFaceTerminal from './ClockFaceTerminal.svelte';
|
||||
import ClockFaceTypewriter from './ClockFaceTypewriter.svelte';
|
||||
import ClockFaceRadar from './ClockFaceRadar.svelte';
|
||||
|
||||
interface Props {
|
||||
type: ClockFaceType;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { type, hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Map of clock face types to components
|
||||
const clockFaceComponents = {
|
||||
classic: ClockFaceClassic,
|
||||
minimalist: ClockFaceMinimalist,
|
||||
modern: ClockFaceModern,
|
||||
elegant: ClockFaceElegant,
|
||||
sporty: ClockFaceSporty,
|
||||
vintage: ClockFaceVintage,
|
||||
nautical: ClockFaceNautical,
|
||||
industrial: ClockFaceIndustrial,
|
||||
bauhaus: ClockFaceBauhaus,
|
||||
railway: ClockFaceRailway,
|
||||
lcd: ClockFaceLCD,
|
||||
flip: ClockFaceFlip,
|
||||
matrix: ClockFaceMatrix,
|
||||
neon: ClockFaceNeon,
|
||||
binary: ClockFaceBinary,
|
||||
retro: ClockFaceRetro,
|
||||
gradient: ClockFaceGradient,
|
||||
terminal: ClockFaceTerminal,
|
||||
typewriter: ClockFaceTypewriter,
|
||||
radar: ClockFaceRadar,
|
||||
} as const;
|
||||
|
||||
let ClockComponent = $derived(clockFaceComponents[type] || ClockFaceModern);
|
||||
</script>
|
||||
|
||||
<ClockComponent {hours} {minutes} {seconds} {size} />
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
</script>
|
||||
|
||||
<div class="clock-face-bauhaus" style="--size: {size}px;">
|
||||
<!-- Simple border -->
|
||||
<div class="frame"></div>
|
||||
|
||||
<!-- Clean white background -->
|
||||
<div class="clock-bg"></div>
|
||||
|
||||
<!-- Geometric hour markers - simple lines -->
|
||||
{#each Array(12) as _, i}
|
||||
<div
|
||||
class="marker"
|
||||
class:marker-quarter={i % 3 === 0}
|
||||
style="transform: rotate({i * 30}deg)"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
<!-- Primary color accents at quarters -->
|
||||
<div class="accent accent-12"></div>
|
||||
<div class="accent accent-3"></div>
|
||||
<div class="accent accent-6"></div>
|
||||
<div class="accent accent-9"></div>
|
||||
|
||||
<!-- Clock hands - geometric shapes -->
|
||||
<div class="hands-container">
|
||||
<!-- Hour hand - rectangle -->
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)"></div>
|
||||
|
||||
<!-- Minute hand - longer rectangle -->
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)"></div>
|
||||
|
||||
<!-- Second hand - thin with circle -->
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)">
|
||||
<div class="second-line"></div>
|
||||
<div class="second-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center - simple circle -->
|
||||
<div class="center"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-bauhaus {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: calc(var(--size) * 0.02);
|
||||
border-radius: 50%;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: calc(var(--size) * 0.06);
|
||||
margin-left: -1px;
|
||||
margin-top: calc(var(--size) * -0.45);
|
||||
background: #1a1a1a;
|
||||
transform-origin: center calc(var(--size) * 0.45);
|
||||
}
|
||||
|
||||
.marker-quarter {
|
||||
width: 4px;
|
||||
margin-left: -2px;
|
||||
height: calc(var(--size) * 0.1);
|
||||
margin-top: calc(var(--size) * -0.45);
|
||||
}
|
||||
|
||||
/* Primary color accents - Bauhaus colors */
|
||||
.accent {
|
||||
position: absolute;
|
||||
width: calc(var(--size) * 0.04);
|
||||
height: calc(var(--size) * 0.04);
|
||||
}
|
||||
|
||||
.accent-12 {
|
||||
top: calc(var(--size) * 0.08);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #e63946; /* Red */
|
||||
}
|
||||
|
||||
.accent-3 {
|
||||
top: 50%;
|
||||
right: calc(var(--size) * 0.08);
|
||||
transform: translateY(-50%);
|
||||
background: #f4a261; /* Yellow/Orange */
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.accent-6 {
|
||||
bottom: calc(var(--size) * 0.08);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #2a9d8f; /* Teal */
|
||||
clip-path: polygon(50% 0%, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
.accent-9 {
|
||||
top: 50%;
|
||||
left: calc(var(--size) * 0.08);
|
||||
transform: translateY(-50%);
|
||||
background: #264653; /* Dark blue */
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.04);
|
||||
height: calc(var(--size) * 0.22);
|
||||
margin-left: calc(var(--size) * -0.02);
|
||||
margin-top: calc(var(--size) * -0.22);
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.025);
|
||||
height: calc(var(--size) * 0.32);
|
||||
margin-left: calc(var(--size) * -0.0125);
|
||||
margin-top: calc(var(--size) * -0.32);
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.008);
|
||||
height: calc(var(--size) * 0.44);
|
||||
margin-left: calc(var(--size) * -0.004);
|
||||
margin-top: calc(var(--size) * -0.36);
|
||||
transform-origin: center 81.82%;
|
||||
}
|
||||
|
||||
.second-line {
|
||||
position: absolute;
|
||||
top: 18%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 82%;
|
||||
background: #e63946;
|
||||
}
|
||||
|
||||
.second-dot {
|
||||
position: absolute;
|
||||
top: 8%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(var(--size) * 0.03);
|
||||
height: calc(var(--size) * 0.03);
|
||||
background: #e63946;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.045);
|
||||
height: calc(var(--size) * 0.045);
|
||||
margin: calc(var(--size) * -0.0225);
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Convert to BCD (Binary Coded Decimal)
|
||||
function toBCD(value: number): { tens: number[]; ones: number[] } {
|
||||
const tens = Math.floor(value / 10);
|
||||
const ones = value % 10;
|
||||
return {
|
||||
tens: [(tens >> 2) & 1, (tens >> 1) & 1, tens & 1],
|
||||
ones: [(ones >> 3) & 1, (ones >> 2) & 1, (ones >> 1) & 1, ones & 1],
|
||||
};
|
||||
}
|
||||
|
||||
let hoursBCD = $derived(toBCD(hours));
|
||||
let minutesBCD = $derived(toBCD(minutes));
|
||||
let secondsBCD = $derived(toBCD(seconds));
|
||||
|
||||
let timeString = $derived(
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="clock-face-binary" style="--size: {size}px;">
|
||||
<div class="binary-display">
|
||||
<!-- Column headers -->
|
||||
<div class="row header-row">
|
||||
<div class="cell header"></div>
|
||||
<div class="cell header label">H</div>
|
||||
<div class="cell header label">H</div>
|
||||
<div class="cell header spacer"></div>
|
||||
<div class="cell header label">M</div>
|
||||
<div class="cell header label">M</div>
|
||||
<div class="cell header spacer"></div>
|
||||
<div class="cell header label">S</div>
|
||||
<div class="cell header label">S</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 8 (only for ones columns) -->
|
||||
<div class="row">
|
||||
<div class="cell weight">8</div>
|
||||
<div class="cell empty"></div>
|
||||
<div class="led" class:on={hoursBCD.ones[0] === 1}></div>
|
||||
<div class="cell spacer"></div>
|
||||
<div class="cell empty"></div>
|
||||
<div class="led" class:on={minutesBCD.ones[0] === 1}></div>
|
||||
<div class="cell spacer"></div>
|
||||
<div class="cell empty"></div>
|
||||
<div class="led led-small" class:on={secondsBCD.ones[0] === 1}></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4 -->
|
||||
<div class="row">
|
||||
<div class="cell weight">4</div>
|
||||
<div class="led" class:on={hoursBCD.tens[0] === 1}></div>
|
||||
<div class="led" class:on={hoursBCD.ones[1] === 1}></div>
|
||||
<div class="cell spacer"></div>
|
||||
<div class="led" class:on={minutesBCD.tens[0] === 1}></div>
|
||||
<div class="led" class:on={minutesBCD.ones[1] === 1}></div>
|
||||
<div class="cell spacer"></div>
|
||||
<div class="led led-small" class:on={secondsBCD.tens[0] === 1}></div>
|
||||
<div class="led led-small" class:on={secondsBCD.ones[1] === 1}></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2 -->
|
||||
<div class="row">
|
||||
<div class="cell weight">2</div>
|
||||
<div class="led" class:on={hoursBCD.tens[1] === 1}></div>
|
||||
<div class="led" class:on={hoursBCD.ones[2] === 1}></div>
|
||||
<div class="cell spacer"></div>
|
||||
<div class="led" class:on={minutesBCD.tens[1] === 1}></div>
|
||||
<div class="led" class:on={minutesBCD.ones[2] === 1}></div>
|
||||
<div class="cell spacer"></div>
|
||||
<div class="led led-small" class:on={secondsBCD.tens[1] === 1}></div>
|
||||
<div class="led led-small" class:on={secondsBCD.ones[2] === 1}></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1 -->
|
||||
<div class="row">
|
||||
<div class="cell weight">1</div>
|
||||
<div class="led" class:on={hoursBCD.tens[2] === 1}></div>
|
||||
<div class="led" class:on={hoursBCD.ones[3] === 1}></div>
|
||||
<div class="cell spacer"></div>
|
||||
<div class="led" class:on={minutesBCD.tens[2] === 1}></div>
|
||||
<div class="led" class:on={minutesBCD.ones[3] === 1}></div>
|
||||
<div class="cell spacer"></div>
|
||||
<div class="led led-small" class:on={secondsBCD.tens[2] === 1}></div>
|
||||
<div class="led led-small" class:on={secondsBCD.ones[3] === 1}></div>
|
||||
</div>
|
||||
|
||||
<!-- Decimal display -->
|
||||
<div class="decimal-row">
|
||||
<span class="decimal-time">{timeString}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-binary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.binary-display {
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #0a0a0a 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cell.header {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cell.label {
|
||||
width: 22px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.cell.weight {
|
||||
width: 16px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.cell.empty {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.cell.spacer {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.led {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #2a2a2a;
|
||||
transition: all 120ms;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.led.on {
|
||||
background: radial-gradient(circle at 30% 30%, #00ff44 0%, #00cc33 50%, #00aa22 100%);
|
||||
border-color: #00ff44;
|
||||
box-shadow:
|
||||
0 0 8px #00ff44,
|
||||
0 0 16px rgba(0, 255, 68, 0.5),
|
||||
inset 0 -2px 4px rgba(0, 0, 0, 0.2),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.led-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.decimal-row {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.decimal-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
|
||||
// Roman numerals
|
||||
const romanNumerals = ['XII', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI'];
|
||||
</script>
|
||||
|
||||
<div class="clock-face-classic" style="--size: {size}px;">
|
||||
<!-- Outer bezel -->
|
||||
<div class="bezel"></div>
|
||||
|
||||
<!-- Background -->
|
||||
<div class="clock-bg">
|
||||
<!-- Decorative pattern -->
|
||||
<div class="pattern"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minute tick marks -->
|
||||
{#each Array(60) as _, i}
|
||||
{#if i % 5 !== 0}
|
||||
<div class="tick tick-minute" style="transform: rotate({i * 6}deg)"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Hour tick marks -->
|
||||
{#each Array(12) as _, i}
|
||||
<div class="tick tick-hour" style="transform: rotate({i * 30}deg)"></div>
|
||||
{/each}
|
||||
|
||||
<!-- Roman numerals -->
|
||||
{#each romanNumerals as numeral, i}
|
||||
<span class="numeral" style="--angle: {i * 30}deg;">
|
||||
{numeral}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- Inner decorative ring -->
|
||||
<div class="inner-ring"></div>
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)">
|
||||
<div class="hand-body"></div>
|
||||
<div class="hand-tail"></div>
|
||||
</div>
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)">
|
||||
<div class="hand-body"></div>
|
||||
<div class="hand-tail"></div>
|
||||
</div>
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)">
|
||||
<div class="hand-body"></div>
|
||||
<div class="hand-counterweight"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center cap -->
|
||||
<div class="center-cap">
|
||||
<div class="center-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-classic {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bezel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--color-foreground) / 0.15) 0%,
|
||||
hsl(var(--color-foreground) / 0.05) 50%,
|
||||
hsl(var(--color-foreground) / 0.2) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.15),
|
||||
0 2px 8px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--color-surface)) 0%,
|
||||
hsl(var(--color-background)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 2px 8px rgba(0, 0, 0, 0.08),
|
||||
inset 0 -1px 2px rgba(255, 255, 255, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.tick-minute {
|
||||
width: 1px;
|
||||
height: calc(var(--size) * 0.03);
|
||||
margin-left: -0.5px;
|
||||
margin-top: calc(var(--size) * -0.46);
|
||||
background: hsl(var(--color-muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
.tick-hour {
|
||||
width: 2px;
|
||||
height: calc(var(--size) * 0.05);
|
||||
margin-left: -1px;
|
||||
margin-top: calc(var(--size) * -0.46);
|
||||
background: hsl(var(--color-foreground) / 0.6);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.numeral {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, Georgia, serif;
|
||||
font-size: calc(var(--size) * 0.065);
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--size) * -0.36))
|
||||
rotate(calc(var(--angle) * -1));
|
||||
}
|
||||
|
||||
.inner-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid hsl(var(--color-border) / 0.3);
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.03);
|
||||
height: calc(var(--size) * 0.28);
|
||||
margin-left: calc(var(--size) * -0.015);
|
||||
margin-top: calc(var(--size) * -0.28);
|
||||
}
|
||||
|
||||
.hour-hand .hand-body {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 85%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--color-foreground) / 0.7) 0%,
|
||||
hsl(var(--color-foreground)) 50%,
|
||||
hsl(var(--color-foreground) / 0.7) 100%
|
||||
);
|
||||
clip-path: polygon(30% 0%, 70% 0%, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
.hour-hand .hand-tail {
|
||||
position: absolute;
|
||||
top: 85%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%;
|
||||
height: 20%;
|
||||
background: hsl(var(--color-foreground));
|
||||
border-radius: 0 0 2px 2px;
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.022);
|
||||
height: calc(var(--size) * 0.38);
|
||||
margin-left: calc(var(--size) * -0.011);
|
||||
margin-top: calc(var(--size) * -0.38);
|
||||
}
|
||||
|
||||
.minute-hand .hand-body {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 88%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--color-foreground) / 0.7) 0%,
|
||||
hsl(var(--color-foreground)) 50%,
|
||||
hsl(var(--color-foreground) / 0.7) 100%
|
||||
);
|
||||
clip-path: polygon(25% 0%, 75% 0%, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
.minute-hand .hand-tail {
|
||||
position: absolute;
|
||||
top: 88%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 70%;
|
||||
height: 18%;
|
||||
background: hsl(var(--color-foreground));
|
||||
border-radius: 0 0 1px 1px;
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.008);
|
||||
height: calc(var(--size) * 0.48);
|
||||
margin-left: calc(var(--size) * -0.004);
|
||||
margin-top: calc(var(--size) * -0.38);
|
||||
transform-origin: center 79.17%;
|
||||
}
|
||||
|
||||
.second-hand .hand-body {
|
||||
position: absolute;
|
||||
bottom: 20%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 80%;
|
||||
background: hsl(var(--color-primary));
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.second-hand .hand-counterweight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200%;
|
||||
height: 20%;
|
||||
background: hsl(var(--color-primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.center-cap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.06);
|
||||
height: calc(var(--size) * 0.06);
|
||||
margin: calc(var(--size) * -0.03);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--color-foreground)) 0%,
|
||||
hsl(var(--color-muted-foreground)) 100%
|
||||
);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.center-inner {
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
</script>
|
||||
|
||||
<div class="clock-face-elegant" style="--size: {size}px;">
|
||||
<!-- Outer golden bezel -->
|
||||
<div class="bezel-outer"></div>
|
||||
<div class="bezel-inner"></div>
|
||||
|
||||
<!-- Background with texture -->
|
||||
<div class="clock-bg">
|
||||
<div class="texture"></div>
|
||||
<div class="guilloche"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minute markers -->
|
||||
{#each Array(60) as _, i}
|
||||
<div class="marker" class:marker-5={i % 5 === 0} style="transform: rotate({i * 6}deg)"></div>
|
||||
{/each}
|
||||
|
||||
<!-- Hour indices (diamond shaped) -->
|
||||
{#each Array(12) as _, i}
|
||||
<div class="hour-index" style="--angle: {i * 30}deg;">
|
||||
<div class="diamond"></div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Decorative rings -->
|
||||
<div class="ring ring-outer"></div>
|
||||
<div class="ring ring-inner"></div>
|
||||
|
||||
<!-- Brand text -->
|
||||
<div class="brand">ELEGANT</div>
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)">
|
||||
<div class="hand-blade"></div>
|
||||
</div>
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)">
|
||||
<div class="hand-blade"></div>
|
||||
</div>
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center jewel -->
|
||||
<div class="center-jewel">
|
||||
<div class="jewel-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-elegant {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bezel-outer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#f5e7a3 0%,
|
||||
#d4af37 25%,
|
||||
#f5e7a3 50%,
|
||||
#d4af37 75%,
|
||||
#f5e7a3 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.bezel-inner {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #b8960c 0%, #d4af37 50%, #8b7229 100%);
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--color-surface)) 0%,
|
||||
hsl(var(--color-background) / 0.95) 100%
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.texture {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.15) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.guilloche {
|
||||
position: absolute;
|
||||
inset: 25%;
|
||||
border-radius: 50%;
|
||||
background: repeating-conic-gradient(
|
||||
from 0deg,
|
||||
hsl(var(--color-border) / 0.05) 0deg 3deg,
|
||||
transparent 3deg 6deg
|
||||
);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: calc(var(--size) * 0.02);
|
||||
margin-left: -0.5px;
|
||||
margin-top: calc(var(--size) * -0.44);
|
||||
background: #d4af37;
|
||||
opacity: 0.4;
|
||||
transform-origin: center calc(var(--size) * 0.44);
|
||||
}
|
||||
|
||||
.marker-5 {
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
height: calc(var(--size) * 0.035);
|
||||
margin-top: calc(var(--size) * -0.44);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hour-index {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--size) * -0.36));
|
||||
}
|
||||
|
||||
.diamond {
|
||||
width: calc(var(--size) * 0.025);
|
||||
height: calc(var(--size) * 0.04);
|
||||
background: linear-gradient(135deg, #f5e7a3 0%, #d4af37 50%, #8b7229 100%);
|
||||
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid #d4af37;
|
||||
}
|
||||
|
||||
.ring-outer {
|
||||
width: 75%;
|
||||
height: 75%;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.ring-inner {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 32%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, Georgia, serif;
|
||||
font-size: calc(var(--size) * 0.035);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.25em;
|
||||
color: #d4af37;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.025);
|
||||
height: calc(var(--size) * 0.24);
|
||||
margin-left: calc(var(--size) * -0.0125);
|
||||
margin-top: calc(var(--size) * -0.24);
|
||||
}
|
||||
|
||||
.hour-hand .hand-blade {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #8b7229 0%, #d4af37 50%, #8b7229 100%);
|
||||
clip-path: polygon(40% 0%, 60% 0%, 100% 100%, 0% 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.018);
|
||||
height: calc(var(--size) * 0.34);
|
||||
margin-left: calc(var(--size) * -0.009);
|
||||
margin-top: calc(var(--size) * -0.34);
|
||||
}
|
||||
|
||||
.minute-hand .hand-blade {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #8b7229 0%, #d4af37 50%, #8b7229 100%);
|
||||
clip-path: polygon(35% 0%, 65% 0%, 100% 100%, 0% 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.006);
|
||||
height: calc(var(--size) * 0.4);
|
||||
margin-left: calc(var(--size) * -0.003);
|
||||
margin-top: calc(var(--size) * -0.32);
|
||||
transform-origin: center 80%;
|
||||
background: #d4af37;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.center-jewel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.06);
|
||||
height: calc(var(--size) * 0.06);
|
||||
margin: calc(var(--size) * -0.03);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #f5e7a3 0%, #d4af37 50%, #8b7229 100%);
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.jewel-inner {
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #fff 0%, #f5e7a3 100%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
let h1 = $derived(Math.floor(hours / 10));
|
||||
let h2 = $derived(hours % 10);
|
||||
let m1 = $derived(Math.floor(minutes / 10));
|
||||
let m2 = $derived(minutes % 10);
|
||||
let s1 = $derived(Math.floor(seconds / 10));
|
||||
let s2 = $derived(seconds % 10);
|
||||
</script>
|
||||
|
||||
<div class="clock-face-flip" style="--size: {size}px;">
|
||||
<div class="flip-container">
|
||||
<!-- Hours -->
|
||||
<div class="flip-group">
|
||||
<div class="flip-card">
|
||||
<div class="flip-top">
|
||||
<span>{h1}</span>
|
||||
</div>
|
||||
<div class="flip-bottom">
|
||||
<span>{h1}</span>
|
||||
</div>
|
||||
<div class="flip-hinge"></div>
|
||||
</div>
|
||||
<div class="flip-card">
|
||||
<div class="flip-top">
|
||||
<span>{h2}</span>
|
||||
</div>
|
||||
<div class="flip-bottom">
|
||||
<span>{h2}</span>
|
||||
</div>
|
||||
<div class="flip-hinge"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flip-separator">
|
||||
<div class="sep-dot"></div>
|
||||
<div class="sep-dot"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minutes -->
|
||||
<div class="flip-group">
|
||||
<div class="flip-card">
|
||||
<div class="flip-top">
|
||||
<span>{m1}</span>
|
||||
</div>
|
||||
<div class="flip-bottom">
|
||||
<span>{m1}</span>
|
||||
</div>
|
||||
<div class="flip-hinge"></div>
|
||||
</div>
|
||||
<div class="flip-card">
|
||||
<div class="flip-top">
|
||||
<span>{m2}</span>
|
||||
</div>
|
||||
<div class="flip-bottom">
|
||||
<span>{m2}</span>
|
||||
</div>
|
||||
<div class="flip-hinge"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flip-separator flip-separator-small">
|
||||
<div class="sep-dot sep-dot-small"></div>
|
||||
<div class="sep-dot sep-dot-small"></div>
|
||||
</div>
|
||||
|
||||
<!-- Seconds -->
|
||||
<div class="flip-group flip-group-small">
|
||||
<div class="flip-card flip-card-small">
|
||||
<div class="flip-top">
|
||||
<span>{s1}</span>
|
||||
</div>
|
||||
<div class="flip-bottom">
|
||||
<span>{s1}</span>
|
||||
</div>
|
||||
<div class="flip-hinge"></div>
|
||||
</div>
|
||||
<div class="flip-card flip-card-small">
|
||||
<div class="flip-top">
|
||||
<span>{s2}</span>
|
||||
</div>
|
||||
<div class="flip-bottom">
|
||||
<span>{s2}</span>
|
||||
</div>
|
||||
<div class="flip-hinge"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-flip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flip-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #0a0a0a 100%);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.flip-group {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.flip-card {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 64px;
|
||||
perspective: 300px;
|
||||
}
|
||||
|
||||
.flip-card-small {
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.flip-top,
|
||||
.flip-bottom {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.flip-top {
|
||||
top: 0;
|
||||
background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%);
|
||||
border-radius: 6px 6px 0 0;
|
||||
align-items: flex-end;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.flip-top span {
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
.flip-bottom {
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
border-radius: 0 0 6px 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.flip-bottom span {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.flip-top span,
|
||||
.flip-bottom span {
|
||||
font-family: 'Oswald', 'Bebas Neue', 'Impact', sans-serif;
|
||||
font-size: 48px;
|
||||
font-weight: 500;
|
||||
color: #f0f0f0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.flip-card-small .flip-top span,
|
||||
.flip-card-small .flip-bottom span {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.flip-hinge {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
margin-top: -1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(0, 0, 0, 0.8) 10%,
|
||||
rgba(0, 0, 0, 0.8) 90%,
|
||||
transparent 100%
|
||||
);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.flip-hinge::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 5%;
|
||||
right: 5%;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.flip-separator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.flip-separator-small {
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.sep-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #666;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.sep-dot-small {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.flip-group-small {
|
||||
align-self: flex-end;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
let timeString = $derived(
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
|
||||
);
|
||||
let secondsString = $derived(seconds.toString().padStart(2, '0'));
|
||||
|
||||
// Calculate progress through the day for gradient animation
|
||||
let dayProgress = $derived((hours * 3600 + minutes * 60 + seconds) / 86400);
|
||||
let hue = $derived(Math.round(dayProgress * 360));
|
||||
</script>
|
||||
|
||||
<div class="clock-face-gradient" style="--size: {size}px; --hue: {hue};">
|
||||
<div class="gradient-container">
|
||||
<!-- Animated background -->
|
||||
<div class="bg-gradient"></div>
|
||||
<div class="bg-overlay"></div>
|
||||
|
||||
<!-- Glass effect -->
|
||||
<div class="glass-effect"></div>
|
||||
|
||||
<!-- Time display -->
|
||||
<div class="time-wrapper">
|
||||
<span class="time-shadow">{timeString}</span>
|
||||
<span class="time-text">{timeString}</span>
|
||||
</div>
|
||||
|
||||
<!-- Seconds with different style -->
|
||||
<div class="seconds-wrapper">
|
||||
<span class="seconds-text">{secondsString}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar for seconds -->
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {(seconds / 60) * 100}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-gradient {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gradient-container {
|
||||
position: relative;
|
||||
padding: 24px 36px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--hue), 70%, 45%) 0%,
|
||||
hsl(calc(var(--hue) + 60), 70%, 35%) 50%,
|
||||
hsl(calc(var(--hue) + 120), 70%, 25%) 100%
|
||||
);
|
||||
transition: background 1s ease;
|
||||
}
|
||||
|
||||
.bg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 20%, rgba(255, 255, 255, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 80%, rgba(0, 0, 0, 0.2) 0%, transparent 50%);
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
rgba(255, 255, 255, 0.05) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
border-radius: 20px 20px 0 0;
|
||||
}
|
||||
|
||||
.time-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.time-shadow {
|
||||
position: absolute;
|
||||
font-family:
|
||||
'SF Pro Display',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
font-size: 3.5rem;
|
||||
font-weight: 200;
|
||||
letter-spacing: 0.05em;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
transform: translate(2px, 2px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
position: relative;
|
||||
font-family:
|
||||
'SF Pro Display',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
font-size: 3.5rem;
|
||||
font-weight: 200;
|
||||
letter-spacing: 0.05em;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seconds-wrapper {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.seconds-text {
|
||||
font-family:
|
||||
'SF Pro Display',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 300;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
margin-top: 16px;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s linear;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
|
||||
const numbers = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
</script>
|
||||
|
||||
<div class="clock-face-industrial" style="--size: {size}px;">
|
||||
<!-- Riveted metal frame -->
|
||||
<div class="frame">
|
||||
{#each Array(12) as _, i}
|
||||
<div class="rivet" style="transform: rotate({i * 30}deg)"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Metal dial -->
|
||||
<div class="clock-bg">
|
||||
<div class="brushed-texture"></div>
|
||||
<div class="inner-ring"></div>
|
||||
</div>
|
||||
|
||||
<!-- All tick marks -->
|
||||
{#each Array(60) as _, i}
|
||||
<div class="marker" class:marker-5={i % 5 === 0} style="transform: rotate({i * 6}deg)"></div>
|
||||
{/each}
|
||||
|
||||
<!-- Numbers with industrial font -->
|
||||
{#each numbers as num, i}
|
||||
<span class="number" style="--angle: {i * 30}deg;">
|
||||
{num.toString().padStart(2, '0')}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- Warning stripes decoration -->
|
||||
<div class="warning-ring"></div>
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)">
|
||||
<div class="hand-body"></div>
|
||||
<div class="hand-highlight"></div>
|
||||
</div>
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)">
|
||||
<div class="hand-body"></div>
|
||||
<div class="hand-highlight"></div>
|
||||
</div>
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center bolt -->
|
||||
<div class="center-bolt">
|
||||
<div class="bolt-slot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-industrial {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #5a5a5a 0%, #3d3d3d 50%, #2a2a2a 100%);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.4),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.1),
|
||||
inset 0 -2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.rivet {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.025);
|
||||
height: calc(var(--size) * 0.025);
|
||||
margin-left: calc(var(--size) * -0.0125);
|
||||
margin-top: calc(var(--size) * -0.48);
|
||||
background: radial-gradient(circle at 30% 30%, #888 0%, #444 100%);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.3),
|
||||
0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
transform-origin: center calc(var(--size) * 0.48);
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: calc(var(--size) * 0.045);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #e8e8e8 0%, #d0d0d0 50%, #b8b8b8 100%);
|
||||
box-shadow: inset 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brushed-texture {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 1px,
|
||||
rgba(255, 255, 255, 0.03) 1px,
|
||||
rgba(255, 255, 255, 0.03) 2px
|
||||
);
|
||||
}
|
||||
|
||||
.inner-ring {
|
||||
position: absolute;
|
||||
inset: 15%;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: calc(var(--size) * 0.03);
|
||||
margin-left: -1px;
|
||||
margin-top: calc(var(--size) * -0.43);
|
||||
background: #333;
|
||||
transform-origin: center calc(var(--size) * 0.43);
|
||||
}
|
||||
|
||||
.marker-5 {
|
||||
width: 4px;
|
||||
margin-left: -2px;
|
||||
height: calc(var(--size) * 0.05);
|
||||
background: #1a1a1a;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family: 'Share Tech Mono', 'Courier New', monospace;
|
||||
font-size: calc(var(--size) * 0.058);
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--size) * -0.33))
|
||||
rotate(calc(var(--angle) * -1));
|
||||
}
|
||||
|
||||
.warning-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 25%;
|
||||
height: 25%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: repeating-conic-gradient(from 0deg, #f59e0b 0deg 15deg, #1a1a1a 15deg 30deg);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.04);
|
||||
height: calc(var(--size) * 0.22);
|
||||
margin-left: calc(var(--size) * -0.02);
|
||||
margin-top: calc(var(--size) * -0.22);
|
||||
}
|
||||
|
||||
.hour-hand .hand-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2a2a2a 0%, #4a4a4a 50%, #2a2a2a 100%);
|
||||
clip-path: polygon(20% 0%, 80% 0%, 65% 100%, 35% 100%);
|
||||
}
|
||||
|
||||
.hour-hand .hand-highlight {
|
||||
position: absolute;
|
||||
top: 10%;
|
||||
left: 30%;
|
||||
width: 40%;
|
||||
height: 80%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
clip-path: polygon(0% 0%, 100% 0%, 80% 100%, 20% 100%);
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.03);
|
||||
height: calc(var(--size) * 0.32);
|
||||
margin-left: calc(var(--size) * -0.015);
|
||||
margin-top: calc(var(--size) * -0.32);
|
||||
}
|
||||
|
||||
.minute-hand .hand-body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #2a2a2a 0%, #4a4a4a 50%, #2a2a2a 100%);
|
||||
clip-path: polygon(15% 0%, 85% 0%, 60% 100%, 40% 100%);
|
||||
}
|
||||
|
||||
.minute-hand .hand-highlight {
|
||||
position: absolute;
|
||||
top: 8%;
|
||||
left: 25%;
|
||||
width: 50%;
|
||||
height: 85%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
clip-path: polygon(0% 0%, 100% 0%, 75% 100%, 25% 100%);
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.012);
|
||||
height: calc(var(--size) * 0.42);
|
||||
margin-left: calc(var(--size) * -0.006);
|
||||
margin-top: calc(var(--size) * -0.34);
|
||||
background: #f59e0b;
|
||||
border-radius: 1px;
|
||||
transform-origin: center 80.95%;
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.center-bolt {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.065);
|
||||
height: calc(var(--size) * 0.065);
|
||||
margin: calc(var(--size) * -0.0325);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #666 0%, #333 100%);
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bolt-slot {
|
||||
width: 60%;
|
||||
height: 3px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// 7-segment display mapping
|
||||
// Segments: a(top), b(topRight), c(bottomRight), d(bottom), e(bottomLeft), f(topLeft), g(middle)
|
||||
const segments: Record<string, string> = {
|
||||
'0': 'abcdef',
|
||||
'1': 'bc',
|
||||
'2': 'abdeg',
|
||||
'3': 'abcdg',
|
||||
'4': 'bcfg',
|
||||
'5': 'acdfg',
|
||||
'6': 'acdefg',
|
||||
'7': 'abc',
|
||||
'8': 'abcdefg',
|
||||
'9': 'abcdfg',
|
||||
};
|
||||
|
||||
let h1 = $derived(Math.floor(hours / 10).toString());
|
||||
let h2 = $derived((hours % 10).toString());
|
||||
let m1 = $derived(Math.floor(minutes / 10).toString());
|
||||
let m2 = $derived((minutes % 10).toString());
|
||||
let s1 = $derived(Math.floor(seconds / 10).toString());
|
||||
let s2 = $derived((seconds % 10).toString());
|
||||
|
||||
function isOn(digit: string, segment: string): boolean {
|
||||
return segments[digit]?.includes(segment) ?? false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="clock-face-lcd" style="--size: {size}px;">
|
||||
<div class="lcd-case">
|
||||
<div class="lcd-screen">
|
||||
<!-- Screen glare -->
|
||||
<div class="screen-glare"></div>
|
||||
|
||||
<!-- Digits container -->
|
||||
<div class="digits">
|
||||
<!-- Hours -->
|
||||
<div class="digit-group">
|
||||
<div class="digit">
|
||||
<div class="segment seg-a" class:on={isOn(h1, 'a')}></div>
|
||||
<div class="segment seg-b" class:on={isOn(h1, 'b')}></div>
|
||||
<div class="segment seg-c" class:on={isOn(h1, 'c')}></div>
|
||||
<div class="segment seg-d" class:on={isOn(h1, 'd')}></div>
|
||||
<div class="segment seg-e" class:on={isOn(h1, 'e')}></div>
|
||||
<div class="segment seg-f" class:on={isOn(h1, 'f')}></div>
|
||||
<div class="segment seg-g" class:on={isOn(h1, 'g')}></div>
|
||||
</div>
|
||||
<div class="digit">
|
||||
<div class="segment seg-a" class:on={isOn(h2, 'a')}></div>
|
||||
<div class="segment seg-b" class:on={isOn(h2, 'b')}></div>
|
||||
<div class="segment seg-c" class:on={isOn(h2, 'c')}></div>
|
||||
<div class="segment seg-d" class:on={isOn(h2, 'd')}></div>
|
||||
<div class="segment seg-e" class:on={isOn(h2, 'e')}></div>
|
||||
<div class="segment seg-f" class:on={isOn(h2, 'f')}></div>
|
||||
<div class="segment seg-g" class:on={isOn(h2, 'g')}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colon -->
|
||||
<div class="colon">
|
||||
<div class="colon-dot"></div>
|
||||
<div class="colon-dot"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minutes -->
|
||||
<div class="digit-group">
|
||||
<div class="digit">
|
||||
<div class="segment seg-a" class:on={isOn(m1, 'a')}></div>
|
||||
<div class="segment seg-b" class:on={isOn(m1, 'b')}></div>
|
||||
<div class="segment seg-c" class:on={isOn(m1, 'c')}></div>
|
||||
<div class="segment seg-d" class:on={isOn(m1, 'd')}></div>
|
||||
<div class="segment seg-e" class:on={isOn(m1, 'e')}></div>
|
||||
<div class="segment seg-f" class:on={isOn(m1, 'f')}></div>
|
||||
<div class="segment seg-g" class:on={isOn(m1, 'g')}></div>
|
||||
</div>
|
||||
<div class="digit">
|
||||
<div class="segment seg-a" class:on={isOn(m2, 'a')}></div>
|
||||
<div class="segment seg-b" class:on={isOn(m2, 'b')}></div>
|
||||
<div class="segment seg-c" class:on={isOn(m2, 'c')}></div>
|
||||
<div class="segment seg-d" class:on={isOn(m2, 'd')}></div>
|
||||
<div class="segment seg-e" class:on={isOn(m2, 'e')}></div>
|
||||
<div class="segment seg-f" class:on={isOn(m2, 'f')}></div>
|
||||
<div class="segment seg-g" class:on={isOn(m2, 'g')}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Small colon -->
|
||||
<div class="colon colon-small">
|
||||
<div class="colon-dot"></div>
|
||||
<div class="colon-dot"></div>
|
||||
</div>
|
||||
|
||||
<!-- Seconds (smaller) -->
|
||||
<div class="digit-group digit-group-small">
|
||||
<div class="digit digit-small">
|
||||
<div class="segment seg-a" class:on={isOn(s1, 'a')}></div>
|
||||
<div class="segment seg-b" class:on={isOn(s1, 'b')}></div>
|
||||
<div class="segment seg-c" class:on={isOn(s1, 'c')}></div>
|
||||
<div class="segment seg-d" class:on={isOn(s1, 'd')}></div>
|
||||
<div class="segment seg-e" class:on={isOn(s1, 'e')}></div>
|
||||
<div class="segment seg-f" class:on={isOn(s1, 'f')}></div>
|
||||
<div class="segment seg-g" class:on={isOn(s1, 'g')}></div>
|
||||
</div>
|
||||
<div class="digit digit-small">
|
||||
<div class="segment seg-a" class:on={isOn(s2, 'a')}></div>
|
||||
<div class="segment seg-b" class:on={isOn(s2, 'b')}></div>
|
||||
<div class="segment seg-c" class:on={isOn(s2, 'c')}></div>
|
||||
<div class="segment seg-d" class:on={isOn(s2, 'd')}></div>
|
||||
<div class="segment seg-e" class:on={isOn(s2, 'e')}></div>
|
||||
<div class="segment seg-f" class:on={isOn(s2, 'f')}></div>
|
||||
<div class="segment seg-g" class:on={isOn(s2, 'g')}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-lcd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lcd-case {
|
||||
background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 50%, #1a1a1a 100%);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.lcd-screen {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, #7a9a6a 0%, #5a7a4a 100%);
|
||||
border-radius: 6px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
:global(.dark) .lcd-screen {
|
||||
background: linear-gradient(180deg, #1a2a1a 0%, #0a1a0a 100%);
|
||||
}
|
||||
|
||||
.screen-glare {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.15) 0%, transparent 100%);
|
||||
border-radius: 6px 6px 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.digits {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.digit-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.digit {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.digit-small {
|
||||
width: 20px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
position: absolute;
|
||||
background: rgba(30, 60, 30, 0.2);
|
||||
transition:
|
||||
background-color 50ms,
|
||||
box-shadow 50ms;
|
||||
}
|
||||
|
||||
:global(.dark) .segment {
|
||||
background: rgba(0, 50, 0, 0.3);
|
||||
}
|
||||
|
||||
.segment.on {
|
||||
background: #2a3a20;
|
||||
}
|
||||
|
||||
:global(.dark) .segment.on {
|
||||
background: #00dd00;
|
||||
box-shadow: 0 0 6px #00dd00;
|
||||
}
|
||||
|
||||
/* Segment positions */
|
||||
.seg-a {
|
||||
top: 0;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
height: 4px;
|
||||
clip-path: polygon(15% 0%, 85% 0%, 100% 50%, 85% 100%, 15% 100%, 0% 50%);
|
||||
}
|
||||
|
||||
.seg-b {
|
||||
top: 3px;
|
||||
right: 0;
|
||||
width: 4px;
|
||||
height: 44%;
|
||||
clip-path: polygon(50% 0%, 100% 15%, 100% 85%, 50% 100%, 0% 85%, 0% 15%);
|
||||
}
|
||||
|
||||
.seg-c {
|
||||
top: 53%;
|
||||
right: 0;
|
||||
width: 4px;
|
||||
height: 44%;
|
||||
clip-path: polygon(50% 0%, 100% 15%, 100% 85%, 50% 100%, 0% 85%, 0% 15%);
|
||||
}
|
||||
|
||||
.seg-d {
|
||||
bottom: 0;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
height: 4px;
|
||||
clip-path: polygon(15% 0%, 85% 0%, 100% 50%, 85% 100%, 15% 100%, 0% 50%);
|
||||
}
|
||||
|
||||
.seg-e {
|
||||
top: 53%;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 44%;
|
||||
clip-path: polygon(50% 0%, 100% 15%, 100% 85%, 50% 100%, 0% 85%, 0% 15%);
|
||||
}
|
||||
|
||||
.seg-f {
|
||||
top: 3px;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 44%;
|
||||
clip-path: polygon(50% 0%, 100% 15%, 100% 85%, 50% 100%, 0% 85%, 0% 15%);
|
||||
}
|
||||
|
||||
.seg-g {
|
||||
top: 50%;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
height: 4px;
|
||||
margin-top: -2px;
|
||||
clip-path: polygon(15% 0%, 85% 0%, 100% 50%, 85% 100%, 15% 100%, 0% 50%);
|
||||
}
|
||||
|
||||
.colon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.colon-small {
|
||||
gap: 8px;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
.colon-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: #2a3a20;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
:global(.dark) .colon-dot {
|
||||
background: #00dd00;
|
||||
box-shadow: 0 0 4px #00dd00;
|
||||
}
|
||||
|
||||
.digit-group-small {
|
||||
align-self: flex-end;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.digit-small .seg-a,
|
||||
.digit-small .seg-d,
|
||||
.digit-small .seg-g {
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.digit-small .seg-b,
|
||||
.digit-small .seg-c,
|
||||
.digit-small .seg-e,
|
||||
.digit-small .seg-f {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.digit-small .seg-g {
|
||||
margin-top: -1.5px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// 5x7 dot matrix patterns for digits 0-9
|
||||
const patterns: Record<string, number[][]> = {
|
||||
'0': [
|
||||
[0, 1, 1, 1, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[1, 0, 0, 1, 1],
|
||||
[1, 0, 1, 0, 1],
|
||||
[1, 1, 0, 0, 1],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
'1': [
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 1, 1, 0, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
'2': [
|
||||
[0, 1, 1, 1, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 0, 0, 0, 1],
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 1, 0, 0, 0],
|
||||
[1, 1, 1, 1, 1],
|
||||
],
|
||||
'3': [
|
||||
[0, 1, 1, 1, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 0, 0, 0, 1],
|
||||
[0, 0, 1, 1, 0],
|
||||
[0, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
'4': [
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 0, 1, 1, 0],
|
||||
[0, 1, 0, 1, 0],
|
||||
[1, 0, 0, 1, 0],
|
||||
[1, 1, 1, 1, 1],
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 0, 0, 1, 0],
|
||||
],
|
||||
'5': [
|
||||
[1, 1, 1, 1, 1],
|
||||
[1, 0, 0, 0, 0],
|
||||
[1, 1, 1, 1, 0],
|
||||
[0, 0, 0, 0, 1],
|
||||
[0, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
'6': [
|
||||
[0, 0, 1, 1, 0],
|
||||
[0, 1, 0, 0, 0],
|
||||
[1, 0, 0, 0, 0],
|
||||
[1, 1, 1, 1, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
'7': [
|
||||
[1, 1, 1, 1, 1],
|
||||
[0, 0, 0, 0, 1],
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 0, 1, 0, 0],
|
||||
[0, 1, 0, 0, 0],
|
||||
[0, 1, 0, 0, 0],
|
||||
[0, 1, 0, 0, 0],
|
||||
],
|
||||
'8': [
|
||||
[0, 1, 1, 1, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 0],
|
||||
],
|
||||
'9': [
|
||||
[0, 1, 1, 1, 0],
|
||||
[1, 0, 0, 0, 1],
|
||||
[1, 0, 0, 0, 1],
|
||||
[0, 1, 1, 1, 1],
|
||||
[0, 0, 0, 0, 1],
|
||||
[0, 0, 0, 1, 0],
|
||||
[0, 1, 1, 0, 0],
|
||||
],
|
||||
};
|
||||
|
||||
const colonPattern = [[0], [1], [0], [0], [0], [1], [0]];
|
||||
|
||||
let h1 = $derived(Math.floor(hours / 10).toString());
|
||||
let h2 = $derived((hours % 10).toString());
|
||||
let m1 = $derived(Math.floor(minutes / 10).toString());
|
||||
let m2 = $derived((minutes % 10).toString());
|
||||
let s1 = $derived(Math.floor(seconds / 10).toString());
|
||||
let s2 = $derived((seconds % 10).toString());
|
||||
</script>
|
||||
|
||||
<div class="clock-face-matrix" style="--size: {size}px;">
|
||||
<div class="matrix-display">
|
||||
<div class="matrix-screen">
|
||||
<div class="screen-overlay"></div>
|
||||
<div class="digits-container">
|
||||
<!-- Hours -->
|
||||
<div class="digit-matrix">
|
||||
{#each patterns[h1] as row}
|
||||
<div class="dot-row">
|
||||
{#each row as dot}
|
||||
<div class="dot" class:on={dot === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="digit-matrix">
|
||||
{#each patterns[h2] as row}
|
||||
<div class="dot-row">
|
||||
{#each row as dot}
|
||||
<div class="dot" class:on={dot === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Colon -->
|
||||
<div class="colon-matrix">
|
||||
{#each colonPattern as row}
|
||||
<div class="dot-row">
|
||||
{#each row as dot}
|
||||
<div class="dot dot-colon" class:on={dot === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Minutes -->
|
||||
<div class="digit-matrix">
|
||||
{#each patterns[m1] as row}
|
||||
<div class="dot-row">
|
||||
{#each row as dot}
|
||||
<div class="dot" class:on={dot === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="digit-matrix">
|
||||
{#each patterns[m2] as row}
|
||||
<div class="dot-row">
|
||||
{#each row as dot}
|
||||
<div class="dot" class:on={dot === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Colon -->
|
||||
<div class="colon-matrix">
|
||||
{#each colonPattern as row}
|
||||
<div class="dot-row">
|
||||
{#each row as dot}
|
||||
<div class="dot dot-colon" class:on={dot === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Seconds -->
|
||||
<div class="digit-matrix digit-small">
|
||||
{#each patterns[s1] as row}
|
||||
<div class="dot-row">
|
||||
{#each row as dot}
|
||||
<div class="dot dot-small" class:on={dot === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="digit-matrix digit-small">
|
||||
{#each patterns[s2] as row}
|
||||
<div class="dot-row">
|
||||
{#each row as dot}
|
||||
<div class="dot dot-small" class:on={dot === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-matrix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.matrix-display {
|
||||
background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.matrix-screen {
|
||||
position: relative;
|
||||
background: #0a0a0a;
|
||||
border-radius: 6px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.screen-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.03) 0%,
|
||||
transparent 50%,
|
||||
rgba(0, 0, 0, 0.1) 100%
|
||||
);
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.digits-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.digit-matrix {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.digit-small {
|
||||
transform: scale(0.7);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
.dot-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
transition: all 80ms;
|
||||
}
|
||||
|
||||
.dot.on {
|
||||
background: #ff3333;
|
||||
box-shadow:
|
||||
0 0 4px #ff3333,
|
||||
0 0 8px #ff3333,
|
||||
0 0 12px rgba(255, 51, 51, 0.5);
|
||||
}
|
||||
|
||||
.dot-small {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.dot-colon {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.colon-matrix {
|
||||
padding: 0 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
</script>
|
||||
|
||||
<div class="clock-face-minimalist" style="--size: {size}px;">
|
||||
<!-- Clean background -->
|
||||
<div class="clock-bg"></div>
|
||||
|
||||
<!-- Only 12 subtle hour markers as thin lines -->
|
||||
{#each Array(12) as _, i}
|
||||
<div
|
||||
class="marker"
|
||||
class:marker-main={i % 3 === 0}
|
||||
style="transform: rotate({i * 30}deg)"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<!-- Hour hand - thick, short -->
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)"></div>
|
||||
|
||||
<!-- Minute hand - thinner, longer -->
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)"></div>
|
||||
|
||||
<!-- Second hand - very thin, accent color -->
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)">
|
||||
<div class="second-line"></div>
|
||||
<div class="second-circle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimal center dot -->
|
||||
<div class="center-dot"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-minimalist {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-surface));
|
||||
box-shadow:
|
||||
0 4px 20px rgba(0, 0, 0, 0.08),
|
||||
0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: calc(var(--size) * 0.06);
|
||||
margin-left: -0.5px;
|
||||
margin-top: calc(var(--size) * -0.47);
|
||||
background: hsl(var(--color-muted-foreground) / 0.25);
|
||||
transform-origin: center calc(var(--size) * 0.47);
|
||||
}
|
||||
|
||||
.marker-main {
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
height: calc(var(--size) * 0.08);
|
||||
margin-top: calc(var(--size) * -0.47);
|
||||
background: hsl(var(--color-foreground) / 0.6);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.025);
|
||||
height: calc(var(--size) * 0.24);
|
||||
margin-left: calc(var(--size) * -0.0125);
|
||||
margin-top: calc(var(--size) * -0.24);
|
||||
background: hsl(var(--color-foreground));
|
||||
border-radius: calc(var(--size) * 0.0125);
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.015);
|
||||
height: calc(var(--size) * 0.34);
|
||||
margin-left: calc(var(--size) * -0.0075);
|
||||
margin-top: calc(var(--size) * -0.34);
|
||||
background: hsl(var(--color-foreground));
|
||||
border-radius: calc(var(--size) * 0.0075);
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.006);
|
||||
height: calc(var(--size) * 0.42);
|
||||
margin-left: calc(var(--size) * -0.003);
|
||||
margin-top: calc(var(--size) * -0.34);
|
||||
transform-origin: center 80.95%;
|
||||
}
|
||||
|
||||
.second-line {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 80%;
|
||||
background: hsl(var(--color-primary));
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.second-circle {
|
||||
position: absolute;
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(var(--size) * 0.025);
|
||||
height: calc(var(--size) * 0.025);
|
||||
background: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.center-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.035);
|
||||
height: calc(var(--size) * 0.035);
|
||||
margin: calc(var(--size) * -0.0175);
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
|
||||
// Arabic numerals
|
||||
const numbers = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
</script>
|
||||
|
||||
<div class="clock-face-modern" style="--size: {size}px;">
|
||||
<!-- Background with subtle gradient -->
|
||||
<div class="clock-bg">
|
||||
<div class="inner-shadow"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minute tick marks -->
|
||||
{#each Array(60) as _, i}
|
||||
{#if i % 5 !== 0}
|
||||
<div class="tick tick-minute" style="transform: rotate({i * 6}deg)"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Arabic numbers -->
|
||||
{#each numbers as num, i}
|
||||
<span class="number" style="--angle: {i * 30}deg;">
|
||||
{num}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)"></div>
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)"></div>
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)">
|
||||
<div class="second-body"></div>
|
||||
<div class="second-tail"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center dot -->
|
||||
<div class="center-cap">
|
||||
<div class="center-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-modern {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 3px solid hsl(var(--color-border));
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.1),
|
||||
0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inner-shadow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.tick-minute {
|
||||
width: 2px;
|
||||
height: calc(var(--size) * 0.025);
|
||||
margin-left: -1px;
|
||||
margin-top: calc(var(--size) * -0.46);
|
||||
background: hsl(var(--color-muted-foreground) / 0.3);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: calc(var(--size) * 0.075);
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--size) * -0.36))
|
||||
rotate(calc(var(--angle) * -1));
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.028);
|
||||
height: calc(var(--size) * 0.26);
|
||||
margin-left: calc(var(--size) * -0.014);
|
||||
margin-top: calc(var(--size) * -0.26);
|
||||
background: hsl(var(--color-foreground));
|
||||
border-radius: calc(var(--size) * 0.014);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.02);
|
||||
height: calc(var(--size) * 0.36);
|
||||
margin-left: calc(var(--size) * -0.01);
|
||||
margin-top: calc(var(--size) * -0.36);
|
||||
background: hsl(var(--color-foreground));
|
||||
border-radius: calc(var(--size) * 0.01);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.008);
|
||||
height: calc(var(--size) * 0.46);
|
||||
margin-left: calc(var(--size) * -0.004);
|
||||
margin-top: calc(var(--size) * -0.36);
|
||||
transform-origin: center 78.26%;
|
||||
}
|
||||
|
||||
.second-body {
|
||||
position: absolute;
|
||||
top: 22%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 78%;
|
||||
background: hsl(var(--color-primary));
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.second-tail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 180%;
|
||||
height: 22%;
|
||||
background: hsl(var(--color-primary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.center-cap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.055);
|
||||
height: calc(var(--size) * 0.055);
|
||||
margin: calc(var(--size) * -0.0275);
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-foreground));
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.center-inner {
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
|
||||
// Numbers
|
||||
const numbers = [12, 3, 6, 9];
|
||||
const angles = [0, 90, 180, 270];
|
||||
</script>
|
||||
|
||||
<div class="clock-face-nautical" style="--size: {size}px;">
|
||||
<!-- Brass bezel -->
|
||||
<div class="bezel">
|
||||
<div class="bezel-inner"></div>
|
||||
</div>
|
||||
|
||||
<!-- White enamel dial -->
|
||||
<div class="clock-bg">
|
||||
<div class="dial-texture"></div>
|
||||
</div>
|
||||
|
||||
<!-- Compass rose decoration -->
|
||||
<div class="compass-rose">
|
||||
{#each Array(8) as _, i}
|
||||
<div class="compass-point" style="transform: rotate({i * 45}deg)"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Minute markers -->
|
||||
{#each Array(60) as _, i}
|
||||
<div
|
||||
class="marker"
|
||||
class:marker-5={i % 5 === 0 && i % 15 !== 0}
|
||||
class:marker-15={i % 15 === 0}
|
||||
style="transform: rotate({i * 6}deg)"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
<!-- Cardinal numbers -->
|
||||
{#each numbers as num, i}
|
||||
<span class="number" style="--angle: {angles[i]}deg;">
|
||||
{num}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- Ship's wheel decoration -->
|
||||
<div class="ships-wheel">
|
||||
{#each Array(8) as _, i}
|
||||
<div class="spoke" style="transform: rotate({i * 45}deg)"></div>
|
||||
{/each}
|
||||
<div class="wheel-hub"></div>
|
||||
</div>
|
||||
|
||||
<!-- Brand text -->
|
||||
<div class="brand">MARINE</div>
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)"></div>
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)"></div>
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center cap -->
|
||||
<div class="center-cap"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-nautical {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bezel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#d4a84b 0%,
|
||||
#b8860b 20%,
|
||||
#cd9b1d 40%,
|
||||
#b8860b 60%,
|
||||
#d4a84b 80%,
|
||||
#b8860b 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.bezel-inner {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #cd9b1d 0%, #b8860b 50%, #8b6914 100%);
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: calc(var(--size) * 0.04);
|
||||
border-radius: 50%;
|
||||
background: #f8f8f0;
|
||||
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dial-texture {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 50%, transparent 60%, rgba(184, 134, 11, 0.03) 100%);
|
||||
}
|
||||
|
||||
.compass-rose {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.compass-point {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 35%;
|
||||
margin-left: -0.5px;
|
||||
margin-top: -35%;
|
||||
background: rgba(184, 134, 11, 0.08);
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: calc(var(--size) * 0.02);
|
||||
margin-left: -0.5px;
|
||||
margin-top: calc(var(--size) * -0.44);
|
||||
background: #1a365d;
|
||||
opacity: 0.4;
|
||||
transform-origin: center calc(var(--size) * 0.44);
|
||||
}
|
||||
|
||||
.marker-5 {
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
height: calc(var(--size) * 0.035);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.marker-15 {
|
||||
width: 3px;
|
||||
margin-left: -1.5px;
|
||||
height: calc(var(--size) * 0.05);
|
||||
opacity: 0.8;
|
||||
background: #1a365d;
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family: 'Cinzel', 'Times New Roman', serif;
|
||||
font-size: calc(var(--size) * 0.085);
|
||||
font-weight: 700;
|
||||
color: #1a365d;
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--size) * -0.32))
|
||||
rotate(calc(var(--angle) * -1));
|
||||
}
|
||||
|
||||
.ships-wheel {
|
||||
position: absolute;
|
||||
top: 62%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.14);
|
||||
height: calc(var(--size) * 0.14);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.spoke {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 50%;
|
||||
margin-left: -1px;
|
||||
margin-top: -50%;
|
||||
background: #b8860b;
|
||||
opacity: 0.4;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.wheel-hub {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(184, 134, 11, 0.4);
|
||||
}
|
||||
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 28%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: 'Cinzel', 'Times New Roman', serif;
|
||||
font-size: calc(var(--size) * 0.035);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.25em;
|
||||
color: #1a365d;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.032);
|
||||
height: calc(var(--size) * 0.24);
|
||||
margin-left: calc(var(--size) * -0.016);
|
||||
margin-top: calc(var(--size) * -0.24);
|
||||
background: #1a365d;
|
||||
clip-path: polygon(30% 0%, 70% 0%, 55% 100%, 45% 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.024);
|
||||
height: calc(var(--size) * 0.34);
|
||||
margin-left: calc(var(--size) * -0.012);
|
||||
margin-top: calc(var(--size) * -0.34);
|
||||
background: #1a365d;
|
||||
clip-path: polygon(25% 0%, 75% 0%, 55% 100%, 45% 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.008);
|
||||
height: calc(var(--size) * 0.4);
|
||||
margin-left: calc(var(--size) * -0.004);
|
||||
margin-top: calc(var(--size) * -0.32);
|
||||
background: #b8860b;
|
||||
border-radius: 1px;
|
||||
transform-origin: center 80%;
|
||||
}
|
||||
|
||||
.center-cap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.05);
|
||||
height: calc(var(--size) * 0.05);
|
||||
margin: calc(var(--size) * -0.025);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #d4a84b 0%, #b8860b 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
let timeString = $derived(
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
|
||||
);
|
||||
let secondsString = $derived(seconds.toString().padStart(2, '0'));
|
||||
</script>
|
||||
|
||||
<div class="clock-face-neon" style="--size: {size}px;">
|
||||
<div class="neon-container">
|
||||
<!-- Background glow -->
|
||||
<div class="bg-glow"></div>
|
||||
|
||||
<!-- Main time -->
|
||||
<div class="time-wrapper">
|
||||
<span class="neon-glow">{timeString}</span>
|
||||
<span class="neon-blur">{timeString}</span>
|
||||
<span class="neon-text">{timeString}</span>
|
||||
</div>
|
||||
|
||||
<!-- Seconds (smaller, different color) -->
|
||||
<div class="seconds-wrapper">
|
||||
<span class="seconds-glow">{secondsString}</span>
|
||||
<span class="seconds-blur">{secondsString}</span>
|
||||
<span class="seconds-text">{secondsString}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-neon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.neon-container {
|
||||
position: relative;
|
||||
padding: 20px 32px;
|
||||
background: linear-gradient(180deg, #0a0a0a 0%, #050510 100%);
|
||||
border-radius: 12px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.03);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bg-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, rgba(255, 0, 255, 0.05) 0%, transparent 70%);
|
||||
border-radius: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.time-wrapper,
|
||||
.seconds-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.neon-glow,
|
||||
.neon-blur,
|
||||
.neon-text {
|
||||
font-family: 'Orbitron', 'Audiowide', 'Rajdhani', sans-serif;
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.neon-glow {
|
||||
position: absolute;
|
||||
color: transparent;
|
||||
text-shadow:
|
||||
0 0 60px #ff00ff,
|
||||
0 0 100px #ff00ff,
|
||||
0 0 140px #ff00ff;
|
||||
filter: blur(20px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.neon-blur {
|
||||
position: absolute;
|
||||
color: #ff00ff;
|
||||
text-shadow:
|
||||
0 0 10px #ff00ff,
|
||||
0 0 20px #ff00ff,
|
||||
0 0 40px #ff00ff;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.neon-text {
|
||||
position: relative;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 0 5px #fff,
|
||||
0 0 10px #ff80ff,
|
||||
0 0 20px #ff00ff;
|
||||
animation: flicker 4s infinite;
|
||||
}
|
||||
|
||||
.seconds-glow,
|
||||
.seconds-blur,
|
||||
.seconds-text {
|
||||
font-family: 'Orbitron', 'Audiowide', 'Rajdhani', sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.seconds-glow {
|
||||
position: absolute;
|
||||
color: transparent;
|
||||
text-shadow:
|
||||
0 0 40px #00ffff,
|
||||
0 0 80px #00ffff;
|
||||
filter: blur(15px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.seconds-blur {
|
||||
position: absolute;
|
||||
color: #00ffff;
|
||||
text-shadow:
|
||||
0 0 8px #00ffff,
|
||||
0 0 16px #00ffff;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.seconds-text {
|
||||
position: relative;
|
||||
color: #fff;
|
||||
text-shadow:
|
||||
0 0 3px #fff,
|
||||
0 0 8px #80ffff,
|
||||
0 0 15px #00ffff;
|
||||
animation: flicker 4s infinite 0.5s;
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
92% {
|
||||
opacity: 1;
|
||||
}
|
||||
93% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
94% {
|
||||
opacity: 1;
|
||||
}
|
||||
95% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
96% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme-based color variations */
|
||||
:global([data-theme='forest']) .neon-glow {
|
||||
text-shadow:
|
||||
0 0 60px #00ff66,
|
||||
0 0 100px #00ff66,
|
||||
0 0 140px #00ff66;
|
||||
}
|
||||
:global([data-theme='forest']) .neon-blur {
|
||||
color: #00ff66;
|
||||
text-shadow:
|
||||
0 0 10px #00ff66,
|
||||
0 0 20px #00ff66,
|
||||
0 0 40px #00ff66;
|
||||
}
|
||||
:global([data-theme='forest']) .neon-text {
|
||||
text-shadow:
|
||||
0 0 5px #fff,
|
||||
0 0 10px #80ff99,
|
||||
0 0 20px #00ff66;
|
||||
}
|
||||
:global([data-theme='forest']) .bg-glow {
|
||||
background: radial-gradient(ellipse at center, rgba(0, 255, 102, 0.05) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
:global([data-theme='ocean']) .neon-glow {
|
||||
text-shadow:
|
||||
0 0 60px #0099ff,
|
||||
0 0 100px #0099ff,
|
||||
0 0 140px #0099ff;
|
||||
}
|
||||
:global([data-theme='ocean']) .neon-blur {
|
||||
color: #0099ff;
|
||||
text-shadow:
|
||||
0 0 10px #0099ff,
|
||||
0 0 20px #0099ff,
|
||||
0 0 40px #0099ff;
|
||||
}
|
||||
:global([data-theme='ocean']) .neon-text {
|
||||
text-shadow:
|
||||
0 0 5px #fff,
|
||||
0 0 10px #80ccff,
|
||||
0 0 20px #0099ff;
|
||||
}
|
||||
:global([data-theme='ocean']) .bg-glow {
|
||||
background: radial-gradient(ellipse at center, rgba(0, 153, 255, 0.05) 0%, transparent 70%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
let timeString = $derived(
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
);
|
||||
|
||||
// Radar sweep angle based on seconds
|
||||
let sweepAngle = $derived((seconds / 60) * 360);
|
||||
</script>
|
||||
|
||||
<div class="clock-face-radar" style="--size: {size}px;">
|
||||
<div class="radar-display">
|
||||
<!-- Radar screen -->
|
||||
<div class="radar-screen">
|
||||
<!-- Grid lines -->
|
||||
<div class="grid-horizontal"></div>
|
||||
<div class="grid-vertical"></div>
|
||||
<div class="grid-circle circle-1"></div>
|
||||
<div class="grid-circle circle-2"></div>
|
||||
<div class="grid-circle circle-3"></div>
|
||||
|
||||
<!-- Sweep line -->
|
||||
<div class="sweep-container" style="transform: rotate({sweepAngle}deg)">
|
||||
<div class="sweep-line"></div>
|
||||
<div class="sweep-glow"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center dot -->
|
||||
<div class="center-dot"></div>
|
||||
|
||||
<!-- Time blips -->
|
||||
<div class="blip blip-hours" style="--angle: {(hours % 12) * 30}deg;">
|
||||
<div class="blip-dot"></div>
|
||||
</div>
|
||||
<div class="blip blip-minutes" style="--angle: {minutes * 6}deg;">
|
||||
<div class="blip-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Digital readout -->
|
||||
<div class="digital-readout">
|
||||
<span class="readout-label">TIME</span>
|
||||
<span class="readout-value">{timeString}</span>
|
||||
</div>
|
||||
|
||||
<!-- Status indicators -->
|
||||
<div class="status-row">
|
||||
<div class="status">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text">SYNC</span>
|
||||
</div>
|
||||
<div class="status">
|
||||
<span class="status-dot active"></span>
|
||||
<span class="status-text">TRAC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-radar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radar-display {
|
||||
background: linear-gradient(180deg, #0a1a0a 0%, #051505 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 1px rgba(0, 255, 0, 0.05);
|
||||
}
|
||||
|
||||
.radar-screen {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: radial-gradient(circle at center, #0a2a0a 0%, #051505 70%, #030a03 100%);
|
||||
border-radius: 50%;
|
||||
border: 2px solid #0f3f0f;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 30px rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.grid-horizontal,
|
||||
.grid-vertical {
|
||||
position: absolute;
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.grid-horizontal {
|
||||
top: 50%;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
height: 1px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.grid-vertical {
|
||||
left: 50%;
|
||||
top: 10%;
|
||||
bottom: 10%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.grid-circle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
.circle-1 {
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.circle-2 {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.circle-3 {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.sweep-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
height: 2px;
|
||||
transform-origin: left center;
|
||||
transition: transform 1s linear;
|
||||
}
|
||||
|
||||
.sweep-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, rgba(0, 255, 0, 0.8) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.sweep-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
transform: translateY(-50%);
|
||||
background: linear-gradient(90deg, rgba(0, 255, 0, 0.2) 0%, transparent 100%);
|
||||
clip-path: polygon(0% 50%, 100% 0%, 100% 100%);
|
||||
}
|
||||
|
||||
.center-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #00ff00;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px #00ff00;
|
||||
}
|
||||
|
||||
.blip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 35%;
|
||||
height: 2px;
|
||||
transform-origin: left center;
|
||||
transform: rotate(var(--angle));
|
||||
}
|
||||
|
||||
.blip-dot {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #00ff00;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px #00ff00;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.blip-hours .blip-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #00ffaa;
|
||||
box-shadow: 0 0 8px #00ffaa;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.digital-readout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 255, 0, 0.05);
|
||||
border: 1px solid rgba(0, 255, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.readout-label {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
color: rgba(0, 255, 0, 0.5);
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
.readout-value {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #00ff00;
|
||||
text-shadow: 0 0 8px rgba(0, 255, 0, 0.5);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: #00ff00;
|
||||
box-shadow: 0 0 4px #00ff00;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 0.6rem;
|
||||
color: rgba(0, 255, 0, 0.6);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
|
||||
const numbers = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
</script>
|
||||
|
||||
<div class="clock-face-railway" style="--size: {size}px;">
|
||||
<!-- Black bezel -->
|
||||
<div class="bezel"></div>
|
||||
|
||||
<!-- White dial -->
|
||||
<div class="clock-bg"></div>
|
||||
|
||||
<!-- Minute markers -->
|
||||
{#each Array(60) as _, i}
|
||||
{#if i % 5 !== 0}
|
||||
<div class="tick tick-minute" style="transform: rotate({i * 6}deg)"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Hour markers - bold rectangles -->
|
||||
{#each Array(12) as _, i}
|
||||
<div class="tick tick-hour" style="transform: rotate({i * 30}deg)"></div>
|
||||
{/each}
|
||||
|
||||
<!-- Numbers -->
|
||||
{#each numbers as num, i}
|
||||
<span class="number" style="--angle: {i * 30}deg;">
|
||||
{num}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- SBB/CFF brand mark -->
|
||||
<div class="brand">SBB CFF FFS</div>
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<!-- Hour hand - rounded rectangle -->
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)"></div>
|
||||
|
||||
<!-- Minute hand - longer rounded rectangle -->
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)"></div>
|
||||
|
||||
<!-- Second hand - distinctive red circle (Mondaine style) -->
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)">
|
||||
<div class="second-shaft"></div>
|
||||
<div class="second-lollipop"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center cap -->
|
||||
<div class="center-cap"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-railway {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bezel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: calc(var(--size) * 0.03);
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.tick-minute {
|
||||
width: 2px;
|
||||
height: calc(var(--size) * 0.025);
|
||||
margin-left: -1px;
|
||||
margin-top: calc(var(--size) * -0.44);
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.tick-hour {
|
||||
width: calc(var(--size) * 0.025);
|
||||
height: calc(var(--size) * 0.065);
|
||||
margin-left: calc(var(--size) * -0.0125);
|
||||
margin-top: calc(var(--size) * -0.44);
|
||||
background: #1a1a1a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: calc(var(--size) * 0.075);
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--size) * -0.32))
|
||||
rotate(calc(var(--angle) * -1));
|
||||
}
|
||||
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 68%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-size: calc(var(--size) * 0.025);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.045);
|
||||
height: calc(var(--size) * 0.23);
|
||||
margin-left: calc(var(--size) * -0.0225);
|
||||
margin-top: calc(var(--size) * -0.23);
|
||||
background: #1a1a1a;
|
||||
border-radius: calc(var(--size) * 0.0225);
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.035);
|
||||
height: calc(var(--size) * 0.33);
|
||||
margin-left: calc(var(--size) * -0.0175);
|
||||
margin-top: calc(var(--size) * -0.33);
|
||||
background: #1a1a1a;
|
||||
border-radius: calc(var(--size) * 0.0175);
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.012);
|
||||
height: calc(var(--size) * 0.42);
|
||||
margin-left: calc(var(--size) * -0.006);
|
||||
margin-top: calc(var(--size) * -0.36);
|
||||
transform-origin: center 85.71%;
|
||||
}
|
||||
|
||||
.second-shaft {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(var(--size) * 0.008);
|
||||
height: 85%;
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.second-lollipop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(var(--size) * 0.055);
|
||||
height: calc(var(--size) * 0.055);
|
||||
background: #dc2626;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.center-cap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.04);
|
||||
height: calc(var(--size) * 0.04);
|
||||
margin: calc(var(--size) * -0.02);
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// 3x5 pixel font patterns
|
||||
const pixelPatterns: Record<string, number[][]> = {
|
||||
'0': [
|
||||
[1, 1, 1],
|
||||
[1, 0, 1],
|
||||
[1, 0, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
],
|
||||
'1': [
|
||||
[0, 1, 0],
|
||||
[1, 1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 1, 0],
|
||||
[1, 1, 1],
|
||||
],
|
||||
'2': [
|
||||
[1, 1, 1],
|
||||
[0, 0, 1],
|
||||
[1, 1, 1],
|
||||
[1, 0, 0],
|
||||
[1, 1, 1],
|
||||
],
|
||||
'3': [
|
||||
[1, 1, 1],
|
||||
[0, 0, 1],
|
||||
[1, 1, 1],
|
||||
[0, 0, 1],
|
||||
[1, 1, 1],
|
||||
],
|
||||
'4': [
|
||||
[1, 0, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
[0, 0, 1],
|
||||
[0, 0, 1],
|
||||
],
|
||||
'5': [
|
||||
[1, 1, 1],
|
||||
[1, 0, 0],
|
||||
[1, 1, 1],
|
||||
[0, 0, 1],
|
||||
[1, 1, 1],
|
||||
],
|
||||
'6': [
|
||||
[1, 1, 1],
|
||||
[1, 0, 0],
|
||||
[1, 1, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
],
|
||||
'7': [
|
||||
[1, 1, 1],
|
||||
[0, 0, 1],
|
||||
[0, 0, 1],
|
||||
[0, 0, 1],
|
||||
[0, 0, 1],
|
||||
],
|
||||
'8': [
|
||||
[1, 1, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
],
|
||||
'9': [
|
||||
[1, 1, 1],
|
||||
[1, 0, 1],
|
||||
[1, 1, 1],
|
||||
[0, 0, 1],
|
||||
[1, 1, 1],
|
||||
],
|
||||
};
|
||||
|
||||
const colonPattern = [[0], [1], [0], [1], [0]];
|
||||
|
||||
let h1 = $derived(Math.floor(hours / 10).toString());
|
||||
let h2 = $derived((hours % 10).toString());
|
||||
let m1 = $derived(Math.floor(minutes / 10).toString());
|
||||
let m2 = $derived((minutes % 10).toString());
|
||||
let s1 = $derived(Math.floor(seconds / 10).toString());
|
||||
let s2 = $derived((seconds % 10).toString());
|
||||
</script>
|
||||
|
||||
<div class="clock-face-retro" style="--size: {size}px;">
|
||||
<div class="retro-case">
|
||||
<!-- CRT screen effect -->
|
||||
<div class="crt-screen">
|
||||
<div class="scanlines"></div>
|
||||
<div class="crt-glow"></div>
|
||||
|
||||
<div class="pixels-container">
|
||||
<!-- Hours -->
|
||||
<div class="digit-pixels">
|
||||
{#each pixelPatterns[h1] as row}
|
||||
<div class="pixel-row">
|
||||
{#each row as pixel}
|
||||
<div class="pixel" class:on={pixel === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="digit-pixels">
|
||||
{#each pixelPatterns[h2] as row}
|
||||
<div class="pixel-row">
|
||||
{#each row as pixel}
|
||||
<div class="pixel" class:on={pixel === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Colon -->
|
||||
<div class="colon-pixels">
|
||||
{#each colonPattern as row}
|
||||
<div class="pixel-row">
|
||||
{#each row as pixel}
|
||||
<div class="pixel pixel-colon" class:on={pixel === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Minutes -->
|
||||
<div class="digit-pixels">
|
||||
{#each pixelPatterns[m1] as row}
|
||||
<div class="pixel-row">
|
||||
{#each row as pixel}
|
||||
<div class="pixel" class:on={pixel === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="digit-pixels">
|
||||
{#each pixelPatterns[m2] as row}
|
||||
<div class="pixel-row">
|
||||
{#each row as pixel}
|
||||
<div class="pixel" class:on={pixel === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Small colon -->
|
||||
<div class="colon-pixels colon-small">
|
||||
{#each colonPattern as row}
|
||||
<div class="pixel-row">
|
||||
{#each row as pixel}
|
||||
<div class="pixel pixel-small" class:on={pixel === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Seconds -->
|
||||
<div class="digit-pixels digit-small">
|
||||
{#each pixelPatterns[s1] as row}
|
||||
<div class="pixel-row">
|
||||
{#each row as pixel}
|
||||
<div class="pixel pixel-small" class:on={pixel === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="digit-pixels digit-small">
|
||||
{#each pixelPatterns[s2] as row}
|
||||
<div class="pixel-row">
|
||||
{#each row as pixel}
|
||||
<div class="pixel pixel-small" class:on={pixel === 1}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-retro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.retro-case {
|
||||
background: linear-gradient(180deg, #4a4a4a 0%, #2a2a2a 50%, #1a1a1a 100%);
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.crt-screen {
|
||||
position: relative;
|
||||
background: #0a0a0a;
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.15) 2px,
|
||||
rgba(0, 0, 0, 0.15) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.crt-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, rgba(0, 255, 0, 0.03) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pixels-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.digit-pixels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.digit-small {
|
||||
transform: scale(0.7);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
.pixel-row {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pixel {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #1a2a1a;
|
||||
border-radius: 1px;
|
||||
transition: all 80ms;
|
||||
}
|
||||
|
||||
.pixel.on {
|
||||
background: #00ff00;
|
||||
box-shadow:
|
||||
0 0 4px #00ff00,
|
||||
0 0 8px #00ff00,
|
||||
0 0 12px rgba(0, 255, 0, 0.4);
|
||||
}
|
||||
|
||||
.pixel-small {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.pixel-colon {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.colon-pixels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.colon-small {
|
||||
padding: 0 1px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
|
||||
// Main numbers
|
||||
const mainNumbers = [
|
||||
{ num: 12, angle: 0 },
|
||||
{ num: 3, angle: 90 },
|
||||
{ num: 6, angle: 180 },
|
||||
{ num: 9, angle: 270 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="clock-face-sporty" style="--size: {size}px;">
|
||||
<!-- Textured bezel -->
|
||||
<div class="bezel">
|
||||
{#each Array(120) as _, i}
|
||||
<div class="bezel-notch" style="transform: rotate({i * 3}deg)"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Dark background -->
|
||||
<div class="clock-bg">
|
||||
<div class="inner-bevel"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minute markers -->
|
||||
{#each Array(60) as _, i}
|
||||
<div
|
||||
class="marker"
|
||||
class:marker-5={i % 5 === 0}
|
||||
class:marker-15={i % 15 === 0}
|
||||
style="transform: rotate({i * 6}deg)"
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
<!-- Main numbers -->
|
||||
{#each mainNumbers as { num, angle }}
|
||||
<span class="number" style="--angle: {angle}deg;">
|
||||
{num}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- Subdial decorations -->
|
||||
<div class="subdial subdial-top">
|
||||
<span class="subdial-text">CHRONO</span>
|
||||
</div>
|
||||
<div class="subdial subdial-bottom">
|
||||
<div class="subdial-ring"></div>
|
||||
</div>
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)">
|
||||
<div class="hand-arrow"></div>
|
||||
</div>
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)">
|
||||
<div class="hand-arrow"></div>
|
||||
</div>
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)">
|
||||
<div class="second-needle"></div>
|
||||
<div class="second-counter"></div>
|
||||
<div class="second-circle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center hub -->
|
||||
<div class="center-hub">
|
||||
<div class="hub-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-sporty {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bezel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--color-foreground)) 0%,
|
||||
hsl(var(--color-muted-foreground)) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.bezel-notch {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: calc(var(--size) * 0.02);
|
||||
margin-left: -1px;
|
||||
margin-top: calc(var(--size) * -0.5);
|
||||
background: hsl(var(--color-background) / 0.3);
|
||||
transform-origin: center calc(var(--size) * 0.5);
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: calc(var(--size) * 0.04);
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-background));
|
||||
box-shadow: inset 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.inner-bevel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid hsl(var(--color-border) / 0.3);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: calc(var(--size) * 0.03);
|
||||
margin-left: -1px;
|
||||
margin-top: calc(var(--size) * -0.42);
|
||||
background: hsl(var(--color-muted-foreground) / 0.5);
|
||||
transform-origin: center calc(var(--size) * 0.42);
|
||||
}
|
||||
|
||||
.marker-5 {
|
||||
width: 3px;
|
||||
margin-left: -1.5px;
|
||||
height: calc(var(--size) * 0.05);
|
||||
background: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.marker-15 {
|
||||
width: 4px;
|
||||
margin-left: -2px;
|
||||
height: calc(var(--size) * 0.06);
|
||||
background: hsl(var(--color-foreground));
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family: 'Impact', 'Arial Black', 'Helvetica Neue', sans-serif;
|
||||
font-size: calc(var(--size) * 0.095);
|
||||
font-weight: 900;
|
||||
color: hsl(var(--color-foreground));
|
||||
letter-spacing: -0.02em;
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--size) * -0.32))
|
||||
rotate(calc(var(--angle) * -1));
|
||||
}
|
||||
|
||||
.subdial {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.subdial-top {
|
||||
top: 26%;
|
||||
}
|
||||
|
||||
.subdial-text {
|
||||
font-family: 'Arial Narrow', Arial, sans-serif;
|
||||
font-size: calc(var(--size) * 0.03);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subdial-bottom {
|
||||
top: 62%;
|
||||
}
|
||||
|
||||
.subdial-ring {
|
||||
width: calc(var(--size) * 0.15);
|
||||
height: calc(var(--size) * 0.15);
|
||||
border-radius: 50%;
|
||||
border: 2px solid hsl(var(--color-border) / 0.4);
|
||||
background: hsl(var(--color-background) / 0.5);
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.04);
|
||||
height: calc(var(--size) * 0.22);
|
||||
margin-left: calc(var(--size) * -0.02);
|
||||
margin-top: calc(var(--size) * -0.22);
|
||||
}
|
||||
|
||||
.hour-hand .hand-arrow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: hsl(var(--color-foreground));
|
||||
clip-path: polygon(15% 0%, 85% 0%, 100% 20%, 60% 100%, 40% 100%, 0% 20%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.03);
|
||||
height: calc(var(--size) * 0.32);
|
||||
margin-left: calc(var(--size) * -0.015);
|
||||
margin-top: calc(var(--size) * -0.32);
|
||||
}
|
||||
|
||||
.minute-hand .hand-arrow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: hsl(var(--color-foreground));
|
||||
clip-path: polygon(20% 0%, 80% 0%, 100% 15%, 55% 100%, 45% 100%, 0% 15%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.012);
|
||||
height: calc(var(--size) * 0.48);
|
||||
margin-left: calc(var(--size) * -0.006);
|
||||
margin-top: calc(var(--size) * -0.36);
|
||||
transform-origin: center 75%;
|
||||
}
|
||||
|
||||
.second-needle {
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 75%;
|
||||
background: hsl(var(--color-error));
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.second-counter {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(var(--size) * 0.025);
|
||||
height: 25%;
|
||||
background: hsl(var(--color-error));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.second-circle {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(var(--size) * 0.03);
|
||||
height: calc(var(--size) * 0.03);
|
||||
background: hsl(var(--color-error));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.center-hub {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.06);
|
||||
height: calc(var(--size) * 0.06);
|
||||
margin: calc(var(--size) * -0.03);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--color-foreground)) 0%,
|
||||
hsl(var(--color-muted-foreground)) 100%
|
||||
);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hub-inner {
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-error));
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
let timeString = $derived(
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
);
|
||||
|
||||
// Get current date info
|
||||
let now = $derived(new Date());
|
||||
let dateString = $derived(
|
||||
now.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="clock-face-terminal" style="--size: {size}px;">
|
||||
<div class="terminal-window">
|
||||
<!-- Title bar -->
|
||||
<div class="title-bar">
|
||||
<div class="title-buttons">
|
||||
<span class="btn btn-close"></span>
|
||||
<span class="btn btn-minimize"></span>
|
||||
<span class="btn btn-maximize"></span>
|
||||
</div>
|
||||
<span class="title-text">clock@system:~</span>
|
||||
</div>
|
||||
|
||||
<!-- Terminal content -->
|
||||
<div class="terminal-content">
|
||||
<div class="line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="command">date --format="%T"</span>
|
||||
</div>
|
||||
|
||||
<div class="output time-output">
|
||||
{timeString}
|
||||
</div>
|
||||
|
||||
<div class="line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="command">date --format="%a %b %d"</span>
|
||||
</div>
|
||||
|
||||
<div class="output date-output">
|
||||
{dateString}
|
||||
</div>
|
||||
|
||||
<div class="line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cursor">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-terminal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal-window {
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.title-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: #ff5f56;
|
||||
}
|
||||
|
||||
.btn-minimize {
|
||||
background: #ffbd2e;
|
||||
}
|
||||
|
||||
.btn-maximize {
|
||||
background: #27ca40;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace;
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.terminal-content {
|
||||
padding: 16px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: #27ca40;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.command {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.output {
|
||||
margin-bottom: 12px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.time-output {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #00ff88;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.date-output {
|
||||
font-size: 0.9rem;
|
||||
color: #61afef;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
color: #27ca40;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
let h1 = $derived(Math.floor(hours / 10));
|
||||
let h2 = $derived(hours % 10);
|
||||
let m1 = $derived(Math.floor(minutes / 10));
|
||||
let m2 = $derived(minutes % 10);
|
||||
let s1 = $derived(Math.floor(seconds / 10));
|
||||
let s2 = $derived(seconds % 10);
|
||||
|
||||
const digits = $derived([h1, h2, -1, m1, m2, -1, s1, s2]);
|
||||
</script>
|
||||
|
||||
<div class="clock-face-typewriter" style="--size: {size}px;">
|
||||
<div class="typewriter-case">
|
||||
<!-- Paper roll effect -->
|
||||
<div class="paper-top"></div>
|
||||
|
||||
<!-- Main display area -->
|
||||
<div class="paper">
|
||||
<div class="paper-texture"></div>
|
||||
|
||||
<div class="digits-row">
|
||||
{#each digits as digit, i}
|
||||
{#if digit === -1}
|
||||
<div class="separator">:</div>
|
||||
{:else}
|
||||
<div class="key-container">
|
||||
<div class="key" class:key-small={i >= 5}>
|
||||
<span class="key-text">{digit}</span>
|
||||
</div>
|
||||
<div class="key-shadow"></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typewriter bar -->
|
||||
<div class="type-bar">
|
||||
<div class="bar-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-typewriter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.typewriter-case {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, #3d3d3d 0%, #2a2a2a 50%, #1a1a1a 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.paper-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 8px;
|
||||
background: #f5f0e6;
|
||||
border-radius: 0 0 4px 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.paper {
|
||||
position: relative;
|
||||
background: #f5f0e6;
|
||||
border-radius: 4px;
|
||||
padding: 20px 16px;
|
||||
margin-top: 8px;
|
||||
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.paper-texture {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 24px,
|
||||
rgba(0, 0, 0, 0.02) 24px,
|
||||
rgba(0, 0, 0, 0.02) 25px
|
||||
);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.digits-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.key-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.key {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 44px;
|
||||
background: linear-gradient(180deg, #4a4a4a 0%, #2d2d2d 100%);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow:
|
||||
0 4px 8px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.2),
|
||||
inset 0 -1px 2px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
transition: transform 100ms;
|
||||
}
|
||||
|
||||
.key-small {
|
||||
width: 28px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-family: 'American Typewriter', 'Courier New', monospace;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #f5f0e6;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.key-small .key-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.key-shadow {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
height: 6px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 0 0 4px 4px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.separator {
|
||||
font-family: 'American Typewriter', 'Courier New', monospace;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #2d2d2d;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.type-bar {
|
||||
height: 6px;
|
||||
background: linear-gradient(90deg, #666 0%, #888 50%, #666 100%);
|
||||
border-radius: 3px;
|
||||
margin-top: 12px;
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.3),
|
||||
0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bar-detail {
|
||||
height: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 4px,
|
||||
rgba(0, 0, 0, 0.1) 4px,
|
||||
rgba(0, 0, 0, 0.1) 5px
|
||||
);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { hours, minutes, seconds, size = 280 }: Props = $props();
|
||||
|
||||
// Clock hand rotations
|
||||
let secondRotation = $derived((seconds / 60) * 360);
|
||||
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
|
||||
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
|
||||
|
||||
// Vintage numbers
|
||||
const numbers = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
</script>
|
||||
|
||||
<div class="clock-face-vintage" style="--size: {size}px;">
|
||||
<!-- Aged outer frame -->
|
||||
<div class="outer-frame"></div>
|
||||
|
||||
<!-- Background with aged texture -->
|
||||
<div class="clock-bg">
|
||||
<div class="age-texture"></div>
|
||||
<div class="stains"></div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative inner border -->
|
||||
<div class="inner-border"></div>
|
||||
|
||||
<!-- Minute tick marks -->
|
||||
{#each Array(60) as _, i}
|
||||
{#if i % 5 !== 0}
|
||||
<div class="tick tick-minute" style="transform: rotate({i * 6}deg)"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Hour tick marks -->
|
||||
{#each Array(12) as _, i}
|
||||
<div class="tick tick-hour" style="transform: rotate({i * 30}deg)"></div>
|
||||
{/each}
|
||||
|
||||
<!-- Vintage numbers -->
|
||||
{#each numbers as num, i}
|
||||
<span class="number" style="--angle: {i * 30}deg;">
|
||||
{num}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- Brand text -->
|
||||
<div class="brand">
|
||||
<span class="brand-name">VINTAGE</span>
|
||||
<span class="brand-sub">EST. 1920</span>
|
||||
</div>
|
||||
|
||||
<!-- Clock hands -->
|
||||
<div class="hands-container">
|
||||
<div class="hand hour-hand" style="transform: rotate({hourRotation}deg)">
|
||||
<div class="spade-tip"></div>
|
||||
<div class="hand-body"></div>
|
||||
</div>
|
||||
<div class="hand minute-hand" style="transform: rotate({minuteRotation}deg)">
|
||||
<div class="spade-tip"></div>
|
||||
<div class="hand-body"></div>
|
||||
</div>
|
||||
<div class="hand second-hand" style="transform: rotate({secondRotation}deg)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center cap -->
|
||||
<div class="center-cap">
|
||||
<div class="cap-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clock-face-vintage {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.outer-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#8b6914 0%,
|
||||
#c9a227 25%,
|
||||
#a67c00 50%,
|
||||
#c9a227 75%,
|
||||
#8b6914 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.3),
|
||||
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.clock-bg {
|
||||
position: absolute;
|
||||
inset: calc(var(--size) * 0.035);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(180deg, #f5e6c8 0%, #e8d4a8 50%, #d4c090 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.age-texture {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(139, 90, 43, 0.1) 0%, transparent 30%),
|
||||
radial-gradient(circle at 80% 20%, rgba(139, 90, 43, 0.08) 0%, transparent 25%),
|
||||
radial-gradient(circle at 50% 50%, transparent 40%, rgba(0, 0, 0, 0.05) 100%);
|
||||
}
|
||||
|
||||
.stains {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 70%, rgba(101, 67, 33, 0.08) 0%, transparent 20%),
|
||||
radial-gradient(ellipse at 70% 30%, rgba(101, 67, 33, 0.06) 0%, transparent 15%);
|
||||
}
|
||||
|
||||
.inner-border {
|
||||
position: absolute;
|
||||
inset: calc(var(--size) * 0.06);
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(139, 105, 20, 0.3);
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.tick-minute {
|
||||
width: 1px;
|
||||
height: calc(var(--size) * 0.025);
|
||||
margin-left: -0.5px;
|
||||
margin-top: calc(var(--size) * -0.44);
|
||||
background: rgba(101, 67, 33, 0.4);
|
||||
}
|
||||
|
||||
.tick-hour {
|
||||
width: 2px;
|
||||
height: calc(var(--size) * 0.045);
|
||||
margin-left: -1px;
|
||||
margin-top: calc(var(--size) * -0.44);
|
||||
background: #654321;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family: 'Playfair Display', 'Times New Roman', Georgia, serif;
|
||||
font-size: calc(var(--size) * 0.075);
|
||||
font-weight: 700;
|
||||
color: #3d2914;
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.5);
|
||||
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--size) * -0.34))
|
||||
rotate(calc(var(--angle) * -1));
|
||||
}
|
||||
|
||||
.brand {
|
||||
position: absolute;
|
||||
top: 65%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: calc(var(--size) * 0.038);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
color: #654321;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-family: 'Times New Roman', serif;
|
||||
font-size: calc(var(--size) * 0.025);
|
||||
font-style: italic;
|
||||
color: #8b6914;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hands-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hand {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.hour-hand {
|
||||
width: calc(var(--size) * 0.035);
|
||||
height: calc(var(--size) * 0.25);
|
||||
margin-left: calc(var(--size) * -0.0175);
|
||||
margin-top: calc(var(--size) * -0.25);
|
||||
}
|
||||
|
||||
.hour-hand .hand-body {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%;
|
||||
height: 85%;
|
||||
background: #1a1a1a;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hour-hand .spade-tip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 25%;
|
||||
background: #1a1a1a;
|
||||
clip-path: polygon(50% 0%, 100% 100%, 80% 100%, 50% 30%, 20% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
.minute-hand {
|
||||
width: calc(var(--size) * 0.028);
|
||||
height: calc(var(--size) * 0.35);
|
||||
margin-left: calc(var(--size) * -0.014);
|
||||
margin-top: calc(var(--size) * -0.35);
|
||||
}
|
||||
|
||||
.minute-hand .hand-body {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%;
|
||||
height: 88%;
|
||||
background: #1a1a1a;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.minute-hand .spade-tip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
height: 20%;
|
||||
background: #1a1a1a;
|
||||
clip-path: polygon(50% 0%, 100% 100%, 75% 100%, 50% 35%, 25% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
.second-hand {
|
||||
width: calc(var(--size) * 0.008);
|
||||
height: calc(var(--size) * 0.42);
|
||||
margin-left: calc(var(--size) * -0.004);
|
||||
margin-top: calc(var(--size) * -0.34);
|
||||
background: #8b0000;
|
||||
border-radius: 1px;
|
||||
transform-origin: center 80.95%;
|
||||
}
|
||||
|
||||
.center-cap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--size) * 0.055);
|
||||
height: calc(var(--size) * 0.055);
|
||||
margin: calc(var(--size) * -0.0275);
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #c9a227 0%, #8b6914 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cap-detail {
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
border-radius: 50%;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
</style>
|
||||
23
apps/clock/apps/web/src/lib/components/clock-faces/index.ts
Normal file
23
apps/clock/apps/web/src/lib/components/clock-faces/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export { default as ClockFace } from './ClockFace.svelte';
|
||||
// Analog faces
|
||||
export { default as ClockFaceClassic } from './ClockFaceClassic.svelte';
|
||||
export { default as ClockFaceMinimalist } from './ClockFaceMinimalist.svelte';
|
||||
export { default as ClockFaceModern } from './ClockFaceModern.svelte';
|
||||
export { default as ClockFaceElegant } from './ClockFaceElegant.svelte';
|
||||
export { default as ClockFaceSporty } from './ClockFaceSporty.svelte';
|
||||
export { default as ClockFaceVintage } from './ClockFaceVintage.svelte';
|
||||
export { default as ClockFaceNautical } from './ClockFaceNautical.svelte';
|
||||
export { default as ClockFaceIndustrial } from './ClockFaceIndustrial.svelte';
|
||||
export { default as ClockFaceBauhaus } from './ClockFaceBauhaus.svelte';
|
||||
export { default as ClockFaceRailway } from './ClockFaceRailway.svelte';
|
||||
// Digital faces
|
||||
export { default as ClockFaceLCD } from './ClockFaceLCD.svelte';
|
||||
export { default as ClockFaceFlip } from './ClockFaceFlip.svelte';
|
||||
export { default as ClockFaceMatrix } from './ClockFaceMatrix.svelte';
|
||||
export { default as ClockFaceNeon } from './ClockFaceNeon.svelte';
|
||||
export { default as ClockFaceBinary } from './ClockFaceBinary.svelte';
|
||||
export { default as ClockFaceRetro } from './ClockFaceRetro.svelte';
|
||||
export { default as ClockFaceGradient } from './ClockFaceGradient.svelte';
|
||||
export { default as ClockFaceTerminal } from './ClockFaceTerminal.svelte';
|
||||
export { default as ClockFaceTypewriter } from './ClockFaceTypewriter.svelte';
|
||||
export { default as ClockFaceRadar } from './ClockFaceRadar.svelte';
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
<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>
|
||||
333
apps/clock/apps/web/src/lib/components/life-clock/DotGrid.svelte
Normal file
333
apps/clock/apps/web/src/lib/components/life-clock/DotGrid.svelte
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
weeksLived: number;
|
||||
lifeExpectancyYears?: number;
|
||||
}
|
||||
|
||||
let { weeksLived, lifeExpectancyYears = 80 }: Props = $props();
|
||||
|
||||
// Calculate total weeks in expected lifetime
|
||||
let totalWeeks = $derived(Math.ceil(lifeExpectancyYears * 52.1775));
|
||||
let weeksPerRow = 52; // One year per row
|
||||
let totalRows = $derived(Math.ceil(totalWeeks / weeksPerRow));
|
||||
|
||||
// Generate rows with decade markers
|
||||
let rows = $derived(() => {
|
||||
const result: {
|
||||
year: number;
|
||||
weeks: { index: number; lived: boolean }[];
|
||||
isDecade: boolean;
|
||||
}[] = [];
|
||||
for (let year = 0; year < totalRows; year++) {
|
||||
const weeks: { index: number; lived: boolean }[] = [];
|
||||
for (let week = 0; week < weeksPerRow; week++) {
|
||||
const weekIndex = year * weeksPerRow + week;
|
||||
if (weekIndex < totalWeeks) {
|
||||
weeks.push({
|
||||
index: weekIndex,
|
||||
lived: weekIndex < weeksLived,
|
||||
});
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
year: year + 1,
|
||||
weeks,
|
||||
isDecade: (year + 1) % 10 === 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
let percentageLived = $derived(Math.min((weeksLived / totalWeeks) * 100, 100).toFixed(1));
|
||||
let yearsLived = $derived(Math.floor(weeksLived / 52));
|
||||
</script>
|
||||
|
||||
<div class="dot-grid-container">
|
||||
<!-- Header Stats -->
|
||||
<div class="dot-grid-header">
|
||||
<div class="header-main">
|
||||
<span class="header-weeks">{weeksLived.toLocaleString('de-DE')}</span>
|
||||
<span class="header-label">Wochen gelebt</span>
|
||||
</div>
|
||||
<div class="header-secondary">
|
||||
<span>{percentageLived}% von {totalWeeks.toLocaleString('de-DE')} Wochen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="dot-grid-wrapper">
|
||||
<div class="dot-grid">
|
||||
{#each rows() as row}
|
||||
<div class="dot-row" class:decade-row={row.isDecade}>
|
||||
<span class="year-label" class:decade-label={row.isDecade}>
|
||||
{row.year}
|
||||
</span>
|
||||
<div class="dots">
|
||||
{#each row.weeks as week, i}
|
||||
{#if i === 26}
|
||||
<div class="half-year-marker"></div>
|
||||
{/if}
|
||||
<div
|
||||
class="dot"
|
||||
class:lived={week.lived}
|
||||
class:current={week.index === weeksLived}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="dot-grid-legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot lived"></div>
|
||||
<span>Gelebt ({yearsLived} Jahre)</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot current"></div>
|
||||
<span>Aktuelle Woche</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-dot"></div>
|
||||
<span>Verbleibend</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dot-grid-container {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.dot-grid-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.header-weeks {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 200;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.header-label {
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.header-secondary {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground) / 0.8);
|
||||
}
|
||||
|
||||
/* Grid Wrapper */
|
||||
.dot-grid-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--color-muted) / 0.03);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.dot-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.dot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.dot-row.decade-row {
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.3);
|
||||
}
|
||||
|
||||
/* Year Labels */
|
||||
.year-label {
|
||||
font-size: 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: hsl(var(--color-muted-foreground) / 0.5);
|
||||
width: 1.25rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.year-label.decade-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Dots Container */
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Half Year Marker */
|
||||
.half-year-marker {
|
||||
width: 1px;
|
||||
height: 6px;
|
||||
background: hsl(var(--color-border) / 0.3);
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
/* Individual Dots */
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: hsl(var(--color-muted-foreground) / 0.12);
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.lived {
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.dot.current {
|
||||
background: hsl(var(--color-primary));
|
||||
box-shadow:
|
||||
0 0 0 2px hsl(var(--color-background)),
|
||||
0 0 0 4px hsl(var(--color-primary));
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dot:hover {
|
||||
transform: scale(1.4);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.dot-grid-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--color-border) / 0.3);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
background: hsl(var(--color-muted-foreground) / 0.12);
|
||||
}
|
||||
|
||||
.legend-dot.lived {
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.legend-dot.current {
|
||||
background: hsl(var(--color-primary));
|
||||
box-shadow:
|
||||
0 0 0 2px hsl(var(--color-background)),
|
||||
0 0 0 3px hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 640px) {
|
||||
.dot-grid-container {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.dots {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot-grid {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.half-year-marker {
|
||||
height: 8px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.dot-grid-wrapper {
|
||||
max-height: 55vh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.header-weeks {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.dot-grid-container {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.dots {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dot-grid {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dot-grid-wrapper {
|
||||
max-height: 60vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
font-size: 0.625rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.year-label.decade-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
yearsLived: number;
|
||||
exactAge: { years: number; months: number; days: number };
|
||||
lifeExpectancyYears?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { yearsLived, exactAge, lifeExpectancyYears = 80, size = 300 }: Props = $props();
|
||||
|
||||
// Calculate rings
|
||||
let totalYears = lifeExpectancyYears;
|
||||
let ringWidth = $derived(Math.max(2, (size / 2 - 20) / totalYears));
|
||||
let centerX = $derived(size / 2);
|
||||
let centerY = $derived(size / 2);
|
||||
|
||||
// Generate rings data
|
||||
let rings = $derived(() => {
|
||||
const result: {
|
||||
year: number;
|
||||
radius: number;
|
||||
lived: boolean;
|
||||
current: boolean;
|
||||
decade: boolean;
|
||||
}[] = [];
|
||||
for (let year = 1; year <= totalYears; year++) {
|
||||
const radius = 15 + year * ringWidth;
|
||||
result.push({
|
||||
year,
|
||||
radius,
|
||||
lived: year <= yearsLived,
|
||||
current: year === yearsLived + 1,
|
||||
decade: year % 10 === 0,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Current year progress (partial ring)
|
||||
let currentYearProgress = $derived(() => {
|
||||
const monthProgress = exactAge.months / 12;
|
||||
const dayProgress = exactAge.days / 365;
|
||||
return (monthProgress + dayProgress) * 100;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="year-rings-container">
|
||||
<div class="rings-header">
|
||||
<span class="header-label">Jeder Ring = 1 Jahr deines Lebens</span>
|
||||
</div>
|
||||
|
||||
<div class="rings-wrapper" style="width: {size}px; height: {size}px;">
|
||||
<svg width={size} height={size} viewBox="0 0 {size} {size}" class="rings-svg">
|
||||
<!-- Background rings (future years) -->
|
||||
{#each rings() as ring}
|
||||
{#if !ring.lived && !ring.current}
|
||||
<circle
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
r={ring.radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--color-muted-foreground) / 0.1)"
|
||||
stroke-width={ringWidth - 1}
|
||||
class:decade-ring={ring.decade}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Lived years -->
|
||||
{#each rings() as ring}
|
||||
{#if ring.lived}
|
||||
<circle
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
r={ring.radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--color-primary) / {0.3 + (ring.year / totalYears) * 0.7})"
|
||||
stroke-width={ringWidth - 1}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Current year (partial) -->
|
||||
{#each rings() as ring}
|
||||
{#if ring.current}
|
||||
{@const circumference = 2 * Math.PI * ring.radius}
|
||||
{@const dashOffset = circumference - (currentYearProgress() / 100) * circumference}
|
||||
<circle
|
||||
cx={centerX}
|
||||
cy={centerY}
|
||||
r={ring.radius}
|
||||
fill="none"
|
||||
stroke="hsl(var(--color-primary))"
|
||||
stroke-width={ringWidth - 1}
|
||||
stroke-dasharray={circumference}
|
||||
stroke-dashoffset={dashOffset}
|
||||
stroke-linecap="round"
|
||||
transform="rotate(-90 {centerX} {centerY})"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Decade markers -->
|
||||
{#each [10, 20, 30, 40, 50, 60, 70, 80] as decade}
|
||||
{@const markerRadius = 15 + decade * ringWidth + ringWidth / 2 + 2}
|
||||
{#if decade <= totalYears}
|
||||
<text
|
||||
x={centerX + markerRadius}
|
||||
y={centerY}
|
||||
text-anchor="start"
|
||||
dominant-baseline="middle"
|
||||
class="decade-label"
|
||||
>
|
||||
{decade}
|
||||
</text>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Center dot -->
|
||||
<circle cx={centerX} cy={centerY} r="8" fill="hsl(var(--color-primary))" />
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
class="birth-label"
|
||||
>
|
||||
0
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="rings-info">
|
||||
<div class="age-display">
|
||||
<span class="age-years">{exactAge.years}</span>
|
||||
<span class="age-unit">Jahre</span>
|
||||
<span class="age-detail">+ {exactAge.months} Monate, {exactAge.days} Tage</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rings-legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-ring lived"></div>
|
||||
<span>Gelebte Jahre</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-ring current"></div>
|
||||
<span>Aktuelles Jahr</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-ring future"></div>
|
||||
<span>Zukünftige Jahre</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.year-rings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rings-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.rings-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rings-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.decade-ring {
|
||||
stroke: hsl(var(--color-muted-foreground) / 0.2) !important;
|
||||
}
|
||||
|
||||
.decade-label {
|
||||
font-size: 0.5rem;
|
||||
fill: hsl(var(--color-muted-foreground) / 0.6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.birth-label {
|
||||
font-size: 0.5rem;
|
||||
fill: hsl(var(--color-primary-foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rings-info {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.age-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.age-years {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.age-unit {
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.age-detail {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.rings-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.legend-ring {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
}
|
||||
|
||||
.legend-ring.lived {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.legend-ring.current {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.legend-ring.future {
|
||||
border-color: hsl(var(--color-muted-foreground) / 0.2);
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as DotGrid } from './DotGrid.svelte';
|
||||
export { default as CircularProgress } from './CircularProgress.svelte';
|
||||
export { default as YearRings } from './YearRings.svelte';
|
||||
58
apps/clock/apps/web/src/lib/i18n/index.ts
Normal file
58
apps/clock/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* i18n setup for Clock app
|
||||
* Supports: DE, EN, FR, ES, IT
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, getLocaleFromNavigator } from 'svelte-i18n';
|
||||
|
||||
// Supported locales
|
||||
export const supportedLocales = ['de', 'en', 'fr', 'es', 'it'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
// Register locales
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
register('fr', () => import('./locales/fr.json'));
|
||||
register('es', () => import('./locales/es.json'));
|
||||
register('it', () => import('./locales/it.json'));
|
||||
|
||||
// Get initial locale
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
// Check localStorage first
|
||||
const saved = localStorage.getItem('clock-locale');
|
||||
if (saved && supportedLocales.includes(saved as SupportedLocale)) {
|
||||
return saved as SupportedLocale;
|
||||
}
|
||||
|
||||
// Fall back to browser language
|
||||
const browserLocale = getLocaleFromNavigator();
|
||||
if (browserLocale) {
|
||||
const shortLocale = browserLocale.split('-')[0] as SupportedLocale;
|
||||
if (supportedLocales.includes(shortLocale)) {
|
||||
return shortLocale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to German
|
||||
return 'de';
|
||||
}
|
||||
|
||||
// Initialize i18n at module scope (required for SSR)
|
||||
init({
|
||||
fallbackLocale: 'de',
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
// Set locale and persist
|
||||
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 } from 'svelte-i18n';
|
||||
275
apps/clock/apps/web/src/lib/i18n/locales/de.json
Normal file
275
apps/clock/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Laden..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Übersicht",
|
||||
"alarms": "Wecker",
|
||||
"timers": "Timer",
|
||||
"stopwatch": "Stoppuhr",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "Weltzeituhr",
|
||||
"lifeClock": "Lebensuhr",
|
||||
"settings": "Einstellungen",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
"register": "Registrieren",
|
||||
"logout": "Abmelden",
|
||||
"forgotPassword": "Passwort vergessen",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"confirmPassword": "Passwort bestätigen"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"nextAlarm": "Nächster Wecker",
|
||||
"activeTimers": "Aktive Timer",
|
||||
"worldClocks": "Weltuhr"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Wecker",
|
||||
"add": "Wecker hinzufügen",
|
||||
"edit": "Wecker bearbeiten",
|
||||
"delete": "Wecker löschen",
|
||||
"label": "Bezeichnung",
|
||||
"time": "Zeit",
|
||||
"repeat": "Wiederholen",
|
||||
"sound": "Ton",
|
||||
"snooze": "Schlummern",
|
||||
"snoozeMinutes": "{minutes} Minuten",
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert",
|
||||
"noAlarms": "Keine Wecker eingestellt",
|
||||
"days": {
|
||||
"sun": "So",
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
"wed": "Mi",
|
||||
"thu": "Do",
|
||||
"fri": "Fr",
|
||||
"sat": "Sa"
|
||||
},
|
||||
"once": "Einmalig",
|
||||
"daily": "Täglich",
|
||||
"weekdays": "Wochentags",
|
||||
"weekends": "Am Wochenende",
|
||||
"custom": "Benutzerdefiniert"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Timer",
|
||||
"add": "Timer hinzufügen",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"reset": "Zurücksetzen",
|
||||
"stop": "Stopp",
|
||||
"delete": "Löschen",
|
||||
"label": "Bezeichnung",
|
||||
"duration": "Dauer",
|
||||
"hours": "Stunden",
|
||||
"minutes": "Minuten",
|
||||
"seconds": "Sekunden",
|
||||
"noTimers": "Keine Timer aktiv",
|
||||
"presets": "Schnellauswahl",
|
||||
"finished": "Timer abgelaufen!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Stoppuhr",
|
||||
"start": "Start",
|
||||
"stop": "Stopp",
|
||||
"lap": "Runde",
|
||||
"reset": "Zurücksetzen",
|
||||
"laps": "Runden",
|
||||
"noLaps": "Noch keine Runden",
|
||||
"best": "Beste",
|
||||
"worst": "Längste",
|
||||
"total": "Gesamt",
|
||||
"new": "Neu",
|
||||
"continue": "Weiter",
|
||||
"noStopwatches": "Keine Stoppuhren",
|
||||
"noStopwatchesDescription": "Starte deine erste Stoppuhr, um mehrere Zeitmessungen parallel zu verfolgen.",
|
||||
"startFirst": "Erste Stoppuhr starten",
|
||||
"otherStopwatches": "Weitere Stoppuhren"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Arbeiten",
|
||||
"break": "Pause",
|
||||
"longBreak": "Lange Pause",
|
||||
"sessions": "Sitzungen",
|
||||
"sessionsCompleted": "{count} von {total} Sitzungen",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"skip": "Überspringen",
|
||||
"reset": "Zurücksetzen",
|
||||
"presets": {
|
||||
"classic": "Klassisch",
|
||||
"shortFocus": "Kurzer Fokus",
|
||||
"deepWork": "Tiefe Arbeit"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Arbeitszeit",
|
||||
"breakDuration": "Pausenzeit",
|
||||
"longBreakDuration": "Lange Pause",
|
||||
"sessionsBeforeLongBreak": "Sitzungen bis zur langen Pause"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "Weltzeituhr",
|
||||
"add": "Stadt hinzufügen",
|
||||
"search": "Stadt oder Zeitzone suchen...",
|
||||
"noClocks": "Keine Städte hinzugefügt",
|
||||
"difference": "{hours} Std. {direction}",
|
||||
"ahead": "voraus",
|
||||
"behind": "zurück",
|
||||
"same": "Gleiche Zeit"
|
||||
},
|
||||
"lifeClock": {
|
||||
"title": "Lebensuhr",
|
||||
"description": "Gib dein Geburtsdatum ein, um zu sehen, wie viele Tage du bereits gelebt hast.",
|
||||
"daysLived": "Tage gelebt",
|
||||
"since": "seit",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"showMore": "Mehr Details",
|
||||
"showLess": "Weniger anzeigen",
|
||||
"nextMilestone": "Nächster Meilenstein",
|
||||
"inDays": "in {days} Tagen",
|
||||
"stats": {
|
||||
"hours": "Stunden",
|
||||
"minutes": "Minuten",
|
||||
"weeks": "Wochen",
|
||||
"months": "Monate"
|
||||
},
|
||||
"funFacts": {
|
||||
"title": "Ungefähre Schätzungen",
|
||||
"heartbeats": "Herzschläge",
|
||||
"breaths": "Atemzüge",
|
||||
"sunrises": "Sonnenaufgänge",
|
||||
"sleepHours": "Stunden geschlafen"
|
||||
},
|
||||
"milestones": {
|
||||
"title": "Meilensteine",
|
||||
"days": "Tage"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"general": "Allgemein",
|
||||
"appearance": "Darstellung",
|
||||
"sounds": "Töne",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"language": "Sprache",
|
||||
"theme": "Design",
|
||||
"darkMode": "Dunkelmodus",
|
||||
"clockFormat": "Uhrzeitformat",
|
||||
"format24h": "24 Stunden",
|
||||
"format12h": "12 Stunden (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
"confirm": "Bestätigen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"ok": "OK",
|
||||
"loading": "Laden...",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolg",
|
||||
"back": "Zurück"
|
||||
},
|
||||
"clockFaces": {
|
||||
"title": "Zifferblätter",
|
||||
"subtitle": "Wähle dein bevorzugtes Uhren-Design für die Startseite",
|
||||
"customize": "Zifferblatt anpassen",
|
||||
"currentSelection": "Aktuelle Auswahl",
|
||||
"analog": "Analoge Uhren",
|
||||
"digital": "Digitale Uhren",
|
||||
"selected": "Ausgewählt",
|
||||
"classic": {
|
||||
"name": "Klassisch",
|
||||
"description": "Elegante Uhr mit römischen Ziffern"
|
||||
},
|
||||
"minimalist": {
|
||||
"name": "Minimalistisch",
|
||||
"description": "Schlichtes Design mit Stundenmarkierungen"
|
||||
},
|
||||
"modern": {
|
||||
"name": "Modern",
|
||||
"description": "Zeitgemäßer Stil mit arabischen Zahlen"
|
||||
},
|
||||
"elegant": {
|
||||
"name": "Elegant",
|
||||
"description": "Luxuriöse goldene Akzente"
|
||||
},
|
||||
"sporty": {
|
||||
"name": "Sportlich",
|
||||
"description": "Dynamisches Design wie bei Sportuhren"
|
||||
},
|
||||
"lcd": {
|
||||
"name": "LCD",
|
||||
"description": "Klassisches 7-Segment LCD-Display"
|
||||
},
|
||||
"flip": {
|
||||
"name": "Fallblatt",
|
||||
"description": "Retro-Stil wie alte Bahnhofsuhren"
|
||||
},
|
||||
"matrix": {
|
||||
"name": "Matrix",
|
||||
"description": "Punktmatrix LED-Anzeige"
|
||||
},
|
||||
"neon": {
|
||||
"name": "Neon",
|
||||
"description": "Leuchtender Neonröhren-Effekt"
|
||||
},
|
||||
"binary": {
|
||||
"name": "Binär",
|
||||
"description": "Zeit im Binärformat dargestellt"
|
||||
},
|
||||
"vintage": {
|
||||
"name": "Vintage",
|
||||
"description": "Antike Uhr mit gealterter Patina"
|
||||
},
|
||||
"nautical": {
|
||||
"name": "Nautisch",
|
||||
"description": "Marine-Stil mit Messing-Schiffsuhr"
|
||||
},
|
||||
"industrial": {
|
||||
"name": "Industrie",
|
||||
"description": "Fabrik-Stil mit Metall-Akzenten"
|
||||
},
|
||||
"bauhaus": {
|
||||
"name": "Bauhaus",
|
||||
"description": "Geometrisches Design mit Primärfarben"
|
||||
},
|
||||
"railway": {
|
||||
"name": "Bahnhof",
|
||||
"description": "Schweizer Bahnhofsuhr-Design"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Retro",
|
||||
"description": "Pixel-Stil CRT-Anzeige"
|
||||
},
|
||||
"gradient": {
|
||||
"name": "Farbverlauf",
|
||||
"description": "Moderne Anzeige mit Farbwechsel"
|
||||
},
|
||||
"terminal": {
|
||||
"name": "Terminal",
|
||||
"description": "Kommandozeilen-Interface-Stil"
|
||||
},
|
||||
"typewriter": {
|
||||
"name": "Schreibmaschine",
|
||||
"description": "Vintage mechanischer Tastatur-Stil"
|
||||
},
|
||||
"radar": {
|
||||
"name": "Radar",
|
||||
"description": "Militär-Radarschirm-Anzeige"
|
||||
}
|
||||
}
|
||||
}
|
||||
275
apps/clock/apps/web/src/lib/i18n/locales/en.json
Normal file
275
apps/clock/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"alarms": "Alarms",
|
||||
"timers": "Timers",
|
||||
"stopwatch": "Stopwatch",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "World Clock",
|
||||
"lifeClock": "Life Clock",
|
||||
"settings": "Settings",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign In",
|
||||
"register": "Sign Up",
|
||||
"logout": "Sign Out",
|
||||
"forgotPassword": "Forgot Password",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"nextAlarm": "Next Alarm",
|
||||
"activeTimers": "Active Timers",
|
||||
"worldClocks": "World Clocks"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Alarms",
|
||||
"add": "Add Alarm",
|
||||
"edit": "Edit Alarm",
|
||||
"delete": "Delete Alarm",
|
||||
"label": "Label",
|
||||
"time": "Time",
|
||||
"repeat": "Repeat",
|
||||
"sound": "Sound",
|
||||
"snooze": "Snooze",
|
||||
"snoozeMinutes": "{minutes} minutes",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"noAlarms": "No alarms set",
|
||||
"days": {
|
||||
"sun": "Sun",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat"
|
||||
},
|
||||
"once": "Once",
|
||||
"daily": "Daily",
|
||||
"weekdays": "Weekdays",
|
||||
"weekends": "Weekends",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Timers",
|
||||
"add": "Add Timer",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"reset": "Reset",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"label": "Label",
|
||||
"duration": "Duration",
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes",
|
||||
"seconds": "Seconds",
|
||||
"noTimers": "No active timers",
|
||||
"presets": "Quick Select",
|
||||
"finished": "Timer finished!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Stopwatch",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"lap": "Lap",
|
||||
"reset": "Reset",
|
||||
"laps": "Laps",
|
||||
"noLaps": "No laps yet",
|
||||
"best": "Best",
|
||||
"worst": "Worst",
|
||||
"total": "Total",
|
||||
"new": "New",
|
||||
"continue": "Continue",
|
||||
"noStopwatches": "No Stopwatches",
|
||||
"noStopwatchesDescription": "Start your first stopwatch to track multiple time measurements simultaneously.",
|
||||
"startFirst": "Start First Stopwatch",
|
||||
"otherStopwatches": "Other Stopwatches"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Work",
|
||||
"break": "Break",
|
||||
"longBreak": "Long Break",
|
||||
"sessions": "Sessions",
|
||||
"sessionsCompleted": "{count} of {total} sessions",
|
||||
"start": "Start",
|
||||
"pause": "Pause",
|
||||
"skip": "Skip",
|
||||
"reset": "Reset",
|
||||
"presets": {
|
||||
"classic": "Classic",
|
||||
"shortFocus": "Short Focus",
|
||||
"deepWork": "Deep Work"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Work Duration",
|
||||
"breakDuration": "Break Duration",
|
||||
"longBreakDuration": "Long Break Duration",
|
||||
"sessionsBeforeLongBreak": "Sessions Before Long Break"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "World Clock",
|
||||
"add": "Add City",
|
||||
"search": "Search city or timezone...",
|
||||
"noClocks": "No cities added",
|
||||
"difference": "{hours} hrs {direction}",
|
||||
"ahead": "ahead",
|
||||
"behind": "behind",
|
||||
"same": "Same time"
|
||||
},
|
||||
"lifeClock": {
|
||||
"title": "Life Clock",
|
||||
"description": "Enter your birth date to see how many days you have lived.",
|
||||
"daysLived": "days lived",
|
||||
"since": "since",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less",
|
||||
"nextMilestone": "Next Milestone",
|
||||
"inDays": "in {days} days",
|
||||
"stats": {
|
||||
"hours": "Hours",
|
||||
"minutes": "Minutes",
|
||||
"weeks": "Weeks",
|
||||
"months": "Months"
|
||||
},
|
||||
"funFacts": {
|
||||
"title": "Approximate Estimates",
|
||||
"heartbeats": "Heartbeats",
|
||||
"breaths": "Breaths",
|
||||
"sunrises": "Sunrises",
|
||||
"sleepHours": "Hours slept"
|
||||
},
|
||||
"milestones": {
|
||||
"title": "Milestones",
|
||||
"days": "days"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"general": "General",
|
||||
"appearance": "Appearance",
|
||||
"sounds": "Sounds",
|
||||
"notifications": "Notifications",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"darkMode": "Dark Mode",
|
||||
"clockFormat": "Clock Format",
|
||||
"format24h": "24 Hours",
|
||||
"format12h": "12 Hours (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"confirm": "Confirm",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"back": "Back"
|
||||
},
|
||||
"clockFaces": {
|
||||
"title": "Clock Faces",
|
||||
"subtitle": "Choose your preferred clock design for the homepage",
|
||||
"customize": "Customize Clock",
|
||||
"currentSelection": "Current Selection",
|
||||
"analog": "Analog Clocks",
|
||||
"digital": "Digital Clocks",
|
||||
"selected": "Selected",
|
||||
"classic": {
|
||||
"name": "Classic",
|
||||
"description": "Elegant clock with Roman numerals"
|
||||
},
|
||||
"minimalist": {
|
||||
"name": "Minimalist",
|
||||
"description": "Clean design with only hour markers"
|
||||
},
|
||||
"modern": {
|
||||
"name": "Modern",
|
||||
"description": "Contemporary style with Arabic numerals"
|
||||
},
|
||||
"elegant": {
|
||||
"name": "Elegant",
|
||||
"description": "Luxurious golden accents"
|
||||
},
|
||||
"sporty": {
|
||||
"name": "Sporty",
|
||||
"description": "Bold design inspired by sports watches"
|
||||
},
|
||||
"lcd": {
|
||||
"name": "LCD",
|
||||
"description": "Classic 7-segment LCD display"
|
||||
},
|
||||
"flip": {
|
||||
"name": "Flip Clock",
|
||||
"description": "Retro split-flap display style"
|
||||
},
|
||||
"matrix": {
|
||||
"name": "Matrix",
|
||||
"description": "Dot matrix LED display"
|
||||
},
|
||||
"neon": {
|
||||
"name": "Neon",
|
||||
"description": "Glowing neon tube effect"
|
||||
},
|
||||
"binary": {
|
||||
"name": "Binary",
|
||||
"description": "Time displayed in binary format"
|
||||
},
|
||||
"vintage": {
|
||||
"name": "Vintage",
|
||||
"description": "Antique clock with aged patina"
|
||||
},
|
||||
"nautical": {
|
||||
"name": "Nautical",
|
||||
"description": "Marine-inspired brass ship clock"
|
||||
},
|
||||
"industrial": {
|
||||
"name": "Industrial",
|
||||
"description": "Factory-style with metal accents"
|
||||
},
|
||||
"bauhaus": {
|
||||
"name": "Bauhaus",
|
||||
"description": "Geometric design with primary colors"
|
||||
},
|
||||
"railway": {
|
||||
"name": "Railway",
|
||||
"description": "Swiss railway station clock style"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Retro",
|
||||
"description": "Pixel-style CRT display"
|
||||
},
|
||||
"gradient": {
|
||||
"name": "Gradient",
|
||||
"description": "Modern display with color shifts"
|
||||
},
|
||||
"terminal": {
|
||||
"name": "Terminal",
|
||||
"description": "Command-line interface style"
|
||||
},
|
||||
"typewriter": {
|
||||
"name": "Typewriter",
|
||||
"description": "Vintage mechanical keyboard style"
|
||||
},
|
||||
"radar": {
|
||||
"name": "Radar",
|
||||
"description": "Military radar screen display"
|
||||
}
|
||||
}
|
||||
}
|
||||
245
apps/clock/apps/web/src/lib/i18n/locales/es.json
Normal file
245
apps/clock/apps/web/src/lib/i18n/locales/es.json
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Cargando..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panel",
|
||||
"alarms": "Alarmas",
|
||||
"timers": "Temporizadores",
|
||||
"stopwatch": "Cronómetro",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "Reloj mundial",
|
||||
"settings": "Ajustes",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
"register": "Registrarse",
|
||||
"logout": "Cerrar sesión",
|
||||
"forgotPassword": "Olvidé mi contraseña",
|
||||
"email": "Correo electrónico",
|
||||
"password": "Contraseña",
|
||||
"confirmPassword": "Confirmar contraseña"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
"nextAlarm": "Próxima alarma",
|
||||
"activeTimers": "Temporizadores activos",
|
||||
"worldClocks": "Relojes mundiales"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Alarmas",
|
||||
"add": "Agregar alarma",
|
||||
"edit": "Editar alarma",
|
||||
"delete": "Eliminar alarma",
|
||||
"label": "Etiqueta",
|
||||
"time": "Hora",
|
||||
"repeat": "Repetir",
|
||||
"sound": "Sonido",
|
||||
"snooze": "Posponer",
|
||||
"snoozeMinutes": "{minutes} minutos",
|
||||
"enabled": "Activada",
|
||||
"disabled": "Desactivada",
|
||||
"noAlarms": "No hay alarmas configuradas",
|
||||
"days": {
|
||||
"sun": "Dom",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mié",
|
||||
"thu": "Jue",
|
||||
"fri": "Vie",
|
||||
"sat": "Sáb"
|
||||
},
|
||||
"once": "Una vez",
|
||||
"daily": "Diario",
|
||||
"weekdays": "Días laborables",
|
||||
"weekends": "Fines de semana",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Temporizadores",
|
||||
"add": "Agregar temporizador",
|
||||
"start": "Iniciar",
|
||||
"pause": "Pausar",
|
||||
"reset": "Reiniciar",
|
||||
"stop": "Detener",
|
||||
"delete": "Eliminar",
|
||||
"label": "Etiqueta",
|
||||
"duration": "Duración",
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"seconds": "Segundos",
|
||||
"noTimers": "No hay temporizadores activos",
|
||||
"presets": "Selección rápida",
|
||||
"finished": "¡Temporizador terminado!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Cronómetro",
|
||||
"start": "Iniciar",
|
||||
"stop": "Detener",
|
||||
"lap": "Vuelta",
|
||||
"reset": "Reiniciar",
|
||||
"laps": "Vueltas",
|
||||
"noLaps": "Aún no hay vueltas",
|
||||
"best": "Mejor",
|
||||
"worst": "Peor",
|
||||
"total": "Total",
|
||||
"new": "Nuevo",
|
||||
"continue": "Continuar",
|
||||
"noStopwatches": "Sin cronómetros",
|
||||
"noStopwatchesDescription": "Inicia tu primer cronómetro para seguir múltiples mediciones de tiempo simultáneamente.",
|
||||
"startFirst": "Iniciar primer cronómetro",
|
||||
"otherStopwatches": "Otros cronómetros"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Trabajo",
|
||||
"break": "Descanso",
|
||||
"longBreak": "Descanso largo",
|
||||
"sessions": "Sesiones",
|
||||
"sessionsCompleted": "{count} de {total} sesiones",
|
||||
"start": "Iniciar",
|
||||
"pause": "Pausar",
|
||||
"skip": "Saltar",
|
||||
"reset": "Reiniciar",
|
||||
"presets": {
|
||||
"classic": "Clásico",
|
||||
"shortFocus": "Enfoque corto",
|
||||
"deepWork": "Trabajo profundo"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Duración del trabajo",
|
||||
"breakDuration": "Duración del descanso",
|
||||
"longBreakDuration": "Duración del descanso largo",
|
||||
"sessionsBeforeLongBreak": "Sesiones antes del descanso largo"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "Reloj mundial",
|
||||
"add": "Agregar ciudad",
|
||||
"search": "Buscar ciudad o zona horaria...",
|
||||
"noClocks": "No hay ciudades agregadas",
|
||||
"difference": "{hours} hrs {direction}",
|
||||
"ahead": "adelante",
|
||||
"behind": "atrás",
|
||||
"same": "Misma hora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes",
|
||||
"general": "General",
|
||||
"appearance": "Apariencia",
|
||||
"sounds": "Sonidos",
|
||||
"notifications": "Notificaciones",
|
||||
"language": "Idioma",
|
||||
"theme": "Tema",
|
||||
"darkMode": "Modo oscuro",
|
||||
"clockFormat": "Formato de hora",
|
||||
"format24h": "24 horas",
|
||||
"format12h": "12 horas (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"add": "Agregar",
|
||||
"confirm": "Confirmar",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"loading": "Cargando...",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
"back": "Volver"
|
||||
},
|
||||
"clockFaces": {
|
||||
"title": "Esferas de Reloj",
|
||||
"subtitle": "Elige tu diseño de reloj preferido para la página de inicio",
|
||||
"customize": "Personalizar reloj",
|
||||
"currentSelection": "Selección actual",
|
||||
"analog": "Relojes analógicos",
|
||||
"digital": "Relojes digitales",
|
||||
"selected": "Seleccionado",
|
||||
"classic": {
|
||||
"name": "Clásico",
|
||||
"description": "Reloj elegante con números romanos"
|
||||
},
|
||||
"minimalist": {
|
||||
"name": "Minimalista",
|
||||
"description": "Diseño limpio con marcadores de hora"
|
||||
},
|
||||
"modern": {
|
||||
"name": "Moderno",
|
||||
"description": "Estilo contemporáneo con números árabes"
|
||||
},
|
||||
"elegant": {
|
||||
"name": "Elegante",
|
||||
"description": "Acentos dorados de lujo"
|
||||
},
|
||||
"sporty": {
|
||||
"name": "Deportivo",
|
||||
"description": "Diseño audaz inspirado en relojes deportivos"
|
||||
},
|
||||
"lcd": {
|
||||
"name": "LCD",
|
||||
"description": "Pantalla LCD clásica de 7 segmentos"
|
||||
},
|
||||
"flip": {
|
||||
"name": "Flip",
|
||||
"description": "Estilo retro de paneles giratorios"
|
||||
},
|
||||
"matrix": {
|
||||
"name": "Matriz",
|
||||
"description": "Pantalla LED de matriz de puntos"
|
||||
},
|
||||
"neon": {
|
||||
"name": "Neón",
|
||||
"description": "Efecto de tubo de neón brillante"
|
||||
},
|
||||
"binary": {
|
||||
"name": "Binario",
|
||||
"description": "Hora mostrada en formato binario"
|
||||
},
|
||||
"vintage": {
|
||||
"name": "Vintage",
|
||||
"description": "Reloj antiguo con pátina envejecida"
|
||||
},
|
||||
"nautical": {
|
||||
"name": "Náutico",
|
||||
"description": "Reloj de barco de latón estilo marino"
|
||||
},
|
||||
"industrial": {
|
||||
"name": "Industrial",
|
||||
"description": "Estilo fábrica con acentos metálicos"
|
||||
},
|
||||
"bauhaus": {
|
||||
"name": "Bauhaus",
|
||||
"description": "Diseño geométrico con colores primarios"
|
||||
},
|
||||
"railway": {
|
||||
"name": "Estación",
|
||||
"description": "Estilo reloj de estación suiza"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Retro",
|
||||
"description": "Pantalla CRT estilo pixel"
|
||||
},
|
||||
"gradient": {
|
||||
"name": "Degradado",
|
||||
"description": "Pantalla moderna con cambios de color"
|
||||
},
|
||||
"terminal": {
|
||||
"name": "Terminal",
|
||||
"description": "Estilo interfaz de línea de comandos"
|
||||
},
|
||||
"typewriter": {
|
||||
"name": "Máquina de escribir",
|
||||
"description": "Estilo teclado mecánico vintage"
|
||||
},
|
||||
"radar": {
|
||||
"name": "Radar",
|
||||
"description": "Pantalla de radar militar"
|
||||
}
|
||||
}
|
||||
}
|
||||
245
apps/clock/apps/web/src/lib/i18n/locales/fr.json
Normal file
245
apps/clock/apps/web/src/lib/i18n/locales/fr.json
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Chargement..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"alarms": "Alarmes",
|
||||
"timers": "Minuteries",
|
||||
"stopwatch": "Chronomètre",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "Horloge mondiale",
|
||||
"settings": "Paramètres",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"register": "Inscription",
|
||||
"logout": "Déconnexion",
|
||||
"forgotPassword": "Mot de passe oublié",
|
||||
"email": "E-mail",
|
||||
"password": "Mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"nextAlarm": "Prochaine alarme",
|
||||
"activeTimers": "Minuteries actives",
|
||||
"worldClocks": "Horloges mondiales"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Alarmes",
|
||||
"add": "Ajouter une alarme",
|
||||
"edit": "Modifier l'alarme",
|
||||
"delete": "Supprimer l'alarme",
|
||||
"label": "Libellé",
|
||||
"time": "Heure",
|
||||
"repeat": "Répéter",
|
||||
"sound": "Son",
|
||||
"snooze": "Répétition",
|
||||
"snoozeMinutes": "{minutes} minutes",
|
||||
"enabled": "Activée",
|
||||
"disabled": "Désactivée",
|
||||
"noAlarms": "Aucune alarme définie",
|
||||
"days": {
|
||||
"sun": "Dim",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mer",
|
||||
"thu": "Jeu",
|
||||
"fri": "Ven",
|
||||
"sat": "Sam"
|
||||
},
|
||||
"once": "Une fois",
|
||||
"daily": "Quotidien",
|
||||
"weekdays": "Jours de semaine",
|
||||
"weekends": "Week-ends",
|
||||
"custom": "Personnalisé"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Minuteries",
|
||||
"add": "Ajouter une minuterie",
|
||||
"start": "Démarrer",
|
||||
"pause": "Pause",
|
||||
"reset": "Réinitialiser",
|
||||
"stop": "Arrêter",
|
||||
"delete": "Supprimer",
|
||||
"label": "Libellé",
|
||||
"duration": "Durée",
|
||||
"hours": "Heures",
|
||||
"minutes": "Minutes",
|
||||
"seconds": "Secondes",
|
||||
"noTimers": "Aucune minuterie active",
|
||||
"presets": "Sélection rapide",
|
||||
"finished": "Minuterie terminée!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Chronomètre",
|
||||
"start": "Démarrer",
|
||||
"stop": "Arrêter",
|
||||
"lap": "Tour",
|
||||
"reset": "Réinitialiser",
|
||||
"laps": "Tours",
|
||||
"noLaps": "Pas encore de tours",
|
||||
"best": "Meilleur",
|
||||
"worst": "Pire",
|
||||
"total": "Total",
|
||||
"new": "Nouveau",
|
||||
"continue": "Continuer",
|
||||
"noStopwatches": "Aucun chronomètre",
|
||||
"noStopwatchesDescription": "Démarrez votre premier chronomètre pour suivre plusieurs mesures de temps simultanément.",
|
||||
"startFirst": "Démarrer le premier chronomètre",
|
||||
"otherStopwatches": "Autres chronomètres"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Travail",
|
||||
"break": "Pause",
|
||||
"longBreak": "Longue pause",
|
||||
"sessions": "Sessions",
|
||||
"sessionsCompleted": "{count} sur {total} sessions",
|
||||
"start": "Démarrer",
|
||||
"pause": "Pause",
|
||||
"skip": "Passer",
|
||||
"reset": "Réinitialiser",
|
||||
"presets": {
|
||||
"classic": "Classique",
|
||||
"shortFocus": "Focus court",
|
||||
"deepWork": "Travail profond"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Durée de travail",
|
||||
"breakDuration": "Durée de pause",
|
||||
"longBreakDuration": "Durée de longue pause",
|
||||
"sessionsBeforeLongBreak": "Sessions avant longue pause"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "Horloge mondiale",
|
||||
"add": "Ajouter une ville",
|
||||
"search": "Rechercher ville ou fuseau horaire...",
|
||||
"noClocks": "Aucune ville ajoutée",
|
||||
"difference": "{hours} h {direction}",
|
||||
"ahead": "d'avance",
|
||||
"behind": "de retard",
|
||||
"same": "Même heure"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"general": "Général",
|
||||
"appearance": "Apparence",
|
||||
"sounds": "Sons",
|
||||
"notifications": "Notifications",
|
||||
"language": "Langue",
|
||||
"theme": "Thème",
|
||||
"darkMode": "Mode sombre",
|
||||
"clockFormat": "Format d'heure",
|
||||
"format24h": "24 heures",
|
||||
"format12h": "12 heures (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"add": "Ajouter",
|
||||
"confirm": "Confirmer",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"ok": "OK",
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"back": "Retour"
|
||||
},
|
||||
"clockFaces": {
|
||||
"title": "Cadrans",
|
||||
"subtitle": "Choisissez votre design d'horloge préféré pour la page d'accueil",
|
||||
"customize": "Personnaliser l'horloge",
|
||||
"currentSelection": "Sélection actuelle",
|
||||
"analog": "Horloges analogiques",
|
||||
"digital": "Horloges numériques",
|
||||
"selected": "Sélectionné",
|
||||
"classic": {
|
||||
"name": "Classique",
|
||||
"description": "Horloge élégante avec chiffres romains"
|
||||
},
|
||||
"minimalist": {
|
||||
"name": "Minimaliste",
|
||||
"description": "Design épuré avec marqueurs d'heures"
|
||||
},
|
||||
"modern": {
|
||||
"name": "Moderne",
|
||||
"description": "Style contemporain avec chiffres arabes"
|
||||
},
|
||||
"elegant": {
|
||||
"name": "Élégant",
|
||||
"description": "Accents dorés luxueux"
|
||||
},
|
||||
"sporty": {
|
||||
"name": "Sportif",
|
||||
"description": "Design audacieux inspiré des montres de sport"
|
||||
},
|
||||
"lcd": {
|
||||
"name": "LCD",
|
||||
"description": "Affichage LCD classique à 7 segments"
|
||||
},
|
||||
"flip": {
|
||||
"name": "Flip",
|
||||
"description": "Style rétro à volets"
|
||||
},
|
||||
"matrix": {
|
||||
"name": "Matrice",
|
||||
"description": "Affichage LED à matrice de points"
|
||||
},
|
||||
"neon": {
|
||||
"name": "Néon",
|
||||
"description": "Effet tube néon lumineux"
|
||||
},
|
||||
"binary": {
|
||||
"name": "Binaire",
|
||||
"description": "Heure affichée en format binaire"
|
||||
},
|
||||
"vintage": {
|
||||
"name": "Vintage",
|
||||
"description": "Horloge ancienne avec patine vieillie"
|
||||
},
|
||||
"nautical": {
|
||||
"name": "Nautique",
|
||||
"description": "Horloge de navire en laiton style marin"
|
||||
},
|
||||
"industrial": {
|
||||
"name": "Industriel",
|
||||
"description": "Style usine avec accents métalliques"
|
||||
},
|
||||
"bauhaus": {
|
||||
"name": "Bauhaus",
|
||||
"description": "Design géométrique avec couleurs primaires"
|
||||
},
|
||||
"railway": {
|
||||
"name": "Gare",
|
||||
"description": "Style horloge de gare suisse"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Rétro",
|
||||
"description": "Affichage CRT style pixel"
|
||||
},
|
||||
"gradient": {
|
||||
"name": "Dégradé",
|
||||
"description": "Affichage moderne avec changements de couleur"
|
||||
},
|
||||
"terminal": {
|
||||
"name": "Terminal",
|
||||
"description": "Style interface ligne de commande"
|
||||
},
|
||||
"typewriter": {
|
||||
"name": "Machine à écrire",
|
||||
"description": "Style clavier mécanique vintage"
|
||||
},
|
||||
"radar": {
|
||||
"name": "Radar",
|
||||
"description": "Affichage écran radar militaire"
|
||||
}
|
||||
}
|
||||
}
|
||||
245
apps/clock/apps/web/src/lib/i18n/locales/it.json
Normal file
245
apps/clock/apps/web/src/lib/i18n/locales/it.json
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Clock",
|
||||
"loading": "Caricamento..."
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Panoramica",
|
||||
"alarms": "Sveglie",
|
||||
"timers": "Timer",
|
||||
"stopwatch": "Cronometro",
|
||||
"pomodoro": "Pomodoro",
|
||||
"worldClock": "Orologio mondiale",
|
||||
"settings": "Impostazioni",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
"register": "Registrati",
|
||||
"logout": "Esci",
|
||||
"forgotPassword": "Password dimenticata",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Conferma password"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panoramica",
|
||||
"nextAlarm": "Prossima sveglia",
|
||||
"activeTimers": "Timer attivi",
|
||||
"worldClocks": "Orologi mondiali"
|
||||
},
|
||||
"alarm": {
|
||||
"title": "Sveglie",
|
||||
"add": "Aggiungi sveglia",
|
||||
"edit": "Modifica sveglia",
|
||||
"delete": "Elimina sveglia",
|
||||
"label": "Etichetta",
|
||||
"time": "Ora",
|
||||
"repeat": "Ripeti",
|
||||
"sound": "Suono",
|
||||
"snooze": "Posticipa",
|
||||
"snoozeMinutes": "{minutes} minuti",
|
||||
"enabled": "Attiva",
|
||||
"disabled": "Disattiva",
|
||||
"noAlarms": "Nessuna sveglia impostata",
|
||||
"days": {
|
||||
"sun": "Dom",
|
||||
"mon": "Lun",
|
||||
"tue": "Mar",
|
||||
"wed": "Mer",
|
||||
"thu": "Gio",
|
||||
"fri": "Ven",
|
||||
"sat": "Sab"
|
||||
},
|
||||
"once": "Una volta",
|
||||
"daily": "Giornaliero",
|
||||
"weekdays": "Giorni feriali",
|
||||
"weekends": "Fine settimana",
|
||||
"custom": "Personalizzato"
|
||||
},
|
||||
"timer": {
|
||||
"title": "Timer",
|
||||
"add": "Aggiungi timer",
|
||||
"start": "Avvia",
|
||||
"pause": "Pausa",
|
||||
"reset": "Reimposta",
|
||||
"stop": "Ferma",
|
||||
"delete": "Elimina",
|
||||
"label": "Etichetta",
|
||||
"duration": "Durata",
|
||||
"hours": "Ore",
|
||||
"minutes": "Minuti",
|
||||
"seconds": "Secondi",
|
||||
"noTimers": "Nessun timer attivo",
|
||||
"presets": "Selezione rapida",
|
||||
"finished": "Timer terminato!"
|
||||
},
|
||||
"stopwatch": {
|
||||
"title": "Cronometro",
|
||||
"start": "Avvia",
|
||||
"stop": "Ferma",
|
||||
"lap": "Giro",
|
||||
"reset": "Reimposta",
|
||||
"laps": "Giri",
|
||||
"noLaps": "Nessun giro ancora",
|
||||
"best": "Migliore",
|
||||
"worst": "Peggiore",
|
||||
"total": "Totale",
|
||||
"new": "Nuovo",
|
||||
"continue": "Continua",
|
||||
"noStopwatches": "Nessun cronometro",
|
||||
"noStopwatchesDescription": "Avvia il tuo primo cronometro per tracciare più misurazioni di tempo contemporaneamente.",
|
||||
"startFirst": "Avvia primo cronometro",
|
||||
"otherStopwatches": "Altri cronometri"
|
||||
},
|
||||
"pomodoro": {
|
||||
"title": "Pomodoro",
|
||||
"work": "Lavoro",
|
||||
"break": "Pausa",
|
||||
"longBreak": "Pausa lunga",
|
||||
"sessions": "Sessioni",
|
||||
"sessionsCompleted": "{count} di {total} sessioni",
|
||||
"start": "Avvia",
|
||||
"pause": "Pausa",
|
||||
"skip": "Salta",
|
||||
"reset": "Reimposta",
|
||||
"presets": {
|
||||
"classic": "Classico",
|
||||
"shortFocus": "Focus breve",
|
||||
"deepWork": "Lavoro profondo"
|
||||
},
|
||||
"settings": {
|
||||
"workDuration": "Durata lavoro",
|
||||
"breakDuration": "Durata pausa",
|
||||
"longBreakDuration": "Durata pausa lunga",
|
||||
"sessionsBeforeLongBreak": "Sessioni prima della pausa lunga"
|
||||
}
|
||||
},
|
||||
"worldClock": {
|
||||
"title": "Orologio mondiale",
|
||||
"add": "Aggiungi città",
|
||||
"search": "Cerca città o fuso orario...",
|
||||
"noClocks": "Nessuna città aggiunta",
|
||||
"difference": "{hours} ore {direction}",
|
||||
"ahead": "avanti",
|
||||
"behind": "indietro",
|
||||
"same": "Stessa ora"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"general": "Generale",
|
||||
"appearance": "Aspetto",
|
||||
"sounds": "Suoni",
|
||||
"notifications": "Notifiche",
|
||||
"language": "Lingua",
|
||||
"theme": "Tema",
|
||||
"darkMode": "Modalità scura",
|
||||
"clockFormat": "Formato ora",
|
||||
"format24h": "24 ore",
|
||||
"format12h": "12 ore (AM/PM)"
|
||||
},
|
||||
"common": {
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"edit": "Modifica",
|
||||
"add": "Aggiungi",
|
||||
"confirm": "Conferma",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"loading": "Caricamento...",
|
||||
"error": "Errore",
|
||||
"success": "Successo",
|
||||
"back": "Indietro"
|
||||
},
|
||||
"clockFaces": {
|
||||
"title": "Quadranti",
|
||||
"subtitle": "Scegli il tuo design di orologio preferito per la home",
|
||||
"customize": "Personalizza orologio",
|
||||
"currentSelection": "Selezione attuale",
|
||||
"analog": "Orologi analogici",
|
||||
"digital": "Orologi digitali",
|
||||
"selected": "Selezionato",
|
||||
"classic": {
|
||||
"name": "Classico",
|
||||
"description": "Orologio elegante con numeri romani"
|
||||
},
|
||||
"minimalist": {
|
||||
"name": "Minimalista",
|
||||
"description": "Design pulito con marcatori delle ore"
|
||||
},
|
||||
"modern": {
|
||||
"name": "Moderno",
|
||||
"description": "Stile contemporaneo con numeri arabi"
|
||||
},
|
||||
"elegant": {
|
||||
"name": "Elegante",
|
||||
"description": "Accenti dorati di lusso"
|
||||
},
|
||||
"sporty": {
|
||||
"name": "Sportivo",
|
||||
"description": "Design audace ispirato agli orologi sportivi"
|
||||
},
|
||||
"lcd": {
|
||||
"name": "LCD",
|
||||
"description": "Display LCD classico a 7 segmenti"
|
||||
},
|
||||
"flip": {
|
||||
"name": "Flip",
|
||||
"description": "Stile retrò a palette"
|
||||
},
|
||||
"matrix": {
|
||||
"name": "Matrice",
|
||||
"description": "Display LED a matrice di punti"
|
||||
},
|
||||
"neon": {
|
||||
"name": "Neon",
|
||||
"description": "Effetto tubo neon luminoso"
|
||||
},
|
||||
"binary": {
|
||||
"name": "Binario",
|
||||
"description": "Ora visualizzata in formato binario"
|
||||
},
|
||||
"vintage": {
|
||||
"name": "Vintage",
|
||||
"description": "Orologio antico con patina invecchiata"
|
||||
},
|
||||
"nautical": {
|
||||
"name": "Nautico",
|
||||
"description": "Orologio da nave in ottone stile marino"
|
||||
},
|
||||
"industrial": {
|
||||
"name": "Industriale",
|
||||
"description": "Stile fabbrica con accenti metallici"
|
||||
},
|
||||
"bauhaus": {
|
||||
"name": "Bauhaus",
|
||||
"description": "Design geometrico con colori primari"
|
||||
},
|
||||
"railway": {
|
||||
"name": "Stazione",
|
||||
"description": "Stile orologio stazione svizzera"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Retro",
|
||||
"description": "Display CRT stile pixel"
|
||||
},
|
||||
"gradient": {
|
||||
"name": "Sfumato",
|
||||
"description": "Display moderno con cambi di colore"
|
||||
},
|
||||
"terminal": {
|
||||
"name": "Terminale",
|
||||
"description": "Stile interfaccia riga di comando"
|
||||
},
|
||||
"typewriter": {
|
||||
"name": "Macchina da scrivere",
|
||||
"description": "Stile tastiera meccanica vintage"
|
||||
},
|
||||
"radar": {
|
||||
"name": "Radar",
|
||||
"description": "Display schermo radar militare"
|
||||
}
|
||||
}
|
||||
}
|
||||
135
apps/clock/apps/web/src/lib/stores/alarms.svelte.ts
Normal file
135
apps/clock/apps/web/src/lib/stores/alarms.svelte.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Alarms Store - Manages alarm state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
|
||||
import { alarmsApi } from '$lib/api/alarms';
|
||||
|
||||
// State
|
||||
let alarms = $state<Alarm[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const alarmsStore = {
|
||||
// Getters
|
||||
get alarms() {
|
||||
return alarms;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get enabledAlarms() {
|
||||
return alarms.filter((a) => a.enabled);
|
||||
},
|
||||
get nextAlarm() {
|
||||
const now = new Date();
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
const enabled = alarms.filter((a) => a.enabled);
|
||||
if (enabled.length === 0) return null;
|
||||
|
||||
// Find the next alarm based on time
|
||||
let nextAlarm: Alarm | null = null;
|
||||
let minDiff = Infinity;
|
||||
|
||||
for (const alarm of enabled) {
|
||||
const [hours, minutes] = alarm.time.split(':').map(Number);
|
||||
const alarmTime = hours * 60 + minutes;
|
||||
let diff = alarmTime - currentTime;
|
||||
if (diff < 0) diff += 24 * 60; // Tomorrow
|
||||
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
nextAlarm = alarm;
|
||||
}
|
||||
}
|
||||
|
||||
return nextAlarm;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all alarms from the API
|
||||
*/
|
||||
async fetchAlarms() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await alarmsApi.getAll();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
alarms = result.data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new alarm
|
||||
*/
|
||||
async createAlarm(input: CreateAlarmInput) {
|
||||
const result = await alarmsApi.create(input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
alarms = [...alarms, result.data];
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing alarm
|
||||
*/
|
||||
async updateAlarm(id: string, input: UpdateAlarmInput) {
|
||||
const result = await alarmsApi.update(id, input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
alarms = alarms.map((a) => (a.id === id ? result.data! : a));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an alarm
|
||||
*/
|
||||
async deleteAlarm(id: string) {
|
||||
const result = await alarmsApi.delete(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
alarms = alarms.filter((a) => a.id !== id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle an alarm's enabled state
|
||||
*/
|
||||
async toggleAlarm(id: string) {
|
||||
const result = await alarmsApi.toggle(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
alarms = alarms.map((a) => (a.id === id ? result.data! : a));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
185
apps/clock/apps/web/src/lib/stores/auth.svelte.ts
Normal file
185
apps/clock/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Initialize Mana Core Auth only on the client side
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
// State
|
||||
let user = $state<UserData | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const authStore = {
|
||||
// Getters
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Login failed' };
|
||||
}
|
||||
|
||||
// Get user data from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
}
|
||||
|
||||
// Mana Core Auth requires separate login after signup
|
||||
if (result.needsVerification) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
// Auto sign in after successful signup
|
||||
const signInResult = await this.signIn(email, password);
|
||||
return { ...signInResult, needsVerification: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage, needsVerification: false };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
// Clear user even if sign out fails
|
||||
user = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access token for API calls
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
return await authService.getAppToken();
|
||||
},
|
||||
};
|
||||
280
apps/clock/apps/web/src/lib/stores/clock-face.svelte.ts
Normal file
280
apps/clock/apps/web/src/lib/stores/clock-face.svelte.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* Clock Face store for Clock app
|
||||
* Manages the selected clock face style for the homepage
|
||||
* SSR-safe implementation with localStorage persistence
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Storage key
|
||||
const CLOCK_FACE_KEY = 'clock-selected-face';
|
||||
|
||||
// Available clock face types
|
||||
export type ClockFaceType =
|
||||
// Analog faces
|
||||
| 'classic'
|
||||
| 'minimalist'
|
||||
| 'modern'
|
||||
| 'elegant'
|
||||
| 'sporty'
|
||||
| 'vintage'
|
||||
| 'nautical'
|
||||
| 'industrial'
|
||||
| 'bauhaus'
|
||||
| 'railway'
|
||||
// Digital faces
|
||||
| 'lcd'
|
||||
| 'flip'
|
||||
| 'matrix'
|
||||
| 'neon'
|
||||
| 'binary'
|
||||
| 'retro'
|
||||
| 'gradient'
|
||||
| 'terminal'
|
||||
| 'typewriter'
|
||||
| 'radar';
|
||||
|
||||
export interface ClockFaceDefinition {
|
||||
id: ClockFaceType;
|
||||
name: string;
|
||||
nameKey: string;
|
||||
description: string;
|
||||
descriptionKey: string;
|
||||
category: 'analog' | 'digital';
|
||||
}
|
||||
|
||||
// All available clock faces
|
||||
export const CLOCK_FACES: ClockFaceDefinition[] = [
|
||||
// Analog faces
|
||||
{
|
||||
id: 'classic',
|
||||
name: 'Classic',
|
||||
nameKey: 'clockFaces.classic.name',
|
||||
description: 'Elegant clock with Roman numerals',
|
||||
descriptionKey: 'clockFaces.classic.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'minimalist',
|
||||
name: 'Minimalist',
|
||||
nameKey: 'clockFaces.minimalist.name',
|
||||
description: 'Clean design with only hour markers',
|
||||
descriptionKey: 'clockFaces.minimalist.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'modern',
|
||||
name: 'Modern',
|
||||
nameKey: 'clockFaces.modern.name',
|
||||
description: 'Contemporary style with Arabic numerals',
|
||||
descriptionKey: 'clockFaces.modern.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'elegant',
|
||||
name: 'Elegant',
|
||||
nameKey: 'clockFaces.elegant.name',
|
||||
description: 'Luxurious golden accents',
|
||||
descriptionKey: 'clockFaces.elegant.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'sporty',
|
||||
name: 'Sporty',
|
||||
nameKey: 'clockFaces.sporty.name',
|
||||
description: 'Bold design inspired by sports watches',
|
||||
descriptionKey: 'clockFaces.sporty.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'vintage',
|
||||
name: 'Vintage',
|
||||
nameKey: 'clockFaces.vintage.name',
|
||||
description: 'Antique clock with aged patina',
|
||||
descriptionKey: 'clockFaces.vintage.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'nautical',
|
||||
name: 'Nautical',
|
||||
nameKey: 'clockFaces.nautical.name',
|
||||
description: 'Marine-inspired brass ship clock',
|
||||
descriptionKey: 'clockFaces.nautical.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'industrial',
|
||||
name: 'Industrial',
|
||||
nameKey: 'clockFaces.industrial.name',
|
||||
description: 'Factory-style with metal accents',
|
||||
descriptionKey: 'clockFaces.industrial.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'bauhaus',
|
||||
name: 'Bauhaus',
|
||||
nameKey: 'clockFaces.bauhaus.name',
|
||||
description: 'Geometric design with primary colors',
|
||||
descriptionKey: 'clockFaces.bauhaus.description',
|
||||
category: 'analog',
|
||||
},
|
||||
{
|
||||
id: 'railway',
|
||||
name: 'Railway',
|
||||
nameKey: 'clockFaces.railway.name',
|
||||
description: 'Swiss railway station clock style',
|
||||
descriptionKey: 'clockFaces.railway.description',
|
||||
category: 'analog',
|
||||
},
|
||||
// Digital faces
|
||||
{
|
||||
id: 'lcd',
|
||||
name: 'LCD',
|
||||
nameKey: 'clockFaces.lcd.name',
|
||||
description: 'Classic 7-segment LCD display',
|
||||
descriptionKey: 'clockFaces.lcd.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'flip',
|
||||
name: 'Flip Clock',
|
||||
nameKey: 'clockFaces.flip.name',
|
||||
description: 'Retro split-flap display style',
|
||||
descriptionKey: 'clockFaces.flip.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'matrix',
|
||||
name: 'Matrix',
|
||||
nameKey: 'clockFaces.matrix.name',
|
||||
description: 'Dot matrix LED display',
|
||||
descriptionKey: 'clockFaces.matrix.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'neon',
|
||||
name: 'Neon',
|
||||
nameKey: 'clockFaces.neon.name',
|
||||
description: 'Glowing neon tube effect',
|
||||
descriptionKey: 'clockFaces.neon.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'binary',
|
||||
name: 'Binary',
|
||||
nameKey: 'clockFaces.binary.name',
|
||||
description: 'Time displayed in binary format',
|
||||
descriptionKey: 'clockFaces.binary.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'retro',
|
||||
name: 'Retro',
|
||||
nameKey: 'clockFaces.retro.name',
|
||||
description: 'Pixel-style CRT display',
|
||||
descriptionKey: 'clockFaces.retro.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'gradient',
|
||||
name: 'Gradient',
|
||||
nameKey: 'clockFaces.gradient.name',
|
||||
description: 'Modern display with color shifts',
|
||||
descriptionKey: 'clockFaces.gradient.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
name: 'Terminal',
|
||||
nameKey: 'clockFaces.terminal.name',
|
||||
description: 'Command-line interface style',
|
||||
descriptionKey: 'clockFaces.terminal.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'typewriter',
|
||||
name: 'Typewriter',
|
||||
nameKey: 'clockFaces.typewriter.name',
|
||||
description: 'Vintage mechanical keyboard style',
|
||||
descriptionKey: 'clockFaces.typewriter.description',
|
||||
category: 'digital',
|
||||
},
|
||||
{
|
||||
id: 'radar',
|
||||
name: 'Radar',
|
||||
nameKey: 'clockFaces.radar.name',
|
||||
description: 'Military radar screen display',
|
||||
descriptionKey: 'clockFaces.radar.description',
|
||||
category: 'digital',
|
||||
},
|
||||
];
|
||||
|
||||
// Default clock face
|
||||
const DEFAULT_FACE: ClockFaceType = 'modern';
|
||||
|
||||
// State
|
||||
let selectedFace = $state<ClockFaceType>(DEFAULT_FACE);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const clockFaceStore = {
|
||||
// Getters
|
||||
get selectedFace(): ClockFaceType {
|
||||
return selectedFace ?? DEFAULT_FACE;
|
||||
},
|
||||
get initialized(): boolean {
|
||||
return initialized;
|
||||
},
|
||||
get faces(): ClockFaceDefinition[] {
|
||||
return CLOCK_FACES;
|
||||
},
|
||||
get analogFaces(): ClockFaceDefinition[] {
|
||||
return CLOCK_FACES.filter((f) => f.category === 'analog');
|
||||
},
|
||||
get digitalFaces(): ClockFaceDefinition[] {
|
||||
return CLOCK_FACES.filter((f) => f.category === 'digital');
|
||||
},
|
||||
get currentFace(): ClockFaceDefinition | undefined {
|
||||
return CLOCK_FACES.find((f) => f.id === selectedFace);
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize from localStorage (client-side only)
|
||||
*/
|
||||
initialize() {
|
||||
if (!browser) return;
|
||||
if (initialized) return;
|
||||
|
||||
const saved = localStorage.getItem(CLOCK_FACE_KEY) as ClockFaceType | null;
|
||||
if (saved && CLOCK_FACES.some((f) => f.id === saved)) {
|
||||
selectedFace = saved;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the selected clock face
|
||||
*/
|
||||
setFace(face: ClockFaceType) {
|
||||
if (!CLOCK_FACES.some((f) => f.id === face)) return;
|
||||
|
||||
selectedFace = face;
|
||||
if (browser) {
|
||||
localStorage.setItem(CLOCK_FACE_KEY, face);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a face is analog
|
||||
*/
|
||||
isAnalog(face: ClockFaceType): boolean {
|
||||
return CLOCK_FACES.find((f) => f.id === face)?.category === 'analog';
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a face is digital
|
||||
*/
|
||||
isDigital(face: ClockFaceType): boolean {
|
||||
return CLOCK_FACES.find((f) => f.id === face)?.category === 'digital';
|
||||
},
|
||||
};
|
||||
221
apps/clock/apps/web/src/lib/stores/life-clock.svelte.ts
Normal file
221
apps/clock/apps/web/src/lib/stores/life-clock.svelte.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Life Clock store for Clock app
|
||||
* Manages the user's birthdate and calculates life statistics
|
||||
* SSR-safe implementation with localStorage persistence
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Storage key
|
||||
const BIRTHDATE_KEY = 'clock-birthdate';
|
||||
|
||||
// Milestones in days
|
||||
export const MILESTONES = [
|
||||
1000, 2000, 3000, 4000, 5000, 7500, 10000, 12500, 15000, 17500, 20000, 25000, 30000, 35000, 40000,
|
||||
];
|
||||
|
||||
// Average life expectancy in years (can be customized)
|
||||
const DEFAULT_LIFE_EXPECTANCY = 82;
|
||||
|
||||
// State
|
||||
let birthdate = $state<string | null>(null);
|
||||
let initialized = $state(false);
|
||||
|
||||
export interface LifeStats {
|
||||
daysLived: number;
|
||||
hoursLived: number;
|
||||
minutesLived: number;
|
||||
secondsLived: number;
|
||||
weeksLived: number;
|
||||
monthsLived: number;
|
||||
yearsLived: number;
|
||||
exactAge: { years: number; months: number; days: number };
|
||||
heartbeats: number;
|
||||
breaths: number;
|
||||
sleepHours: number;
|
||||
mealsEaten: number;
|
||||
sunrises: number;
|
||||
}
|
||||
|
||||
export interface MilestoneInfo {
|
||||
days: number;
|
||||
reached: boolean;
|
||||
daysUntil: number;
|
||||
date: Date | null;
|
||||
}
|
||||
|
||||
function calculateStats(birthDate: Date, now: Date): LifeStats {
|
||||
const diffMs = now.getTime() - birthDate.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
|
||||
// Calculate exact age
|
||||
let years = now.getFullYear() - birthDate.getFullYear();
|
||||
let months = now.getMonth() - birthDate.getMonth();
|
||||
let days = now.getDate() - birthDate.getDate();
|
||||
|
||||
if (days < 0) {
|
||||
months--;
|
||||
const prevMonth = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
days += prevMonth.getDate();
|
||||
}
|
||||
if (months < 0) {
|
||||
years--;
|
||||
months += 12;
|
||||
}
|
||||
|
||||
const totalMonths = years * 12 + months;
|
||||
|
||||
// Fun facts calculations (approximate averages)
|
||||
const heartbeats = Math.floor(diffMinutes * 70); // ~70 bpm average
|
||||
const breaths = Math.floor(diffMinutes * 15); // ~15 breaths per minute
|
||||
const sleepHours = Math.floor(diffDays * 8); // ~8 hours sleep per day
|
||||
const mealsEaten = Math.floor(diffDays * 3); // 3 meals per day
|
||||
const sunrises = diffDays;
|
||||
|
||||
return {
|
||||
daysLived: diffDays,
|
||||
hoursLived: diffHours,
|
||||
minutesLived: diffMinutes,
|
||||
secondsLived: diffSeconds,
|
||||
weeksLived: diffWeeks,
|
||||
monthsLived: totalMonths,
|
||||
yearsLived: years,
|
||||
exactAge: { years, months, days },
|
||||
heartbeats,
|
||||
breaths,
|
||||
sleepHours,
|
||||
mealsEaten,
|
||||
sunrises,
|
||||
};
|
||||
}
|
||||
|
||||
function getMilestones(daysLived: number, birthDate: Date): MilestoneInfo[] {
|
||||
return MILESTONES.map((days) => {
|
||||
const reached = daysLived >= days;
|
||||
const daysUntil = reached ? 0 : days - daysLived;
|
||||
const date = reached ? null : new Date(birthDate.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
return { days, reached, daysUntil, date };
|
||||
});
|
||||
}
|
||||
|
||||
function getNextMilestone(daysLived: number, birthDate: Date): MilestoneInfo | null {
|
||||
const milestones = getMilestones(daysLived, birthDate);
|
||||
return milestones.find((m) => !m.reached) || null;
|
||||
}
|
||||
|
||||
function getLifeProgress(
|
||||
daysLived: number,
|
||||
lifeExpectancy: number = DEFAULT_LIFE_EXPECTANCY
|
||||
): number {
|
||||
const expectedDays = lifeExpectancy * 365.25;
|
||||
return Math.min((daysLived / expectedDays) * 100, 100);
|
||||
}
|
||||
|
||||
function getRemainingDays(
|
||||
daysLived: number,
|
||||
lifeExpectancy: number = DEFAULT_LIFE_EXPECTANCY
|
||||
): number {
|
||||
const expectedDays = Math.floor(lifeExpectancy * 365.25);
|
||||
return Math.max(expectedDays - daysLived, 0);
|
||||
}
|
||||
|
||||
export const lifeClockStore = {
|
||||
// Getters
|
||||
get birthdate(): string | null {
|
||||
return birthdate;
|
||||
},
|
||||
get initialized(): boolean {
|
||||
return initialized;
|
||||
},
|
||||
get hasBirthdate(): boolean {
|
||||
return birthdate !== null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize from localStorage (client-side only)
|
||||
*/
|
||||
initialize() {
|
||||
if (!browser) return;
|
||||
if (initialized) return;
|
||||
|
||||
const saved = localStorage.getItem(BIRTHDATE_KEY);
|
||||
if (saved) {
|
||||
birthdate = saved;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the user's birthdate
|
||||
*/
|
||||
setBirthdate(date: string) {
|
||||
birthdate = date;
|
||||
if (browser) {
|
||||
localStorage.setItem(BIRTHDATE_KEY, date);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the birthdate
|
||||
*/
|
||||
clearBirthdate() {
|
||||
birthdate = null;
|
||||
if (browser) {
|
||||
localStorage.removeItem(BIRTHDATE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get life statistics
|
||||
*/
|
||||
getStats(now: Date = new Date()): LifeStats | null {
|
||||
if (!birthdate) return null;
|
||||
const birthDate = new Date(birthdate);
|
||||
return calculateStats(birthDate, now);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all milestones
|
||||
*/
|
||||
getMilestones(now: Date = new Date()): MilestoneInfo[] {
|
||||
if (!birthdate) return [];
|
||||
const birthDate = new Date(birthdate);
|
||||
const stats = calculateStats(birthDate, now);
|
||||
return getMilestones(stats.daysLived, birthDate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get next upcoming milestone
|
||||
*/
|
||||
getNextMilestone(now: Date = new Date()): MilestoneInfo | null {
|
||||
if (!birthdate) return null;
|
||||
const birthDate = new Date(birthdate);
|
||||
const stats = calculateStats(birthDate, now);
|
||||
return getNextMilestone(stats.daysLived, birthDate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get life progress percentage
|
||||
*/
|
||||
getLifeProgress(now: Date = new Date(), lifeExpectancy?: number): number {
|
||||
if (!birthdate) return 0;
|
||||
const birthDate = new Date(birthdate);
|
||||
const stats = calculateStats(birthDate, now);
|
||||
return getLifeProgress(stats.daysLived, lifeExpectancy);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get remaining days based on life expectancy
|
||||
*/
|
||||
getRemainingDays(now: Date = new Date(), lifeExpectancy?: number): number {
|
||||
if (!birthdate) return 0;
|
||||
const birthDate = new Date(birthdate);
|
||||
const stats = calculateStats(birthDate, now);
|
||||
return getRemainingDays(stats.daysLived, lifeExpectancy);
|
||||
},
|
||||
};
|
||||
8
apps/clock/apps/web/src/lib/stores/navigation.ts
Normal file
8
apps/clock/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Navigation store for sidebar mode state
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
238
apps/clock/apps/web/src/lib/stores/pomodoro.svelte.ts
Normal file
238
apps/clock/apps/web/src/lib/stores/pomodoro.svelte.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Pomodoro Store - Local pomodoro timer state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { DEFAULT_POMODORO_SETTINGS } from '@clock/shared';
|
||||
|
||||
export type PomodoroPhase = 'work' | 'break' | 'longBreak';
|
||||
|
||||
// Settings
|
||||
let workDuration = $state(DEFAULT_POMODORO_SETTINGS.workDuration!);
|
||||
let breakDuration = $state(DEFAULT_POMODORO_SETTINGS.breakDuration!);
|
||||
let longBreakDuration = $state(DEFAULT_POMODORO_SETTINGS.longBreakDuration!);
|
||||
let sessionsBeforeLongBreak = $state(DEFAULT_POMODORO_SETTINGS.sessionsBeforeLongBreak!);
|
||||
|
||||
// State
|
||||
let phase = $state<PomodoroPhase>('work');
|
||||
let isRunning = $state(false);
|
||||
let remainingTime = $state(workDuration);
|
||||
let completedSessions = $state(0);
|
||||
let startTime = $state<number | null>(null);
|
||||
let pausedTimeRemaining = $state(workDuration);
|
||||
|
||||
// Animation frame for updating time
|
||||
let animationFrameId: number | null = null;
|
||||
|
||||
function updateTime() {
|
||||
if (startTime !== null && isRunning) {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
remainingTime = Math.max(0, pausedTimeRemaining - elapsed);
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
handlePhaseComplete();
|
||||
} else {
|
||||
animationFrameId = requestAnimationFrame(updateTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePhaseComplete() {
|
||||
isRunning = false;
|
||||
startTime = null;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
|
||||
// Play notification sound
|
||||
if (browser && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Pomodoro', {
|
||||
body:
|
||||
phase === 'work'
|
||||
? 'Arbeitszeit beendet! Zeit für eine Pause.'
|
||||
: 'Pause beendet! Bereit für die nächste Sitzung?',
|
||||
});
|
||||
}
|
||||
|
||||
// Advance to next phase
|
||||
if (phase === 'work') {
|
||||
completedSessions++;
|
||||
if (completedSessions % sessionsBeforeLongBreak === 0) {
|
||||
phase = 'longBreak';
|
||||
remainingTime = longBreakDuration;
|
||||
pausedTimeRemaining = longBreakDuration;
|
||||
} else {
|
||||
phase = 'break';
|
||||
remainingTime = breakDuration;
|
||||
pausedTimeRemaining = breakDuration;
|
||||
}
|
||||
} else {
|
||||
phase = 'work';
|
||||
remainingTime = workDuration;
|
||||
pausedTimeRemaining = workDuration;
|
||||
}
|
||||
}
|
||||
|
||||
export const pomodoroStore = {
|
||||
// Getters
|
||||
get phase() {
|
||||
return phase;
|
||||
},
|
||||
get isRunning() {
|
||||
return isRunning;
|
||||
},
|
||||
get remainingTime() {
|
||||
return remainingTime;
|
||||
},
|
||||
get completedSessions() {
|
||||
return completedSessions;
|
||||
},
|
||||
get sessionsBeforeLongBreak() {
|
||||
return sessionsBeforeLongBreak;
|
||||
},
|
||||
get currentPhaseDuration() {
|
||||
switch (phase) {
|
||||
case 'work':
|
||||
return workDuration;
|
||||
case 'break':
|
||||
return breakDuration;
|
||||
case 'longBreak':
|
||||
return longBreakDuration;
|
||||
}
|
||||
},
|
||||
get progress() {
|
||||
const total = this.currentPhaseDuration;
|
||||
return ((total - remainingTime) / total) * 100;
|
||||
},
|
||||
get formattedTime() {
|
||||
const minutes = Math.floor(remainingTime / 60);
|
||||
const seconds = remainingTime % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
// Settings getters
|
||||
get settings() {
|
||||
return {
|
||||
workDuration,
|
||||
breakDuration,
|
||||
longBreakDuration,
|
||||
sessionsBeforeLongBreak,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the timer
|
||||
*/
|
||||
start() {
|
||||
if (!isRunning) {
|
||||
isRunning = true;
|
||||
startTime = Date.now();
|
||||
updateTime();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause the timer
|
||||
*/
|
||||
pause() {
|
||||
if (isRunning) {
|
||||
isRunning = false;
|
||||
pausedTimeRemaining = remainingTime;
|
||||
startTime = null;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle start/pause
|
||||
*/
|
||||
toggle() {
|
||||
if (isRunning) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Skip to next phase
|
||||
*/
|
||||
skip() {
|
||||
this.pause();
|
||||
handlePhaseComplete();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the pomodoro timer
|
||||
*/
|
||||
reset() {
|
||||
isRunning = false;
|
||||
phase = 'work';
|
||||
remainingTime = workDuration;
|
||||
pausedTimeRemaining = workDuration;
|
||||
completedSessions = 0;
|
||||
startTime = null;
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update settings
|
||||
*/
|
||||
updateSettings(settings: {
|
||||
workDuration?: number;
|
||||
breakDuration?: number;
|
||||
longBreakDuration?: number;
|
||||
sessionsBeforeLongBreak?: number;
|
||||
}) {
|
||||
if (settings.workDuration !== undefined) {
|
||||
workDuration = settings.workDuration;
|
||||
}
|
||||
if (settings.breakDuration !== undefined) {
|
||||
breakDuration = settings.breakDuration;
|
||||
}
|
||||
if (settings.longBreakDuration !== undefined) {
|
||||
longBreakDuration = settings.longBreakDuration;
|
||||
}
|
||||
if (settings.sessionsBeforeLongBreak !== undefined) {
|
||||
sessionsBeforeLongBreak = settings.sessionsBeforeLongBreak;
|
||||
}
|
||||
|
||||
// Reset to work phase with new duration if not running
|
||||
if (!isRunning && phase === 'work') {
|
||||
remainingTime = workDuration;
|
||||
pausedTimeRemaining = workDuration;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load preset
|
||||
*/
|
||||
loadPreset(preset: {
|
||||
workDuration: number;
|
||||
breakDuration: number;
|
||||
longBreakDuration: number;
|
||||
sessionsBeforeLongBreak: number;
|
||||
}) {
|
||||
this.pause();
|
||||
this.updateSettings(preset);
|
||||
this.reset();
|
||||
},
|
||||
|
||||
/**
|
||||
* Request notification permission
|
||||
*/
|
||||
async requestNotificationPermission() {
|
||||
if (browser && 'Notification' in window) {
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission === 'granted';
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
402
apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts
Normal file
402
apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
/**
|
||||
* Multi-Stopwatch Store - Manages multiple stopwatches using Svelte 5 runes
|
||||
* Local-only with localStorage persistence
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface Lap {
|
||||
number: number;
|
||||
time: number; // milliseconds
|
||||
splitTime: number; // total time at lap
|
||||
}
|
||||
|
||||
export interface Stopwatch {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
isRunning: boolean;
|
||||
elapsedTime: number; // milliseconds
|
||||
laps: Lap[];
|
||||
startTime: number | null;
|
||||
pausedTime: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Available colors for stopwatches
|
||||
export const STOPWATCH_COLORS = [
|
||||
'#f59e0b', // amber (primary)
|
||||
'#ef4444', // red
|
||||
'#22c55e', // green
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
] as const;
|
||||
|
||||
// Storage key
|
||||
const STORAGE_KEY = 'clock-stopwatches';
|
||||
|
||||
// State
|
||||
let stopwatches = $state<Stopwatch[]>([]);
|
||||
let focusedId = $state<string | null>(null);
|
||||
|
||||
// Animation frames for each stopwatch
|
||||
const animationFrames: Map<string, number> = new Map();
|
||||
|
||||
// Initialize from localStorage
|
||||
function loadFromStorage(): Stopwatch[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.map((sw: any) => ({
|
||||
...sw,
|
||||
createdAt: new Date(sw.createdAt),
|
||||
isRunning: false, // Always start paused on page load
|
||||
startTime: null,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load stopwatches from storage:', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
if (!browser) return;
|
||||
try {
|
||||
const toStore = stopwatches.map((sw) => ({
|
||||
...sw,
|
||||
isRunning: false, // Don't persist running state
|
||||
startTime: null,
|
||||
}));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
|
||||
} catch (e) {
|
||||
console.error('Failed to save stopwatches to storage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
if (browser) {
|
||||
stopwatches = loadFromStorage();
|
||||
if (stopwatches.length > 0) {
|
||||
focusedId = stopwatches[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTime(id: string) {
|
||||
const sw = stopwatches.find((s) => s.id === id);
|
||||
if (sw && sw.isRunning && sw.startTime !== null) {
|
||||
const newElapsed = sw.pausedTime + (Date.now() - sw.startTime);
|
||||
stopwatches = stopwatches.map((s) => (s.id === id ? { ...s, elapsedTime: newElapsed } : s));
|
||||
animationFrames.set(
|
||||
id,
|
||||
requestAnimationFrame(() => updateTime(id))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getNextColor(): string {
|
||||
const usedColors = stopwatches.map((sw) => sw.color);
|
||||
const availableColor = STOPWATCH_COLORS.find((c) => !usedColors.includes(c));
|
||||
return availableColor || STOPWATCH_COLORS[stopwatches.length % STOPWATCH_COLORS.length];
|
||||
}
|
||||
|
||||
export const stopwatchesStore = {
|
||||
// Getters
|
||||
get stopwatches() {
|
||||
return stopwatches;
|
||||
},
|
||||
get focusedId() {
|
||||
return focusedId;
|
||||
},
|
||||
get focusedStopwatch() {
|
||||
return stopwatches.find((sw) => sw.id === focusedId) || null;
|
||||
},
|
||||
get runningCount() {
|
||||
return stopwatches.filter((sw) => sw.isRunning).length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new stopwatch
|
||||
*/
|
||||
create(label: string = ''): string {
|
||||
const id = crypto.randomUUID();
|
||||
const newStopwatch: Stopwatch = {
|
||||
id,
|
||||
label: label || `Stoppuhr ${stopwatches.length + 1}`,
|
||||
color: getNextColor(),
|
||||
isRunning: false,
|
||||
elapsedTime: 0,
|
||||
laps: [],
|
||||
startTime: null,
|
||||
pausedTime: 0,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
stopwatches = [...stopwatches, newStopwatch];
|
||||
focusedId = id;
|
||||
saveToStorage();
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a stopwatch
|
||||
*/
|
||||
delete(id: string) {
|
||||
// Stop animation if running
|
||||
const frame = animationFrames.get(id);
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
animationFrames.delete(id);
|
||||
}
|
||||
|
||||
stopwatches = stopwatches.filter((sw) => sw.id !== id);
|
||||
|
||||
// Update focused if needed
|
||||
if (focusedId === id) {
|
||||
focusedId = stopwatches.length > 0 ? stopwatches[0].id : null;
|
||||
}
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update stopwatch label
|
||||
*/
|
||||
updateLabel(id: string, label: string) {
|
||||
stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, label } : sw));
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update stopwatch color
|
||||
*/
|
||||
updateColor(id: string, color: string) {
|
||||
stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, color } : sw));
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set focused stopwatch
|
||||
*/
|
||||
setFocused(id: string | null) {
|
||||
focusedId = id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a stopwatch
|
||||
*/
|
||||
start(id: string) {
|
||||
const sw = stopwatches.find((s) => s.id === id);
|
||||
if (sw && !sw.isRunning) {
|
||||
stopwatches = stopwatches.map((s) =>
|
||||
s.id === id ? { ...s, isRunning: true, startTime: Date.now() } : s
|
||||
);
|
||||
updateTime(id);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause a stopwatch
|
||||
*/
|
||||
pause(id: string) {
|
||||
const sw = stopwatches.find((s) => s.id === id);
|
||||
if (sw && sw.isRunning) {
|
||||
// Cancel animation frame
|
||||
const frame = animationFrames.get(id);
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
animationFrames.delete(id);
|
||||
}
|
||||
|
||||
stopwatches = stopwatches.map((s) =>
|
||||
s.id === id
|
||||
? {
|
||||
...s,
|
||||
isRunning: false,
|
||||
pausedTime: s.elapsedTime,
|
||||
startTime: null,
|
||||
}
|
||||
: s
|
||||
);
|
||||
saveToStorage();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle start/pause
|
||||
*/
|
||||
toggle(id: string) {
|
||||
const sw = stopwatches.find((s) => s.id === id);
|
||||
if (sw) {
|
||||
if (sw.isRunning) {
|
||||
this.pause(id);
|
||||
} else {
|
||||
this.start(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Record a lap
|
||||
*/
|
||||
lap(id: string) {
|
||||
const sw = stopwatches.find((s) => s.id === id);
|
||||
if (sw && sw.elapsedTime > 0) {
|
||||
const lastLapTime = sw.laps.length > 0 ? sw.laps[sw.laps.length - 1].splitTime : 0;
|
||||
const lapTime = sw.elapsedTime - lastLapTime;
|
||||
|
||||
const newLap: Lap = {
|
||||
number: sw.laps.length + 1,
|
||||
time: lapTime,
|
||||
splitTime: sw.elapsedTime,
|
||||
};
|
||||
|
||||
stopwatches = stopwatches.map((s) => (s.id === id ? { ...s, laps: [...s.laps, newLap] } : s));
|
||||
saveToStorage();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset a stopwatch
|
||||
*/
|
||||
reset(id: string) {
|
||||
// Cancel animation frame
|
||||
const frame = animationFrames.get(id);
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
animationFrames.delete(id);
|
||||
}
|
||||
|
||||
stopwatches = stopwatches.map((s) =>
|
||||
s.id === id
|
||||
? {
|
||||
...s,
|
||||
isRunning: false,
|
||||
elapsedTime: 0,
|
||||
laps: [],
|
||||
startTime: null,
|
||||
pausedTime: 0,
|
||||
}
|
||||
: s
|
||||
);
|
||||
saveToStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get best lap for a stopwatch
|
||||
*/
|
||||
getBestLap(id: string): Lap | null {
|
||||
const sw = stopwatches.find((s) => s.id === id);
|
||||
if (!sw || sw.laps.length < 2) return null;
|
||||
return sw.laps.reduce((best, lap) => (lap.time < best.time ? lap : best));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get worst lap for a stopwatch
|
||||
*/
|
||||
getWorstLap(id: string): Lap | null {
|
||||
const sw = stopwatches.find((s) => s.id === id);
|
||||
if (!sw || sw.laps.length < 2) return null;
|
||||
return sw.laps.reduce((worst, lap) => (lap.time > worst.time ? lap : worst));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all stopwatches
|
||||
*/
|
||||
clearAll() {
|
||||
// Stop all animations
|
||||
animationFrames.forEach((frame) => cancelAnimationFrame(frame));
|
||||
animationFrames.clear();
|
||||
|
||||
stopwatches = [];
|
||||
focusedId = null;
|
||||
saveToStorage();
|
||||
},
|
||||
};
|
||||
|
||||
// Legacy single stopwatch store for backwards compatibility
|
||||
export const stopwatchStore = {
|
||||
get isRunning() {
|
||||
const focused = stopwatchesStore.focusedStopwatch;
|
||||
return focused?.isRunning || false;
|
||||
},
|
||||
get elapsedTime() {
|
||||
const focused = stopwatchesStore.focusedStopwatch;
|
||||
return focused?.elapsedTime || 0;
|
||||
},
|
||||
get laps() {
|
||||
const focused = stopwatchesStore.focusedStopwatch;
|
||||
return focused?.laps || [];
|
||||
},
|
||||
get formattedTime() {
|
||||
return formatTime(this.elapsedTime);
|
||||
},
|
||||
get bestLap() {
|
||||
const focused = stopwatchesStore.focusedStopwatch;
|
||||
return focused ? stopwatchesStore.getBestLap(focused.id) : null;
|
||||
},
|
||||
get worstLap() {
|
||||
const focused = stopwatchesStore.focusedStopwatch;
|
||||
return focused ? stopwatchesStore.getWorstLap(focused.id) : null;
|
||||
},
|
||||
start() {
|
||||
const id = stopwatchesStore.focusedId;
|
||||
if (id) stopwatchesStore.start(id);
|
||||
else {
|
||||
const newId = stopwatchesStore.create();
|
||||
stopwatchesStore.start(newId);
|
||||
}
|
||||
},
|
||||
pause() {
|
||||
const id = stopwatchesStore.focusedId;
|
||||
if (id) stopwatchesStore.pause(id);
|
||||
},
|
||||
toggle() {
|
||||
const id = stopwatchesStore.focusedId;
|
||||
if (id) stopwatchesStore.toggle(id);
|
||||
else {
|
||||
const newId = stopwatchesStore.create();
|
||||
stopwatchesStore.start(newId);
|
||||
}
|
||||
},
|
||||
lap() {
|
||||
const id = stopwatchesStore.focusedId;
|
||||
if (id) stopwatchesStore.lap(id);
|
||||
},
|
||||
reset() {
|
||||
const id = stopwatchesStore.focusedId;
|
||||
if (id) stopwatchesStore.reset(id);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format milliseconds to HH:MM:SS.ms
|
||||
*/
|
||||
export function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const centiseconds = Math.floor((ms % 1000) / 10);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format milliseconds to MM:SS.ms (short format for laps)
|
||||
*/
|
||||
export function formatLapTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const centiseconds = Math.floor((ms % 1000) / 10);
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
131
apps/clock/apps/web/src/lib/stores/theme.svelte.ts
Normal file
131
apps/clock/apps/web/src/lib/stores/theme.svelte.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Theme store for Clock app
|
||||
* Manages light/dark mode and theme variants
|
||||
* SSR-safe implementation
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
THEME_VARIANTS,
|
||||
type ThemeVariant,
|
||||
type ThemeMode,
|
||||
THEME_DEFINITIONS,
|
||||
} from '@manacore/shared-theme';
|
||||
|
||||
// Storage keys
|
||||
const MODE_KEY = 'clock-theme-mode';
|
||||
const VARIANT_KEY = 'clock-theme-variant';
|
||||
|
||||
// Default values for SSR
|
||||
const DEFAULT_MODE: ThemeMode = 'system';
|
||||
const DEFAULT_VARIANT: ThemeVariant = 'lume';
|
||||
|
||||
// State (only used client-side, but initialized for SSR)
|
||||
let mode = $state<ThemeMode>(DEFAULT_MODE);
|
||||
let variant = $state<ThemeVariant>(DEFAULT_VARIANT);
|
||||
let isDark = $state(false);
|
||||
let initialized = $state(false);
|
||||
|
||||
// Get system preference
|
||||
function getSystemPrefersDark(): boolean {
|
||||
if (!browser) return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// Apply theme to document
|
||||
function applyTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
// Determine if dark mode
|
||||
const currentMode = mode ?? DEFAULT_MODE;
|
||||
const shouldBeDark = currentMode === 'system' ? getSystemPrefersDark() : currentMode === 'dark';
|
||||
isDark = shouldBeDark;
|
||||
|
||||
// Apply to document
|
||||
const currentVariant = variant ?? DEFAULT_VARIANT;
|
||||
document.documentElement.classList.toggle('dark', shouldBeDark);
|
||||
document.documentElement.setAttribute('data-theme', currentVariant);
|
||||
}
|
||||
|
||||
// Listen for system preference changes (only in browser)
|
||||
if (browser) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if ((mode ?? DEFAULT_MODE) === 'system') {
|
||||
applyTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const theme = {
|
||||
// Getters (SSR-safe with fallbacks)
|
||||
get mode(): ThemeMode {
|
||||
return mode ?? DEFAULT_MODE;
|
||||
},
|
||||
get variant(): ThemeVariant {
|
||||
return variant ?? DEFAULT_VARIANT;
|
||||
},
|
||||
get isDark(): boolean {
|
||||
return isDark ?? false;
|
||||
},
|
||||
get variants(): readonly ThemeVariant[] {
|
||||
return THEME_VARIANTS;
|
||||
},
|
||||
get initialized(): boolean {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize theme from localStorage (client-side only)
|
||||
*/
|
||||
initialize() {
|
||||
if (!browser) return;
|
||||
if (initialized) return;
|
||||
|
||||
// Load saved preferences
|
||||
const savedMode = localStorage.getItem(MODE_KEY) as ThemeMode | null;
|
||||
const savedVariant = localStorage.getItem(VARIANT_KEY) as ThemeVariant | null;
|
||||
|
||||
if (savedMode && ['light', 'dark', 'system'].includes(savedMode)) {
|
||||
mode = savedMode;
|
||||
}
|
||||
|
||||
if (savedVariant && THEME_VARIANTS.includes(savedVariant)) {
|
||||
variant = savedVariant;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
applyTheme();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set theme mode
|
||||
*/
|
||||
setMode(newMode: ThemeMode) {
|
||||
mode = newMode;
|
||||
if (browser) {
|
||||
localStorage.setItem(MODE_KEY, newMode);
|
||||
}
|
||||
applyTheme();
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle between light and dark
|
||||
*/
|
||||
toggleMode() {
|
||||
const currentDark = isDark ?? false;
|
||||
const newMode = currentDark ? 'light' : 'dark';
|
||||
this.setMode(newMode);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set theme variant
|
||||
*/
|
||||
setVariant(newVariant: ThemeVariant) {
|
||||
if (!THEME_VARIANTS.includes(newVariant)) return;
|
||||
variant = newVariant;
|
||||
if (browser) {
|
||||
localStorage.setItem(VARIANT_KEY, newVariant);
|
||||
}
|
||||
applyTheme();
|
||||
},
|
||||
};
|
||||
154
apps/clock/apps/web/src/lib/stores/timers.svelte.ts
Normal file
154
apps/clock/apps/web/src/lib/stores/timers.svelte.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* Timers Store - Manages timer state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
|
||||
import { timersApi } from '$lib/api/timers';
|
||||
|
||||
// State
|
||||
let timers = $state<Timer[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const timersStore = {
|
||||
// Getters
|
||||
get timers() {
|
||||
return timers;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get activeTimers() {
|
||||
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
|
||||
},
|
||||
get runningTimers() {
|
||||
return timers.filter((t) => t.status === 'running');
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all timers from the API
|
||||
*/
|
||||
async fetchTimers() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await timersApi.getAll();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
timers = result.data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new timer
|
||||
*/
|
||||
async createTimer(input: CreateTimerInput) {
|
||||
const result = await timersApi.create(input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = [...timers, result.data];
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing timer
|
||||
*/
|
||||
async updateTimer(id: string, input: UpdateTimerInput) {
|
||||
const result = await timersApi.update(id, input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = timers.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a timer
|
||||
*/
|
||||
async deleteTimer(id: string) {
|
||||
const result = await timersApi.delete(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
timers = timers.filter((t) => t.id !== id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a timer
|
||||
*/
|
||||
async startTimer(id: string) {
|
||||
const result = await timersApi.start(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = timers.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause a timer
|
||||
*/
|
||||
async pauseTimer(id: string) {
|
||||
const result = await timersApi.pause(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = timers.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset a timer
|
||||
*/
|
||||
async resetTimer(id: string) {
|
||||
const result = await timersApi.reset(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
timers = timers.map((t) => (t.id === id ? result.data! : t));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Update local timer state (for countdown display)
|
||||
*/
|
||||
updateLocalTimer(id: string, remainingSeconds: number) {
|
||||
timers = timers.map((t) => (t.id === id ? { ...t, remainingSeconds } : t));
|
||||
},
|
||||
};
|
||||
46
apps/clock/apps/web/src/lib/stores/toast.ts
Normal file
46
apps/clock/apps/web/src/lib/stores/toast.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Toast notification store
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update } = writable<Toast[]>([]);
|
||||
|
||||
function addToast(message: string, type: Toast['type'] = 'info', duration = 5000) {
|
||||
const id = crypto.randomUUID();
|
||||
const toast: Toast = { id, message, type, duration };
|
||||
|
||||
update((toasts) => [...toasts, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeToast(id: string) {
|
||||
update((toasts) => toasts.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
success: (message: string, duration?: number) => addToast(message, 'success', duration),
|
||||
error: (message: string, duration?: number) => addToast(message, 'error', duration),
|
||||
info: (message: string, duration?: number) => addToast(message, 'info', duration),
|
||||
warning: (message: string, duration?: number) => addToast(message, 'warning', duration),
|
||||
remove: removeToast,
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
19
apps/clock/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
19
apps/clock/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* 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 { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'clock',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
120
apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts
Normal file
120
apps/clock/apps/web/src/lib/stores/world-clocks.svelte.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* World Clocks Store - Manages world clock state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
|
||||
import { worldClocksApi } from '$lib/api/world-clocks';
|
||||
|
||||
// State
|
||||
let worldClocks = $state<WorldClock[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const worldClocksStore = {
|
||||
// Getters
|
||||
get worldClocks() {
|
||||
return worldClocks;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get sortedWorldClocks() {
|
||||
return [...worldClocks].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all world clocks from the API
|
||||
*/
|
||||
async fetchWorldClocks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await worldClocksApi.getAll();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error;
|
||||
} else if (result.data) {
|
||||
worldClocks = result.data;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new world clock
|
||||
*/
|
||||
async addWorldClock(input: CreateWorldClockInput) {
|
||||
const result = await worldClocksApi.create(input);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
worldClocks = [...worldClocks, result.data];
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a world clock
|
||||
*/
|
||||
async removeWorldClock(id: string) {
|
||||
const result = await worldClocksApi.delete(id);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
worldClocks = worldClocks.filter((wc) => wc.id !== id);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder world clocks
|
||||
*/
|
||||
async reorderWorldClocks(ids: string[]) {
|
||||
const result = await worldClocksApi.reorder(ids);
|
||||
|
||||
if (result.error) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
worldClocks = result.data;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get time info for a timezone
|
||||
*/
|
||||
getTimeForTimezone(timezone: string) {
|
||||
try {
|
||||
const now = new Date();
|
||||
const formatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const localOffset = now.getTimezoneOffset();
|
||||
const targetDate = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
|
||||
const targetOffset = (now.getTime() - targetDate.getTime()) / (1000 * 60) + localOffset;
|
||||
|
||||
return {
|
||||
time: formatter.format(now),
|
||||
offsetHours: Math.round(-targetOffset / 60),
|
||||
};
|
||||
} catch {
|
||||
return { time: '--:--:--', offsetHours: 0 };
|
||||
}
|
||||
},
|
||||
};
|
||||
145
apps/clock/apps/web/src/routes/(app)/+error.svelte
Normal file
145
apps/clock/apps/web/src/routes/(app)/+error.svelte
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
function handleGoHome() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
function handleGoBack() {
|
||||
window.history.back();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Error - Clock</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="error-page">
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
{#if $page.status === 404}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h1>{$page.status || 500}</h1>
|
||||
|
||||
{#if $page.status === 404}
|
||||
<h2>Seite nicht gefunden</h2>
|
||||
<p>Die Seite, die du suchst, existiert nicht oder wurde verschoben.</p>
|
||||
{:else if $page.status === 500}
|
||||
<h2>Serverfehler</h2>
|
||||
<p>Es ist ein Fehler auf dem Server aufgetreten. Bitte versuche es später erneut.</p>
|
||||
{:else}
|
||||
<h2>Etwas ist schiefgelaufen</h2>
|
||||
<p>{$page.error?.message || 'Ein unerwarteter Fehler ist aufgetreten.'}</p>
|
||||
{/if}
|
||||
|
||||
<div class="error-actions">
|
||||
<button class="btn btn-primary" onclick={handleGoHome}> Zur Startseite </button>
|
||||
<button class="btn btn-secondary" onclick={handleGoBack}> Zurück </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: hsl(var(--color-background));
|
||||
}
|
||||
|
||||
.error-container {
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
margin: 0 auto 2rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 6rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary));
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0 0 2rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-page {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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